@jjlmoya/utils-civic 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +2 -1
- package/src/entries.ts +2 -0
- package/src/tests/locale_completeness.test.ts +1 -1
- package/src/tests/tool_validation.test.ts +1 -1
- package/src/tool/public-budget-analyzer/bibliography.astro +15 -0
- package/src/tool/public-budget-analyzer/bibliography.ts +7 -0
- package/src/tool/public-budget-analyzer/component.astro +52 -0
- package/src/tool/public-budget-analyzer/controller.ts +84 -0
- package/src/tool/public-budget-analyzer/dom-views.ts +52 -0
- package/src/tool/public-budget-analyzer/entry.ts +27 -0
- package/src/tool/public-budget-analyzer/evaluator.ts +15 -0
- package/src/tool/public-budget-analyzer/i18n/de.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/en.ts +78 -0
- package/src/tool/public-budget-analyzer/i18n/es.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/fr.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/id.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/it.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/ja.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/ko.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/nl.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/pl.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/pt.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/ru.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/sv.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/tr.ts +115 -0
- package/src/tool/public-budget-analyzer/i18n/zh.ts +115 -0
- package/src/tool/public-budget-analyzer/index.ts +11 -0
- package/src/tool/public-budget-analyzer/logic.test.ts +81 -0
- package/src/tool/public-budget-analyzer/logic.ts +249 -0
- package/src/tool/public-budget-analyzer/public-budget-analyzer.css +461 -0
- package/src/tool/public-budget-analyzer/seo.astro +15 -0
- package/src/tool/public-budget-analyzer/storage.ts +28 -0
- package/src/tool/public-budget-analyzer/ui.ts +78 -0
- package/src/tools.ts +2 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { FAQPage, HowTo, SoftwareApplication } from 'schema-dts';
|
|
2
|
+
import { bibliography } from '../bibliography';
|
|
3
|
+
import type { PublicBudgetLocaleContent } from '../entry';
|
|
4
|
+
import type { PublicBudgetUI } from '../ui';
|
|
5
|
+
|
|
6
|
+
const ui: PublicBudgetUI = {
|
|
7
|
+
uploadLabel: '将预算 CSV 或 JSON 文件拖到这里',
|
|
8
|
+
uploadHelp: '必填字段: 类别和金额。可选字段: 人口、期间、priorAmount、货币和父类别。',
|
|
9
|
+
loadExample: '加载示例预算',
|
|
10
|
+
supportedFormat: 'CSV 或 JSON,仅在此浏览器中处理',
|
|
11
|
+
tableHeading: '来源行和类别合计',
|
|
12
|
+
categoryLabel: '类别',
|
|
13
|
+
amountLabel: '金额',
|
|
14
|
+
populationLabel: '人口',
|
|
15
|
+
sourceRowsLabel: '来源行',
|
|
16
|
+
analyzeEmpty: '选择文件或加载示例来检查预算。',
|
|
17
|
+
resultHeading: '预算全景',
|
|
18
|
+
totalLabel: '总支出',
|
|
19
|
+
perCapitaLabel: '人均支出',
|
|
20
|
+
variationLabel: '与上一期间的变化',
|
|
21
|
+
topCategoryLabel: '最大类别',
|
|
22
|
+
shareLabel: '占总额比例',
|
|
23
|
+
sourceLabel: '来源行',
|
|
24
|
+
noData: '目前还没有有效的预算行。',
|
|
25
|
+
warningsHeading: '需要检查的警告',
|
|
26
|
+
errorsHeading: '未纳入的行',
|
|
27
|
+
methodHeading: '使用的方法',
|
|
28
|
+
methodText: '工具会验证必填字段,保留每一条来源行,合并相同的类别路径,并根据标准化金额计算合计。当总额为正时,类别占比等于类别金额除以总额。人均支出使用第一个一致的正人口值作为分母。期间变化等于当前金额减去上一金额,再除以上一金额的绝对值。',
|
|
29
|
+
limitsHeading: '这个工具不会做什么',
|
|
30
|
+
limitsText: '它不会证明预算完整、可比较、合法、高效、公平、无偏或分类正确。它不会审计来源,不会评价政策质量,不会换算货币,也不会判断某一行是拨款、债务、付款还是预测。',
|
|
31
|
+
edgeCasesHeading: '边界情况和数据警告',
|
|
32
|
+
edgeCasesText: '混合货币、变化的人口、重复类别、负数调整、父行和子行并存、本地数字格式以及上一期间金额为零,都需要人工复核。浏览器保留原始行号,因此每个合计都可以追溯到输入数据。',
|
|
33
|
+
downloadCsv: '下载已复核表格',
|
|
34
|
+
printAction: '打印分析',
|
|
35
|
+
rowsLabel: '有效行',
|
|
36
|
+
categoriesLabel: '类别',
|
|
37
|
+
positiveTotalLabel: '已可视化的正数金额',
|
|
38
|
+
previousTotalLabel: '上一期间合计',
|
|
39
|
+
invalidFile: '文件无法被解析为空非空的 CSV 或 JSON 表格。',
|
|
40
|
+
fileReadError: '此浏览器无法读取文件。',
|
|
41
|
+
exampleName: '社区预算示例',
|
|
42
|
+
noPreviousData: '没有提供可比较的上一期间金额。',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const softwareApplication: SoftwareApplication = {
|
|
46
|
+
'@type': 'SoftwareApplication',
|
|
47
|
+
name: '公共预算分析工具',
|
|
48
|
+
applicationCategory: 'EducationalApplication',
|
|
49
|
+
operatingSystem: 'Any',
|
|
50
|
+
description: '在本地查看公共支出、类别占比、人均金额以及期间变化。',
|
|
51
|
+
url: 'https://gamebob.dev/zh/gonggong-yusuan-zhichu-fenxi',
|
|
52
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const faqPage: FAQPage = {
|
|
56
|
+
'@type': 'FAQPage',
|
|
57
|
+
mainEntity: [
|
|
58
|
+
{ '@type': 'Question', name: '分析需要哪些列?', acceptedAnswer: { '@type': 'Answer', text: '每一行都需要类别和数字金额。人口、期间、priorAmount、货币和父类别是可选的。' } },
|
|
59
|
+
{ '@type': 'Question', name: '人均支出如何计算?', acceptedAnswer: { '@type': 'Answer', text: '总金额除以表格中第一个一致的正人口值。不同的分母会保留并产生警告。' } },
|
|
60
|
+
{ '@type': 'Question', name: '重复类别会怎样?', acceptedAnswer: { '@type': 'Answer', text: '拥有相同类别路径的行会相加,同时保留来源行号,以便区分真实拆分和重复计算。' } },
|
|
61
|
+
{ '@type': 'Question', name: '它能证明预算很好或公平吗?', acceptedAnswer: { '@type': 'Answer', text: '不能。它只描述提供的数字,不判断完整性、合法性、效率、公平性、分类或结果。' } },
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const howTo: HowTo = {
|
|
66
|
+
'@type': 'HowTo',
|
|
67
|
+
name: '分析公共预算表',
|
|
68
|
+
step: [
|
|
69
|
+
{ '@type': 'HowToStep', name: '准备表格', text: '创建 CSV 或 JSON 表格,每行包含一个类别和金额。保持货币和会计口径一致。' },
|
|
70
|
+
{ '@type': 'HowToStep', name: '补充背景字段', text: '只有在含义一致时,才添加人口、期间、priorAmount、货币或父类别。' },
|
|
71
|
+
{ '@type': 'HowToStep', name: '加载文件', text: '将文件拖入工具,或加载示例来查看预期结构和本地验证结果。' },
|
|
72
|
+
{ '@type': 'HowToStep', name: '阅读全景', text: '用比例条查看构成,用可访问表格查看准确金额、变化和来源行。' },
|
|
73
|
+
{ '@type': 'HowToStep', name: '检查警告', text: '得出结论前,先处理混合货币、重复路径、负数调整、零分母和父行。' },
|
|
74
|
+
],
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const content: PublicBudgetLocaleContent = {
|
|
78
|
+
slug: 'public-budget-analyzer',
|
|
79
|
+
title: '公共预算分析工具',
|
|
80
|
+
description: '在本地分析公共预算表,查看总支出、类别占比、人均支出、期间变化以及每个合计背后的来源行。',
|
|
81
|
+
ui,
|
|
82
|
+
seo: [
|
|
83
|
+
{ type: 'title', text: '把公共预算表变成可核查的视图', level: 2 },
|
|
84
|
+
{ type: 'paragraph', html: '公共预算文件经常把类别标签、会计期间、人口分母和调整金额混在一张难以快速阅读的表中。这个分析工具保留来源行,验证必填字段,并将同一份标准化数据转换为合计、类别比例、人均支出和期间变化。' },
|
|
85
|
+
{ type: 'paragraph', html: '结果是对数字的描述,不是政策判断。一个较大的类别可能来自法定职责、一次性投资、转移支付或不同的会计边界。这个视图让这些模式可见,帮助你带着更具体的问题回到原始资料。' },
|
|
86
|
+
{ type: 'title', text: '计算结果代表什么', level: 2 },
|
|
87
|
+
{ type: 'paragraph', html: '总额是所有有效数字金额的总和,包括负数调整。当总额为正时,类别占比是类别合计除以总额。正数金额使用比例条表示,负数金额留在表格中,因为用正面积表示会改变它的含义。' },
|
|
88
|
+
{ type: 'table', headers: ['结果', '计算', '核查问题'], rows: [['总支出', '标准化金额之和', '所有行是否使用相同的会计口径?'], ['类别占比', '类别金额除以正数总额', '类别是否互斥,父类别是否和子类别重叠?'], ['人均支出', '总额除以正人口分母', '人口是否对应同一个地点和期间?'], ['期间变化', '当前金额减去上一金额,再除以上一金额绝对值', '期间是否可比,是否避免了零基数?']] },
|
|
89
|
+
{ type: 'title', text: '准备可比较的数据', level: 2 },
|
|
90
|
+
{ type: 'paragraph', html: '每一行使用同一种货币,并明确金额的含义。预算授权、债务、付款和预测都可能是有效数字,却回答不同的问题。请在来源旁记录这种含义,因为工具无法从列名推断它。' },
|
|
91
|
+
{ type: 'list', items: ['每个类别使用一行,或提供父类别来建立有意设计的层级。', '保持人口口径一致,不要混合居民、选民、家庭或服务使用者。', '只有上一期间使用相同类别和口径时,才加入 priorAmount。', '检查重复类别路径和父行,避免意外重复计算。', '将复核后的表格与原始来源和使用的假设一起保存。'] },
|
|
92
|
+
{ type: 'tip', title: '比例不是优先级评分', html: '金额最大的类别不一定最重要、最浪费或最成功。用这个视图寻找规模和变化,然后阅读来源注释和服务结果。' },
|
|
93
|
+
{ type: 'title', text: '得出结论前先阅读警告', level: 2 },
|
|
94
|
+
{ type: 'paragraph', html: '重复路径会被合并,避免合计在没有提示的情况下偏低,但来源行列表可以帮助你确认这种合并是否有意。混合货币不会被换算。人口为零时不显示人均结果,上一金额为零时不显示变化百分比,因为除以零没有有意义的解释。' },
|
|
95
|
+
{ type: 'paragraph', html: '嵌套类别尤其需要注意。如果表格同时把教育和教育 > 学校作为金额,直接相加可能把同一笔支出计算两次。工具会提示嵌套路径,但只有来源方法能够说明父行是小计还是独立项目。' },
|
|
96
|
+
{ type: 'title', text: '第一次分析之后要做什么', level: 2 },
|
|
97
|
+
{ type: 'paragraph', html: '使用行号回到官方文件,记录每一个修正或解释。比较含义相同的期间,保留负数调整,并在来源确认小计已经包含其中时删除小计后重新分析。' },
|
|
98
|
+
{ type: 'tip', title: '保持数据边界清晰', html: '这是本地描述性分析。它不验证文件是否完整,不按官方标准进行支出分类,不协调货币,也不证明支出与结果之间存在因果关系。' },
|
|
99
|
+
],
|
|
100
|
+
faq: [
|
|
101
|
+
{ question: '分析需要哪些列?', answer: '每行需要类别和数字金额。人口、期间、priorAmount、货币和父类别是可选的。' },
|
|
102
|
+
{ question: '人均支出如何计算?', answer: '总额除以第一个一致的正人口值。分母不一致时会产生警告。' },
|
|
103
|
+
{ question: '重复类别会怎样?', answer: '相同类别路径会相加,同时显示来源行以便检查。' },
|
|
104
|
+
{ question: '它能证明预算很好或公平吗?', answer: '不能。它描述提供的数字,不能判断完整性、合法性、效率、公平性或结果。' },
|
|
105
|
+
],
|
|
106
|
+
bibliography,
|
|
107
|
+
howTo: [
|
|
108
|
+
{ name: '准备表格', text: '创建每行包含类别和金额的 CSV 或 JSON,并使用相同货币和会计口径。' },
|
|
109
|
+
{ name: '补充背景字段', text: '只有在含义一致时才加入人口、期间、priorAmount、货币或父类别。' },
|
|
110
|
+
{ name: '加载文件', text: '拖入文件或加载示例来查看预期结构。' },
|
|
111
|
+
{ name: '阅读全景', text: '用比例条查看构成,用表格查看数值、变化和来源行。' },
|
|
112
|
+
{ name: '检查警告', text: '下结论前先处理混合货币、重复项、负数调整、零分母和父行。' },
|
|
113
|
+
],
|
|
114
|
+
schemas: [softwareApplication, faqPage, howTo] as unknown as Record<string, unknown>[],
|
|
115
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { publicBudgetAnalyzer } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
export * from './logic';
|
|
6
|
+
export const PUBLIC_BUDGET_ANALYZER_TOOL: ToolDefinition = {
|
|
7
|
+
entry: publicBudgetAnalyzer,
|
|
8
|
+
Component: () => import('./component.astro'),
|
|
9
|
+
SEOComponent: () => import('./seo.astro'),
|
|
10
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
11
|
+
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { analyzeBudgetRecords, parseBudgetText } from './logic';
|
|
3
|
+
import { reviewedCsv } from './dom-views';
|
|
4
|
+
|
|
5
|
+
describe('public budget analyzer logic', () => {
|
|
6
|
+
it('parses CSV with quoted special characters and decimal values', () => {
|
|
7
|
+
const parsed = parseBudgetText('category,amount\n"Parks, east","1.234,50"');
|
|
8
|
+
expect(parsed.errors).toEqual([]);
|
|
9
|
+
expect(parsed.records[0]?.category).toBe('Parks, east');
|
|
10
|
+
expect(parsed.records[0]?.amount).toBe('1.234,50');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('parses JSON arrays and data envelopes', () => {
|
|
14
|
+
expect(parseBudgetText('[{"category":"Health","amount":10}]').records).toHaveLength(1);
|
|
15
|
+
expect(parseBudgetText('{"data":[{"category":"Health","amount":10}]}', 'json').records).toHaveLength(1);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('reports empty and malformed inputs', () => {
|
|
19
|
+
expect(parseBudgetText('').errors).toContain('The file is empty.');
|
|
20
|
+
expect(parseBudgetText('category,amount\nParks', 'csv').errors).toEqual([]);
|
|
21
|
+
expect(parseBudgetText('category,amount\n"Parks,10', 'csv').errors).toContain('CSV contains an unclosed quoted field.');
|
|
22
|
+
expect(parseBudgetText('{bad', 'json').errors).toContain('JSON could not be parsed.');
|
|
23
|
+
expect(parseBudgetText('{"data":[1]}', 'json').errors).toContain('JSON must be an array of objects or an object with a data array.');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('calculates totals, shares, per capita values, and period changes', () => {
|
|
27
|
+
const analysis = analyzeBudgetRecords([
|
|
28
|
+
{ category: 'Education', amount: '1,000.50', population: 100, priorAmount: 800, currency: 'EUR', period: '2026' },
|
|
29
|
+
{ category: 'Health', amount: 500, population: 100, priorAmount: 600, currency: 'EUR', period: '2026' },
|
|
30
|
+
]);
|
|
31
|
+
expect(analysis.total).toBe(1500.5);
|
|
32
|
+
expect(analysis.perCapita).toBe(15.005);
|
|
33
|
+
expect(analysis.variation).toBeCloseTo(0.0717857);
|
|
34
|
+
expect(analysis.categories[0]?.share).toBeCloseTo(1000.5 / 1500.5);
|
|
35
|
+
expect(analysis.currency).toBe('EUR');
|
|
36
|
+
expect(analysis.period).toBe('2026');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('groups duplicate paths, retains source rows, and handles hierarchy', () => {
|
|
40
|
+
const analysis = analyzeBudgetRecords([
|
|
41
|
+
{ category: 'Education > Schools', amount: 50 },
|
|
42
|
+
{ category: 'Schools', parentCategory: 'Education', amount: 25 },
|
|
43
|
+
{ category: 'Housing', amount: 10 },
|
|
44
|
+
]);
|
|
45
|
+
expect(analysis.categories[0]?.amount).toBe(75);
|
|
46
|
+
expect(analysis.categories[0]?.sourceRows).toEqual([2, 3]);
|
|
47
|
+
expect(analysis.warnings.some((warning) => warning.includes('Duplicate category paths'))).toBe(true);
|
|
48
|
+
expect(analysis.warnings.some((warning) => warning.includes('Nested paths'))).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps negative adjustments and warns about zero denominators and baselines', () => {
|
|
52
|
+
const analysis = analyzeBudgetRecords([
|
|
53
|
+
{ category: 'Adjustment', amount: -20, population: 0, priorAmount: 0 },
|
|
54
|
+
{ category: 'Services', amount: 20, population: 200, priorAmount: 100 },
|
|
55
|
+
]);
|
|
56
|
+
expect(analysis.total).toBe(0);
|
|
57
|
+
expect(analysis.positiveTotal).toBe(20);
|
|
58
|
+
expect(analysis.perCapita).toBe(0);
|
|
59
|
+
expect(analysis.variation).toBe(-1);
|
|
60
|
+
expect(analysis.warnings.join(' ')).toContain('Negative amounts');
|
|
61
|
+
expect(analysis.warnings.join(' ')).toContain('Population contains a non-positive value');
|
|
62
|
+
expect(analysis.warnings.join(' ')).toContain('total is not positive');
|
|
63
|
+
expect(analysis.warnings.join(' ')).toContain('zero prior amount');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps valid rows while reporting missing categories and amounts', () => {
|
|
67
|
+
const analysis = analyzeBudgetRecords([
|
|
68
|
+
{ category: 'Valid', amount: 12 },
|
|
69
|
+
{ category: '', amount: 4 },
|
|
70
|
+
{ category: 'Missing amount', amount: 'unknown' },
|
|
71
|
+
{ category: 'Bad population', amount: 2, population: 'many' },
|
|
72
|
+
]);
|
|
73
|
+
expect(analysis.rows).toHaveLength(1);
|
|
74
|
+
expect(analysis.errors).toHaveLength(3);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('escapes reviewed CSV values', () => {
|
|
78
|
+
const analysis = analyzeBudgetRecords([{ category: 'Culture, "north"', amount: 12 }]);
|
|
79
|
+
expect(reviewedCsv(analysis)).toContain('"Culture, ""north"""');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
export interface RawBudgetRecord {
|
|
2
|
+
[key: string]: unknown;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export interface ParsedBudgetText {
|
|
6
|
+
format: 'csv' | 'json';
|
|
7
|
+
records: RawBudgetRecord[];
|
|
8
|
+
errors: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NormalizedBudgetRow {
|
|
12
|
+
category: string;
|
|
13
|
+
path: string[];
|
|
14
|
+
amount: number;
|
|
15
|
+
population?: number | undefined;
|
|
16
|
+
priorAmount?: number | undefined;
|
|
17
|
+
period?: string | undefined;
|
|
18
|
+
currency?: string | undefined;
|
|
19
|
+
sourceRow: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BudgetCategorySummary {
|
|
23
|
+
category: string;
|
|
24
|
+
path: string[];
|
|
25
|
+
amount: number;
|
|
26
|
+
share?: number | undefined;
|
|
27
|
+
priorAmount?: number | undefined;
|
|
28
|
+
variation?: number | undefined;
|
|
29
|
+
sourceRows: number[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface BudgetAnalysis {
|
|
33
|
+
rows: NormalizedBudgetRow[];
|
|
34
|
+
categories: BudgetCategorySummary[];
|
|
35
|
+
total: number;
|
|
36
|
+
positiveTotal: number;
|
|
37
|
+
previousTotal?: number | undefined;
|
|
38
|
+
population?: number | undefined;
|
|
39
|
+
perCapita?: number | undefined;
|
|
40
|
+
variation?: number | undefined;
|
|
41
|
+
currency?: string | undefined;
|
|
42
|
+
period?: string | undefined;
|
|
43
|
+
warnings: string[];
|
|
44
|
+
errors: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const aliases = {
|
|
48
|
+
category: ['category', 'categoryname', 'name', 'label', 'programme', 'program', 'function'],
|
|
49
|
+
amount: ['amount', 'spending', 'expenditure', 'expense', 'value', 'total'],
|
|
50
|
+
population: ['population', 'inhabitants', 'residents'],
|
|
51
|
+
priorAmount: ['prioramount', 'previousamount', 'prior', 'previous', 'lastperiod'],
|
|
52
|
+
period: ['period', 'year', 'fiscalyear', 'date'],
|
|
53
|
+
currency: ['currency', 'currencycode', 'unit'],
|
|
54
|
+
parent: ['parentcategory', 'parent', 'group'],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function normalizeKey(value: string): string {
|
|
58
|
+
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findKey(record: RawBudgetRecord, names: string[]): string | undefined {
|
|
62
|
+
const keys = Object.keys(record);
|
|
63
|
+
const normalized = new Map(keys.map((key) => [normalizeKey(key), key]));
|
|
64
|
+
return names.map(normalizeKey).map((name) => normalized.get(name)).find(Boolean);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readValue(record: RawBudgetRecord, names: string[]): unknown {
|
|
68
|
+
const key = findKey(record, names);
|
|
69
|
+
return key ? record[key] : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseNumber(value: unknown): number | undefined {
|
|
73
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
|
|
74
|
+
if (typeof value !== 'string' || !value.trim()) return undefined;
|
|
75
|
+
const compact = value.trim().replace(/\s/g, '').replace(/[^\d,.-]/g, '');
|
|
76
|
+
if (!compact) return undefined;
|
|
77
|
+
const comma = compact.lastIndexOf(',');
|
|
78
|
+
const dot = compact.lastIndexOf('.');
|
|
79
|
+
const normalized = comma > dot ? compact.replace(/\./g, '').replace(',', '.') : compact.replace(/,/g, '');
|
|
80
|
+
const result = Number(normalized);
|
|
81
|
+
return Number.isFinite(result) ? result : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function splitPath(value: string): string[] {
|
|
85
|
+
return value.split(/\s*(?:>|\/|\\|:)\s*/).map((part) => part.trim()).filter(Boolean);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function validateRow(record: RawBudgetRecord, category: string, amount: number | undefined, sourceRow: number): string | undefined {
|
|
89
|
+
if (!category) return `Row ${sourceRow}: category is empty.`;
|
|
90
|
+
if (amount === undefined) return `Row ${sourceRow}: amount is missing or not numeric.`;
|
|
91
|
+
if (hasInvalidOptional(record, aliases.population)) return `Row ${sourceRow}: population is not numeric.`;
|
|
92
|
+
if (hasInvalidOptional(record, aliases.priorAmount)) return `Row ${sourceRow}: priorAmount is not numeric.`;
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizeRow(record: RawBudgetRecord, sourceRow: number): { row?: NormalizedBudgetRow; error?: string } {
|
|
97
|
+
const categoryValue = readValue(record, aliases.category);
|
|
98
|
+
const amountValue = readValue(record, aliases.amount);
|
|
99
|
+
const category = typeof categoryValue === 'string' ? categoryValue.trim() : String(categoryValue ?? '').trim();
|
|
100
|
+
const amount = parseNumber(amountValue);
|
|
101
|
+
const error = validateRow(record, category, amount, sourceRow);
|
|
102
|
+
if (error) return { error };
|
|
103
|
+
if (amount === undefined) return { error: `Row ${sourceRow}: amount is missing or not numeric.` };
|
|
104
|
+
const parentValue = readValue(record, aliases.parent);
|
|
105
|
+
const parent = typeof parentValue === 'string' ? splitPath(parentValue) : [];
|
|
106
|
+
const path = [...parent, ...splitPath(category)];
|
|
107
|
+
const population = parseOptional(record, aliases.population);
|
|
108
|
+
const priorAmount = parseOptional(record, aliases.priorAmount);
|
|
109
|
+
const period = stringValue(readValue(record, aliases.period));
|
|
110
|
+
const currency = stringValue(readValue(record, aliases.currency));
|
|
111
|
+
return { row: { category: path[path.length - 1] || category, path, amount, population, priorAmount, period, currency, sourceRow } };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseOptional(record: RawBudgetRecord, names: string[]): number | undefined {
|
|
115
|
+
const value = readValue(record, names);
|
|
116
|
+
return value === undefined || value === null || value === '' ? undefined : parseNumber(value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasInvalidOptional(record: RawBudgetRecord, names: string[]): boolean {
|
|
120
|
+
const value = readValue(record, names);
|
|
121
|
+
return value !== undefined && value !== null && value !== '' && parseNumber(value) === undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function stringValue(value: unknown): string | undefined {
|
|
125
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseCsvLine(line: string): string[] {
|
|
129
|
+
const row: string[] = [];
|
|
130
|
+
let cell = '';
|
|
131
|
+
let quoted = false;
|
|
132
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
133
|
+
const char = line[index];
|
|
134
|
+
const next = line[index + 1];
|
|
135
|
+
if (char === '"' && quoted && next === '"') { cell += '"'; index += 1; continue; }
|
|
136
|
+
if (char === '"') { quoted = !quoted; continue; }
|
|
137
|
+
if (char === ',' && !quoted) { row.push(cell.trim()); cell = ''; continue; }
|
|
138
|
+
cell += char;
|
|
139
|
+
}
|
|
140
|
+
row.push(cell.trim());
|
|
141
|
+
return row;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseCsvRows(text: string): string[][] {
|
|
145
|
+
return text.split(/\r?\n/).map(parseCsvLine).filter((row) => row.some(Boolean));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function hasUnclosedQuotes(line: string): boolean {
|
|
149
|
+
return ((line.match(/"/g) || []).length % 2) === 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function recordsFromCsv(text: string): ParsedBudgetText {
|
|
153
|
+
if (text.split(/\r?\n/).some(hasUnclosedQuotes)) return { format: 'csv', records: [], errors: ['CSV contains an unclosed quoted field.'] };
|
|
154
|
+
const rows = parseCsvRows(text);
|
|
155
|
+
if (rows.length < 2) return { format: 'csv', records: [], errors: ['CSV needs a header row and at least one data row.'] };
|
|
156
|
+
const headers = rows[0] || [];
|
|
157
|
+
const records = rows.slice(1).map((values) => Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ''])));
|
|
158
|
+
return { format: 'csv', records, errors: [] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function recordsFromJson(text: string): ParsedBudgetText {
|
|
162
|
+
try {
|
|
163
|
+
const parsed: unknown = JSON.parse(text);
|
|
164
|
+
const data = Array.isArray(parsed) ? parsed : objectValue(parsed)?.data;
|
|
165
|
+
if (!Array.isArray(data) || !data.every(isRecord)) return { format: 'json', records: [], errors: ['JSON must be an array of objects or an object with a data array.'] };
|
|
166
|
+
return { format: 'json', records: data, errors: [] };
|
|
167
|
+
} catch {
|
|
168
|
+
return { format: 'json', records: [], errors: ['JSON could not be parsed.'] };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function objectValue(value: unknown): RawBudgetRecord | undefined {
|
|
173
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as RawBudgetRecord : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isRecord(value: unknown): value is RawBudgetRecord {
|
|
177
|
+
return Boolean(objectValue(value));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function parseBudgetText(text: string, format?: 'csv' | 'json'): ParsedBudgetText {
|
|
181
|
+
const clean = text.trim();
|
|
182
|
+
if (!clean) return { format: format ?? 'csv', records: [], errors: ['The file is empty.'] };
|
|
183
|
+
const inferred = format ?? (clean.startsWith('{') || clean.startsWith('[') ? 'json' : 'csv');
|
|
184
|
+
return inferred === 'json' ? recordsFromJson(clean) : recordsFromCsv(clean);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function uniqueNumbers(rows: NormalizedBudgetRow[], property: 'population' | 'priorAmount'): number[] {
|
|
188
|
+
return [...new Set(rows.map((row) => row[property]).filter((value): value is number => value !== undefined))];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function groupCategories(rows: NormalizedBudgetRow[], total: number): BudgetCategorySummary[] {
|
|
192
|
+
const grouped = new Map<string, BudgetCategorySummary>();
|
|
193
|
+
rows.forEach((row) => {
|
|
194
|
+
const key = row.path.join(' > ');
|
|
195
|
+
const current = grouped.get(key) ?? { category: row.category, path: row.path, amount: 0, sourceRows: [] };
|
|
196
|
+
current.amount += row.amount;
|
|
197
|
+
if (row.priorAmount !== undefined) current.priorAmount = (current.priorAmount ?? 0) + row.priorAmount;
|
|
198
|
+
current.sourceRows.push(row.sourceRow);
|
|
199
|
+
grouped.set(key, current);
|
|
200
|
+
});
|
|
201
|
+
return [...grouped.values()].map((category) => ({
|
|
202
|
+
...category,
|
|
203
|
+
share: total > 0 ? category.amount / total : undefined,
|
|
204
|
+
variation: category.priorAmount !== undefined && category.priorAmount !== 0 ? (category.amount - category.priorAmount) / Math.abs(category.priorAmount) : undefined,
|
|
205
|
+
})).sort((left, right) => right.amount - left.amount);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function collectWarnings(rows: NormalizedBudgetRow[], categories: BudgetCategorySummary[], total: number): string[] {
|
|
209
|
+
const warnings: string[] = [];
|
|
210
|
+
const duplicate = categories.filter((category) => category.sourceRows.length > 1).map((category) => category.path.join(' > '));
|
|
211
|
+
if (duplicate.length) warnings.push(`Duplicate category paths were grouped: ${duplicate.join(', ')}.`);
|
|
212
|
+
const populations = uniqueNumbers(rows, 'population');
|
|
213
|
+
if (populations.length > 1) warnings.push('More than one population denominator was found. Per-capita spending uses the first value and needs review.');
|
|
214
|
+
if (populations.some((population) => population <= 0)) warnings.push('Population contains a non-positive value, so the denominator needs review.');
|
|
215
|
+
if (rows.some((row) => row.amount < 0)) warnings.push('Negative amounts are preserved as adjustments but are excluded from the positive visual composition.');
|
|
216
|
+
if (total <= 0) warnings.push('The total is not positive, so category shares are not shown.');
|
|
217
|
+
if (rows.some((row) => row.priorAmount === 0)) warnings.push('A zero prior amount has no percentage variation and is shown without a change rate.');
|
|
218
|
+
if (rows.some((row) => row.path.length > 1)) warnings.push('Nested paths are grouped by the exact path shown in the source data. Check that parent rows are not also counted as child totals.');
|
|
219
|
+
return warnings;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function analyzeBudgetRecords(records: RawBudgetRecord[]): BudgetAnalysis {
|
|
223
|
+
const normalized = records.map((record, index) => normalizeRow(record, index + 2));
|
|
224
|
+
const errors = normalized.flatMap((result) => result.error ? [result.error] : []);
|
|
225
|
+
const rows = normalized.flatMap((result) => result.row ? [result.row] : []);
|
|
226
|
+
const total = rows.reduce((sum, row) => sum + row.amount, 0);
|
|
227
|
+
const positiveTotal = rows.reduce((sum, row) => sum + (row.amount > 0 ? row.amount : 0), 0);
|
|
228
|
+
const categories = groupCategories(rows, total);
|
|
229
|
+
const populations = uniqueNumbers(rows, 'population');
|
|
230
|
+
const validPopulation = populations.find((value) => value > 0);
|
|
231
|
+
const previousTotal = rows.some((row) => row.priorAmount !== undefined) ? rows.reduce((sum, row) => sum + (row.priorAmount ?? 0), 0) : undefined;
|
|
232
|
+
const currencies = [...new Set(rows.map((row) => row.currency).filter(Boolean))];
|
|
233
|
+
const periods = [...new Set(rows.map((row) => row.period).filter(Boolean))];
|
|
234
|
+
const warnings = collectWarnings(rows, categories, total);
|
|
235
|
+
if (currencies.length > 1) warnings.push('More than one currency label was found. Values have not been converted.');
|
|
236
|
+
if (currencies.some((currency) => !/^[A-Za-z]{3}$/.test(currency || ''))) warnings.push('At least one currency label is not a three-letter code. Values have not been converted.');
|
|
237
|
+
if (periods.length > 1) warnings.push('More than one period label was found. The first period is shown and the source periods need review.');
|
|
238
|
+
return { rows, categories, total, positiveTotal, previousTotal, population: validPopulation, perCapita: validPopulation ? total / validPopulation : undefined, variation: previousTotal !== undefined && previousTotal !== 0 ? (total - previousTotal) / Math.abs(previousTotal) : undefined, currency: currencies[0], period: periods[0], warnings, errors };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function exampleBudgetRecords(): RawBudgetRecord[] {
|
|
242
|
+
return [
|
|
243
|
+
{ category: 'Education > Schools', amount: 480000, population: 24000, period: '2026', priorAmount: 430000, currency: 'EUR' },
|
|
244
|
+
{ category: 'Health > Clinics', amount: 310000, population: 24000, period: '2026', priorAmount: 290000, currency: 'EUR' },
|
|
245
|
+
{ category: 'Housing', amount: 220000, population: 24000, period: '2026', priorAmount: 250000, currency: 'EUR' },
|
|
246
|
+
{ category: 'Parks', amount: 145000, population: 24000, period: '2026', priorAmount: 130000, currency: 'EUR' },
|
|
247
|
+
{ category: 'Culture', amount: 95000, population: 24000, period: '2026', priorAmount: 90000, currency: 'EUR' },
|
|
248
|
+
];
|
|
249
|
+
}
|