dsh-progress-payment 0.0.0-stage → 0.1.0-alpha.1

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.
@@ -0,0 +1,75 @@
1
+ # 故障排查 — dsh-progress-payment
2
+
3
+ 每条按「症状 / 原因 / 动作」写。报错原文都是 `<一句人话>(E_XXX)|下一步:<动作>`,
4
+ 把 `E_XXX` 和这句话一起发到 issue 即可定位。不要把真实申报或合同金额贴进 issue。
5
+
6
+ ## 1. CSV 缺列或某行读不懂(E_PROGRESS_CSV_ROW)
7
+
8
+ - **症状**:`申报或合同 CSV 的表头/某一行读不懂(E_PROGRESS_CSV_ROW)|下一步:按 README 的列名补齐表头;数量写纯数字(最多 3 位小数),单价写金额(最多 2 位小数),不要带单位|细节:申报 CSV 缺少列:cumulative_qty;表头是:…`
9
+ - **原因**:表头少了 7 列中的某一列;或某一行数量/单价为空、带单位、负数、超过小数位;或合同基准里同一清单编码出现两次。
10
+ - **动作**:对照 `samples/quickstart/input/` 的表头;数量写 `25.92`,单价写 `480.00`,不要写 `25.92m3` 或 `¥480`。合同基准一条清单只能一行。Excel 请另存为 CSV UTF-8。
11
+
12
+ ## 2. 空 CSV(E_PROGRESS_CSV_EMPTY)
13
+
14
+ - **症状**:`这个 CSV 是空的,既没有表头也没有数据(E_PROGRESS_CSV_EMPTY)|下一步:确认文件不是 0 字节,并且第一行是表头、后面至少有一行数据`
15
+ - **原因**:文件 0 字节、只有空行,或导出时选错了工作表。
16
+ - **动作**:确认文件大小 > 0,第一行是表头;重新从 Excel 导出。
17
+
18
+ ## 3. 超合同量(E_PROGRESS_OVER_QTY)
19
+
20
+ - **症状**:`累计完成量 101.5 超过合同量 95(E_PROGRESS_OVER_QTY)|下一步:核对合同量和变更签证;超量部分走变更或结算,不在本期支付`
21
+ - **原因**:申报的本期或累计完成量大于合同基准数量;常见于变更未入合同、上期累计填错、或单位换算错误(m³ / m² / t)。
22
+ - **动作**:以合同基准为准核对;确属变更的,先办变更或签证再报量;单位不一致先统一单位。超量部分不会自动从本期金额扣减,需要人工签认。
23
+
24
+ ## 4. 单价与合同不符(E_PROGRESS_PRICE_MISMATCH)
25
+
26
+ - **症状**:`申报合同单价 65.00 与合同基准 55.00 不符(E_PROGRESS_PRICE_MISMATCH)|下一步:本期金额按合同单价 55.00 重算,并要求申报方改正`
27
+ - **原因**:申报表里的合同单价被改动,或合同基准版本过期。
28
+ - **动作**:本期金额已按合同单价重算;让申报方改正单价,或确认哪一版合同才是现行版本。`price_tolerance_bps` 只用于允许的报价浮动,不要用它掩盖错价。
29
+
30
+ ## 5. 重复清单项(E_PROGRESS_DUP_ITEM)
31
+
32
+ - **症状**:`清单编码 010101003 在本期申报里出现 2 次(行 2、7)(E_PROGRESS_DUP_ITEM)|下一步:确认哪一行是本期应报的;重复行不计入本期金额`
33
+ - **原因**:申报表复制粘贴重复、多个标段合并时编码冲突,或同一清单拆成两行报量。
34
+ - **动作**:确认哪一行是本期应报;底稿里重复行已标 `是否计入=否`,不会重复计钱。不要直接删原始申报文件。
35
+
36
+ ## 6. 合同基准缺清单 / 合同量不符(E_PROGRESS_CONTRACT_MISSING / E_PROGRESS_CONTRACT_MISMATCH)
37
+
38
+ - **症状**:`清单编码 Z-9 不在合同基准里(E_PROGRESS_CONTRACT_MISSING)|下一步:先补合同基准;补不了就单独确认这条清单的合同量和单价,不要直接计入本期`;或 `申报合同量 120 与合同基准 100 不符(E_PROGRESS_CONTRACT_MISMATCH)|下一步:以合同基准为准改正申报;确需调整先办合同变更`
39
+ - **原因**:新增清单没有进合同基准,或申报表里的合同量/单价与基准版本不一致。
40
+ - **动作**:补齐或更新合同基准;`CONTRACT_MISSING` 时本包会回退用申报值计算并标问题,不能当作已核验。
41
+
42
+ ## 7. 累计小于本期(E_PROGRESS_QTY_INCONSISTENT)
43
+
44
+ - **症状**:`累计完成量 10 小于本期完成量 20(E_PROGRESS_QTY_INCONSISTENT)|下一步:核对上期累计数;累计完成量应当大于或等于本期完成量`
45
+ - **原因**:上期累计结转错误,或本期完成量填成了合同量。
46
+ - **动作**:核对上期已批累计;累计应 ≥ 本期。改完重新导入,不要手改已导出的底稿。
47
+
48
+ ## 8. 找不到这批申报(E_PROGRESS_BATCH_NOT_FOUND)
49
+
50
+ - **症状**:`找不到这批申报(E_PROGRESS_BATCH_NOT_FOUND)|下一步:先运行 progress.ingest 导入 CSV 拿到 batch_id,再运行 progress.audit 或 progress.export`
51
+ - **原因**:`batch_id` 拼错,或换了会话/进程(批次只存在当前会话内存里)。
52
+ - **动作**:重新运行 `progress.ingest` 拿到新的 `batch_id`,再做 `audit` / `export`。
53
+
54
+ ## 9. 审核参数不合法(E_PROGRESS_OPTION_INVALID)
55
+
56
+ - **症状**:`审核参数不合法(E_PROGRESS_OPTION_INVALID)|下一步:price_tolerance_bps 传 0 或正整数(单位:万分之一),不传表示 0`
57
+ - **原因**:`price_tolerance_bps` 传了负数、NaN 或小数。
58
+ - **动作**:不传(默认 0,精确比对)或传 `100`、`2000` 这样的非负整数。
59
+
60
+ ## 10. 导出文件名不安全 / 写不出去(E_PROGRESS_PATH_INVALID / E_PROGRESS_PATH_NOT_ALLOWED / E_PROGRESS_EXPORT_FAILED)
61
+
62
+ - **症状**:`导出文件名不安全(E_PROGRESS_PATH_INVALID)|下一步:只传一个由字母、数字、点、下划线或短横线组成的文件名`;或 `审核底稿没有写出去(E_PROGRESS_EXPORT_FAILED)`
63
+ - **原因**:`filename` 带 `../`、`/`、反斜杠、空格、中文或超长;或 `$DSH_HOME/dsh-progress-payment` 只读 / 磁盘满。
64
+ - **动作**:只传 `progress-audit-2026-10.csv` 这样的单文件名;确认 `$DSH_HOME` 可写(测试时设 `DSH_PROGRESS_PAYMENT_HOME` 到临时目录),不要删除用户数据来“修复”。
65
+
66
+ ## 还不行
67
+
68
+ ```sh
69
+ node samples/quickstart/run.mjs --check # 自带样例必须通过
70
+ node --test test/*.test.mjs # 32 项测试
71
+ node scripts/gate.mjs # 元数据 + 验收门
72
+ ```
73
+
74
+ 仍失败时请在 <https://github.com/PerryLink/dsh-progress-payment/issues> 附上:包版本、最小合成申报
75
+ (或生成脚本)、`progress.audit` 的完整报错行、Node 与 DSH 版本。**不要附真实合同或客户申报。**
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: dsh-progress-payment
3
+ name: 'dsh-progress-payment'
@@ -0,0 +1,37 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="960" height="220" viewBox="0 0 960 220" role="img" aria-label="dsh-progress-payment workflow">
2
+ <rect width="960" height="220" fill="#0d1117"/>
3
+ <g font-family="'Microsoft YaHei', 'PingFang SC', sans-serif" font-size="15" fill="#e6edf3">
4
+ <rect x="30" y="62" width="210" height="96" rx="12" fill="#161b22" stroke="#30363d"/>
5
+ <text x="48" y="94" font-size="16" font-weight="600">本期申报 CSV</text>
6
+ <text x="48" y="120" fill="#8b949e" font-size="13">7 列 · 6 个清单项</text>
7
+ <text x="48" y="140" fill="#8b949e" font-size="13">合成数据</text>
8
+
9
+ <rect x="300" y="62" width="210" height="96" rx="12" fill="#161b22" stroke="#58a6ff"/>
10
+ <text x="318" y="94" font-size="16" font-weight="600">progress.ingest</text>
11
+ <text x="318" y="120" fill="#8b949e" font-size="13">解析 · 校验表头</text>
12
+ <text x="318" y="140" fill="#8b949e" font-size="13">batch_id(会话内存)</text>
13
+
14
+ <rect x="570" y="62" width="210" height="96" rx="12" fill="#161b22" stroke="#f0b429"/>
15
+ <text x="588" y="94" font-size="16" font-weight="600">progress.audit</text>
16
+ <text x="588" y="120" fill="#8b949e" font-size="13">整数分金额 · 累计比例</text>
17
+ <text x="588" y="140" fill="#8b949e" font-size="13">超量 / 单价 / 重复项</text>
18
+
19
+ <rect x="840" y="62" width="90" height="96" rx="12" fill="#161b22" stroke="#3fb950"/>
20
+ <text x="856" y="94" font-size="16" font-weight="600">底稿</text>
21
+ <text x="856" y="120" fill="#8b949e" font-size="13">CSV</text>
22
+ <text x="856" y="140" fill="#8b949e" font-size="13">exports/</text>
23
+
24
+ <g stroke="#8b949e" stroke-width="2" fill="none">
25
+ <path d="M240 110h52" marker-end="url(#a)"/>
26
+ <path d="M510 110h52" marker-end="url(#a)"/>
27
+ <path d="M780 110h52" marker-end="url(#a)"/>
28
+ </g>
29
+ <text x="300" y="42" fill="#8b949e" font-size="13">合同基准 CSV(可选)→ 以基准为准</text>
30
+ <text x="570" y="42" fill="#8b949e" font-size="13">金额整数分 · 本地运行 · 不联网</text>
31
+ </g>
32
+ <defs>
33
+ <marker id="a" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
34
+ <path d="M0 0l8 4-8 4z" fill="#8b949e"/>
35
+ </marker>
36
+ </defs>
37
+ </svg>
@@ -0,0 +1,14 @@
1
+ $ node samples/quickstart/run.mjs --check
2
+ dsh-progress-payment quickstart - synthetic progress declaration (not customer data)
3
+ declaration: samples/quickstart/input/declaration-2026-10.csv (7 rows / 6 items)
4
+ contract baseline: 6 items; batch pg_ea0edb1548cb530b
5
+ period amount: 50763.60 CNY (declared 51627.60)
6
+ cumulative: 614145.00 CNY / contract 725750.00 = 84.62%
7
+ issues: 3 (over qty 1, price mismatch 1, duplicate 1)
8
+ row 4 010402001 E_PROGRESS_OVER_QTY 累计完成量 101.5 超过合同量 95
9
+ row 6 011702001 E_PROGRESS_PRICE_MISMATCH 申报合同单价 65.00 与合同基准 55.00 不符
10
+ row 7 010101003 E_PROGRESS_DUP_ITEM 清单编码 010101003 在本期申报里出现 2 次
11
+ worksheet: exports/progress-audit-2026-10.csv 8 rows, 1291 bytes, sha256 24cca857d661...
12
+ worksheet columns: 行号,清单编码,名称,单位,合同量,合同单价(元),本期完成量,累计完成量,本期金额(元),累计金额(元),累计比例(%),是否计入,问题代码,问题说明
13
+ golden samples/quickstart/expected/result.json matches
14
+ quickstart ok
Binary file
package/icon.svg ADDED
@@ -0,0 +1,8 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128" role="img" aria-label="Progress payment audit">
2
+ <rect width="128" height="128" rx="28" fill="#0b1020"/>
3
+ <rect x="10" y="10" width="108" height="108" rx="22" fill="#141b30" stroke="#f0b429" stroke-width="3"/>
4
+ <rect x="26" y="24" width="76" height="82" rx="8" fill="#f4f7ff" opacity="0.96"/>
5
+ <rect x="42" y="18" width="44" height="14" rx="5" fill="#f0b429"/>
6
+ <path d="M40 52h48M40 66h32M40 80h22" stroke="#6ea8fe" stroke-width="4" stroke-linecap="round"/>
7
+ <path d="M86 72l8 8 14-16" stroke="#1a7f37" stroke-width="5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
8
+ </svg>
@@ -0,0 +1,16 @@
1
+ export declare function csvField(value: string): string;
2
+ /** Build a UTF-8 CSV body with a trailing newline; empty input yields an empty string. */
3
+ export declare function toCsv(rows: readonly (readonly string[])[]): string;
4
+ /**
5
+ * Parse RFC 4180-style CSV: quoted fields, doubled quotes, embedded CR/LF and a
6
+ * UTF-8 BOM. Blank lines and rows whose every cell is empty are dropped. A
7
+ * quote that never closes is a row-level error (`E_PROGRESS_CSV_ROW`), not a
8
+ * silently truncated table.
9
+ */
10
+ export declare function parseCsv(text: string): string[][];
11
+ /** Normalise a header cell: trim, lower-case, drop spaces and a UTF-8 BOM. */
12
+ export declare function normalizeHeader(value: string): string;
13
+ /** Find the first alias present in the header row; -1 when the column is absent. */
14
+ export declare function headerIndex(header: readonly string[], aliases: readonly string[]): number;
15
+ /** Pad short rows to the header width; extra cells beyond the header are ignored. */
16
+ export declare function cell(row: readonly string[], index: number): string;
@@ -0,0 +1,95 @@
1
+ import { ProgressError } from './errors.js';
2
+ /** RFC 4180 field escaping: quote on comma, quote, CR or LF and double embedded quotes. */
3
+ const CSV_NEEDS_QUOTES = /[",\r\n]/;
4
+ export function csvField(value) {
5
+ return CSV_NEEDS_QUOTES.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
6
+ }
7
+ /** Build a UTF-8 CSV body with a trailing newline; empty input yields an empty string. */
8
+ export function toCsv(rows) {
9
+ if (rows.length === 0)
10
+ return '';
11
+ return `${rows.map((row) => row.map(csvField).join(',')).join('\n')}\n`;
12
+ }
13
+ /**
14
+ * Parse RFC 4180-style CSV: quoted fields, doubled quotes, embedded CR/LF and a
15
+ * UTF-8 BOM. Blank lines and rows whose every cell is empty are dropped. A
16
+ * quote that never closes is a row-level error (`E_PROGRESS_CSV_ROW`), not a
17
+ * silently truncated table.
18
+ */
19
+ export function parseCsv(text) {
20
+ if (typeof text !== 'string')
21
+ throw new ProgressError('E_PROGRESS_CSV_ROW', 'CSV 内容不是文本');
22
+ const source = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
23
+ const rows = [];
24
+ let row = [];
25
+ let field = '';
26
+ let inQuotes = false;
27
+ let index = 0;
28
+ while (index < source.length) {
29
+ const char = source[index];
30
+ if (inQuotes) {
31
+ if (char === '"') {
32
+ if (source[index + 1] === '"') {
33
+ field += '"';
34
+ index += 2;
35
+ continue;
36
+ }
37
+ inQuotes = false;
38
+ index += 1;
39
+ continue;
40
+ }
41
+ field += char;
42
+ index += 1;
43
+ continue;
44
+ }
45
+ if (char === '"' && field === '') {
46
+ inQuotes = true;
47
+ index += 1;
48
+ continue;
49
+ }
50
+ if (char === ',') {
51
+ row.push(field);
52
+ field = '';
53
+ index += 1;
54
+ continue;
55
+ }
56
+ if (char === '\r') {
57
+ index += 1;
58
+ continue;
59
+ }
60
+ if (char === '\n') {
61
+ row.push(field);
62
+ rows.push(row);
63
+ row = [];
64
+ field = '';
65
+ index += 1;
66
+ continue;
67
+ }
68
+ field += char;
69
+ index += 1;
70
+ }
71
+ if (inQuotes)
72
+ throw new ProgressError('E_PROGRESS_CSV_ROW', 'CSV 里有引号没有闭合');
73
+ if (field !== '' || row.length > 0) {
74
+ row.push(field);
75
+ rows.push(row);
76
+ }
77
+ return rows.filter((cells) => cells.some((cell) => cell.trim() !== ''));
78
+ }
79
+ /** Normalise a header cell: trim, lower-case, drop spaces and a UTF-8 BOM. */
80
+ export function normalizeHeader(value) {
81
+ return value.replace(/^\uFEFF/, '').trim().toLowerCase().replace(/\s+/g, '');
82
+ }
83
+ /** Find the first alias present in the header row; -1 when the column is absent. */
84
+ export function headerIndex(header, aliases) {
85
+ for (const alias of aliases) {
86
+ const index = header.indexOf(normalizeHeader(alias));
87
+ if (index >= 0)
88
+ return index;
89
+ }
90
+ return -1;
91
+ }
92
+ /** Pad short rows to the header width; extra cells beyond the header are ignored. */
93
+ export function cell(row, index) {
94
+ return index >= 0 ? (row[index] ?? '').trim() : '';
95
+ }
@@ -0,0 +1,8 @@
1
+ /** Every user-facing failure is a stable code plus one human sentence and one next action. */
2
+ export type ProgressErrorCode = 'E_PROGRESS_CSV_EMPTY' | 'E_PROGRESS_CSV_ROW' | 'E_PROGRESS_BATCH_NOT_FOUND' | 'E_PROGRESS_OPTION_INVALID' | 'E_PROGRESS_PATH_INVALID' | 'E_PROGRESS_PATH_NOT_ALLOWED' | 'E_PROGRESS_EXPORT_FAILED';
3
+ /** `human sentence(E_CODE)|Next: one action|detail` — the only text shown to end users. */
4
+ export declare function progressErrorMessage(code: ProgressErrorCode, detail?: string): string;
5
+ export declare class ProgressError extends Error {
6
+ readonly code: ProgressErrorCode;
7
+ constructor(code: ProgressErrorCode, detail?: string);
8
+ }
@@ -0,0 +1,43 @@
1
+ const PROGRESS_HUMAN = {
2
+ E_PROGRESS_CSV_EMPTY: {
3
+ text: '这个 CSV 是空的,既没有表头也没有数据',
4
+ next: '确认文件不是 0 字节,并且第一行是表头、后面至少有一行数据',
5
+ },
6
+ E_PROGRESS_CSV_ROW: {
7
+ text: '申报或合同 CSV 的表头/某一行读不懂',
8
+ next: '按 README 的列名补齐表头;数量写纯数字(最多 3 位小数),单价写金额(最多 2 位小数),不要带单位',
9
+ },
10
+ E_PROGRESS_BATCH_NOT_FOUND: {
11
+ text: '找不到这批申报',
12
+ next: '先运行 progress.ingest 导入 CSV 拿到 batch_id,再运行 progress.audit 或 progress.export',
13
+ },
14
+ E_PROGRESS_OPTION_INVALID: {
15
+ text: '审核参数不合法',
16
+ next: 'price_tolerance_bps 传 0 或正整数(单位:万分之一),不传表示 0',
17
+ },
18
+ E_PROGRESS_PATH_INVALID: {
19
+ text: '导出文件名不安全',
20
+ next: '只传一个由字母、数字、点、下划线或短横线组成的文件名,例如 progress-2026-10.csv',
21
+ },
22
+ E_PROGRESS_PATH_NOT_ALLOWED: {
23
+ text: '导出路径越过了导出目录',
24
+ next: '只传一个文件名,不要带 ../、目录分隔符或符号链接',
25
+ },
26
+ E_PROGRESS_EXPORT_FAILED: {
27
+ text: '审核底稿没有写出去',
28
+ next: '确认 $DSH_HOME/dsh-progress-payment 指向可写目录后重试',
29
+ },
30
+ };
31
+ /** `human sentence(E_CODE)|Next: one action|detail` — the only text shown to end users. */
32
+ export function progressErrorMessage(code, detail) {
33
+ const human = PROGRESS_HUMAN[code];
34
+ return `${human.text}(${code})|下一步:${human.next}${detail ? `|细节:${detail}` : ''}`;
35
+ }
36
+ export class ProgressError extends Error {
37
+ code;
38
+ constructor(code, detail) {
39
+ super(progressErrorMessage(code, detail));
40
+ this.name = 'ProgressError';
41
+ this.code = code;
42
+ }
43
+ }
@@ -0,0 +1,16 @@
1
+ /** 1 unit of quantity is stored as 1000 milli-units; money is stored as integer cents. */
2
+ export declare const QTY_SCALE = 1000;
3
+ /** Parse a decimal amount into integer cents. Negative amounts and >2 decimals are rejected. */
4
+ export declare function parseCents(raw: string, field: string, row: number): number;
5
+ /** Parse a decimal quantity into integer milli-units (3 decimals). Negative quantities are rejected. */
6
+ export declare function parseQtyMilli(raw: string, field: string, row: number): number;
7
+ /** Quantity x contract unit price, rounded half-up to whole cents. */
8
+ export declare function amountCents(qtyMilli: number, unitPriceCents: number): number;
9
+ /** Format integer cents as `1234.56` (always two decimals). */
10
+ export declare function formatCents(cents: number): string;
11
+ /** Format integer milli-units as a compact decimal: `25.92`, `4.2`, `300`. */
12
+ export declare function formatQty(milli: number): string;
13
+ /** Percentage with two decimals; a non-positive total yields 0 instead of Infinity. */
14
+ export declare function ratioPct(part: number, total: number): number;
15
+ /** Absolute price tolerance in cents for a basis-point setting (0 bps = exact). */
16
+ export declare function priceToleranceCents(contractPriceCents: number, bps: number): number;
@@ -0,0 +1,69 @@
1
+ import { ProgressError } from './errors.js';
2
+ /** 1 unit of quantity is stored as 1000 milli-units; money is stored as integer cents. */
3
+ export const QTY_SCALE = 1000;
4
+ const MONEY = /^(\d+)(?:\.(\d{1,2}))?$/;
5
+ const QTY = /^(\d+)(?:\.(\d{1,3}))?$/;
6
+ function cleanNumber(raw) {
7
+ return String(raw).trim().replace(/[¥¥,\s]/g, '');
8
+ }
9
+ /** Parse a decimal amount into integer cents. Negative amounts and >2 decimals are rejected. */
10
+ export function parseCents(raw, field, row) {
11
+ const value = cleanNumber(raw);
12
+ if (value === '')
13
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `第 ${row} 行 ${field} 为空`);
14
+ const match = MONEY.exec(value);
15
+ if (!match)
16
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `第 ${row} 行 ${field}=${JSON.stringify(raw)} 不是金额`);
17
+ const cents = Number(match[1]) * 100 + Number((match[2] ?? '').padEnd(2, '0'));
18
+ if (!Number.isSafeInteger(cents))
19
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `第 ${row} 行 ${field}=${JSON.stringify(raw)} 太大`);
20
+ return cents;
21
+ }
22
+ /** Parse a decimal quantity into integer milli-units (3 decimals). Negative quantities are rejected. */
23
+ export function parseQtyMilli(raw, field, row) {
24
+ const value = cleanNumber(raw);
25
+ if (value === '')
26
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `第 ${row} 行 ${field} 为空`);
27
+ const match = QTY.exec(value);
28
+ if (!match)
29
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `第 ${row} 行 ${field}=${JSON.stringify(raw)} 不是数量`);
30
+ const milli = Number(match[1]) * QTY_SCALE + Number((match[2] ?? '').padEnd(3, '0'));
31
+ if (!Number.isSafeInteger(milli))
32
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `第 ${row} 行 ${field}=${JSON.stringify(raw)} 太大`);
33
+ return milli;
34
+ }
35
+ /** Quantity x contract unit price, rounded half-up to whole cents. */
36
+ export function amountCents(qtyMilli, unitPriceCents) {
37
+ const raw = qtyMilli * unitPriceCents;
38
+ if (!Number.isSafeInteger(raw))
39
+ throw new ProgressError('E_PROGRESS_CSV_ROW', '金额超出可安全计算的范围');
40
+ return Math.round(raw / QTY_SCALE);
41
+ }
42
+ /** Format integer cents as `1234.56` (always two decimals). */
43
+ export function formatCents(cents) {
44
+ const sign = cents < 0 ? '-' : '';
45
+ const abs = Math.abs(cents);
46
+ return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
47
+ }
48
+ /** Format integer milli-units as a compact decimal: `25.92`, `4.2`, `300`. */
49
+ export function formatQty(milli) {
50
+ const sign = milli < 0 ? '-' : '';
51
+ const abs = Math.abs(milli);
52
+ const whole = Math.floor(abs / QTY_SCALE);
53
+ const fraction = abs % QTY_SCALE;
54
+ if (fraction === 0)
55
+ return `${sign}${whole}`;
56
+ return `${sign}${whole}.${String(fraction).padStart(3, '0').replace(/0+$/, '')}`;
57
+ }
58
+ /** Percentage with two decimals; a non-positive total yields 0 instead of Infinity. */
59
+ export function ratioPct(part, total) {
60
+ if (!Number.isFinite(part) || !Number.isFinite(total) || total <= 0)
61
+ return 0;
62
+ return Math.round((part * 10000) / total) / 100;
63
+ }
64
+ /** Absolute price tolerance in cents for a basis-point setting (0 bps = exact). */
65
+ export function priceToleranceCents(contractPriceCents, bps) {
66
+ if (!Number.isFinite(bps) || bps <= 0)
67
+ return 0;
68
+ return Math.round((contractPriceCents * bps) / 10000);
69
+ }
@@ -0,0 +1,127 @@
1
+ /** One declaration line exactly as parsed from the CSV (row = 1-based data row). */
2
+ export interface DeclarationRow {
3
+ row: number;
4
+ item_code: string;
5
+ item_name: string;
6
+ unit: string;
7
+ contract_qty_milli: number;
8
+ contract_unit_price_cents: number;
9
+ period_qty_milli: number;
10
+ cumulative_qty_milli: number;
11
+ }
12
+ /** One contract-baseline line. Quantities and prices are authoritative for the audit. */
13
+ export interface ContractRow {
14
+ item_code: string;
15
+ item_name: string;
16
+ unit: string;
17
+ contract_qty_milli: number;
18
+ contract_unit_price_cents: number;
19
+ }
20
+ export interface ProgressBatch {
21
+ batch_id: string;
22
+ source_name: string;
23
+ declaration_sha256: string;
24
+ contract_sha256: string | null;
25
+ rows: DeclarationRow[];
26
+ contract: ContractRow[] | null;
27
+ }
28
+ export type ProgressIssueCode = 'E_PROGRESS_OVER_QTY' | 'E_PROGRESS_PRICE_MISMATCH' | 'E_PROGRESS_DUP_ITEM' | 'E_PROGRESS_CONTRACT_MISSING' | 'E_PROGRESS_CONTRACT_MISMATCH' | 'E_PROGRESS_QTY_INCONSISTENT';
29
+ export type ProgressIssue = {
30
+ row: number;
31
+ item_code: string;
32
+ item_name: string;
33
+ code: ProgressIssueCode;
34
+ kind: string;
35
+ field: string;
36
+ message: string;
37
+ blocking: boolean;
38
+ };
39
+ export type AuditItem = {
40
+ row: number;
41
+ item_code: string;
42
+ item_name: string;
43
+ unit: string;
44
+ declared_contract_qty: number;
45
+ declared_contract_unit_price_cents: number;
46
+ contract_qty: number;
47
+ contract_unit_price_cents: number;
48
+ period_qty: number;
49
+ cumulative_qty: number;
50
+ period_amount_cents: number;
51
+ declared_period_amount_cents: number;
52
+ cumulative_amount_cents: number;
53
+ contract_amount_cents: number;
54
+ cumulative_ratio_pct: number;
55
+ issue_codes: ProgressIssueCode[];
56
+ };
57
+ export type AuditSummary = {
58
+ items: number;
59
+ rows: number;
60
+ duplicate_rows: number;
61
+ contract_items: number;
62
+ period_amount_cents: number;
63
+ declared_period_amount_cents: number;
64
+ price_adjustment_cents: number;
65
+ cumulative_amount_cents: number;
66
+ contract_amount_cents: number;
67
+ cumulative_ratio_pct: number;
68
+ issues: number;
69
+ blocking_issues: number;
70
+ over_qty: number;
71
+ price_mismatch: number;
72
+ duplicate_items: number;
73
+ contract_missing: number;
74
+ contract_mismatch: number;
75
+ qty_inconsistent: number;
76
+ status: 'ok' | 'needs_review';
77
+ };
78
+ export type AuditResult = {
79
+ batch_id: string;
80
+ currency: string;
81
+ items: AuditItem[];
82
+ issues: ProgressIssue[];
83
+ summary: AuditSummary;
84
+ };
85
+ export type IngestSummary = {
86
+ batch_id: string;
87
+ rows: number;
88
+ items: number;
89
+ contract_items: number;
90
+ declaration_sha256: string;
91
+ contract_sha256?: string;
92
+ };
93
+ /** Parse the current-period declaration CSV. Column order is free; Chinese and English headers both work. */
94
+ export declare function parseDeclarationCsv(text: string): DeclarationRow[];
95
+ /** Parse the contract baseline CSV: item code, contract quantity, contract unit price. */
96
+ export declare function parseContractCsv(text: string): ContractRow[];
97
+ /** Parse and store one declaration batch. The batch id is deterministic for the same CSV pair. */
98
+ export declare function ingestDeclaration(declarationCsv: string, contractCsv?: string, sourceName?: string): {
99
+ batch: ProgressBatch;
100
+ summary: IngestSummary;
101
+ };
102
+ /** Audit one stored batch: amounts in integer cents, cumulative ratio, over-quantity, price and duplicate checks. */
103
+ export declare function auditBatch(batch: ProgressBatch, options?: {
104
+ price_tolerance_bps?: number;
105
+ }): AuditResult;
106
+ export interface WorksheetRow {
107
+ row: number;
108
+ item_code: string;
109
+ item_name: string;
110
+ unit: string;
111
+ contract_qty: number;
112
+ contract_unit_price_cents: number;
113
+ period_qty: number;
114
+ cumulative_qty: number;
115
+ period_amount_cents: number;
116
+ cumulative_amount_cents: number;
117
+ cumulative_ratio_pct: number;
118
+ counted: boolean;
119
+ issue_codes: ProgressIssueCode[];
120
+ issue_text: string;
121
+ }
122
+ /** Build one worksheet row per declaration line; repeated item codes are marked counted=false. */
123
+ export declare function buildWorksheet(batch: ProgressBatch, audit: AuditResult, options?: {
124
+ price_tolerance_bps?: number;
125
+ }): WorksheetRow[];
126
+ /** Human sentence for one audit result; used by tool renderers and the quickstart. */
127
+ export declare function auditSentence(audit: AuditResult): string;