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,372 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { cell, headerIndex, normalizeHeader, parseCsv } from './csv.js';
3
+ import { ProgressError } from './errors.js';
4
+ import { amountCents, formatCents, formatQty, parseCents, parseQtyMilli, priceToleranceCents, ratioPct, } from './money.js';
5
+ const DECLARATION_COLUMNS = {
6
+ item_code: ['item_code', 'code', '清单编码', '项目编码'],
7
+ item_name: ['item_name', 'name', '名称', '项目名称'],
8
+ unit: ['unit', '单位', '计量单位'],
9
+ contract_qty: ['contract_qty', '合同量', '合同数量'],
10
+ contract_unit_price: ['contract_unit_price', 'contract_price', '合同单价'],
11
+ period_qty: ['period_qty', '本期完成量', '本期工程量'],
12
+ cumulative_qty: ['cumulative_qty', '累计完成量', '累计工程量'],
13
+ };
14
+ const CONTRACT_COLUMNS = {
15
+ item_code: ['item_code', 'code', '清单编码', '项目编码'],
16
+ contract_qty: ['contract_qty', '合同量', '合同数量'],
17
+ contract_unit_price: ['contract_unit_price', 'contract_price', '合同单价'],
18
+ };
19
+ const CONTRACT_OPTIONAL_COLUMNS = {
20
+ item_name: ['item_name', 'name', '名称', '项目名称'],
21
+ unit: ['unit', '单位', '计量单位'],
22
+ };
23
+ function sha256(value) {
24
+ return createHash('sha256').update(value).digest('hex');
25
+ }
26
+ function readTable(text, label) {
27
+ if (typeof text !== 'string' || text.trim() === '')
28
+ throw new ProgressError('E_PROGRESS_CSV_EMPTY', label);
29
+ const parsed = parseCsv(text);
30
+ if (parsed.length === 0)
31
+ throw new ProgressError('E_PROGRESS_CSV_EMPTY', label);
32
+ const header = parsed[0].map(normalizeHeader);
33
+ const rows = parsed.slice(1);
34
+ if (rows.length === 0)
35
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `${label} 只有表头,没有数据行`);
36
+ return { header, rows };
37
+ }
38
+ function requireColumns(header, columns, label) {
39
+ const index = {};
40
+ const missing = [];
41
+ for (const [key, aliases] of Object.entries(columns)) {
42
+ const found = headerIndex(header, aliases);
43
+ index[key] = found;
44
+ if (found < 0)
45
+ missing.push(aliases[0]);
46
+ }
47
+ if (missing.length > 0) {
48
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `${label} 缺少列:${missing.join('、')};表头是:${header.join('、') || '空'}`);
49
+ }
50
+ return index;
51
+ }
52
+ function requireText(value, field, row) {
53
+ if (value === '')
54
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `第 ${row} 行 ${field} 为空`);
55
+ return value;
56
+ }
57
+ function requirePositiveQty(value, field, row) {
58
+ if (value <= 0)
59
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `第 ${row} 行 ${field} 必须大于 0`);
60
+ return value;
61
+ }
62
+ /** Parse the current-period declaration CSV. Column order is free; Chinese and English headers both work. */
63
+ export function parseDeclarationCsv(text) {
64
+ const { header, rows } = readTable(text, '申报 CSV');
65
+ const index = requireColumns(header, DECLARATION_COLUMNS, '申报 CSV');
66
+ return rows.map((cells, offset) => {
67
+ const row = offset + 1;
68
+ const itemCode = requireText(cell(cells, index.item_code), '清单编码', row);
69
+ const itemName = requireText(cell(cells, index.item_name), '名称', row);
70
+ const unit = requireText(cell(cells, index.unit), '单位', row);
71
+ const contractQty = requirePositiveQty(parseQtyMilli(cell(cells, index.contract_qty), '合同量', row), '合同量', row);
72
+ const contractPrice = parseCents(cell(cells, index.contract_unit_price), '合同单价', row);
73
+ const periodQty = parseQtyMilli(cell(cells, index.period_qty), '本期完成量', row);
74
+ const cumulativeQty = parseQtyMilli(cell(cells, index.cumulative_qty), '累计完成量', row);
75
+ return {
76
+ row,
77
+ item_code: itemCode,
78
+ item_name: itemName,
79
+ unit,
80
+ contract_qty_milli: contractQty,
81
+ contract_unit_price_cents: contractPrice,
82
+ period_qty_milli: periodQty,
83
+ cumulative_qty_milli: cumulativeQty,
84
+ };
85
+ });
86
+ }
87
+ /** Parse the contract baseline CSV: item code, contract quantity, contract unit price. */
88
+ export function parseContractCsv(text) {
89
+ const { header, rows } = readTable(text, '合同基准 CSV');
90
+ const index = requireColumns(header, CONTRACT_COLUMNS, '合同基准 CSV');
91
+ const optional = Object.fromEntries(Object.entries(CONTRACT_OPTIONAL_COLUMNS).map(([key, aliases]) => [key, headerIndex(header, aliases)]));
92
+ const seen = new Set();
93
+ return rows.map((cells, offset) => {
94
+ const row = offset + 1;
95
+ const itemCode = requireText(cell(cells, index.item_code), '清单编码', row);
96
+ if (seen.has(itemCode)) {
97
+ throw new ProgressError('E_PROGRESS_CSV_ROW', `合同基准第 ${row} 行清单编码 ${itemCode} 重复;合同基准必须一条清单一行`);
98
+ }
99
+ seen.add(itemCode);
100
+ const contractQty = requirePositiveQty(parseQtyMilli(cell(cells, index.contract_qty), '合同量', row), '合同量', row);
101
+ return {
102
+ item_code: itemCode,
103
+ item_name: cell(cells, optional.item_name ?? -1),
104
+ unit: cell(cells, optional.unit ?? -1),
105
+ contract_qty_milli: contractQty,
106
+ contract_unit_price_cents: parseCents(cell(cells, index.contract_unit_price), '合同单价', row),
107
+ };
108
+ });
109
+ }
110
+ /** Parse and store one declaration batch. The batch id is deterministic for the same CSV pair. */
111
+ export function ingestDeclaration(declarationCsv, contractCsv, sourceName = 'declaration.csv') {
112
+ const rows = parseDeclarationCsv(declarationCsv);
113
+ const contract = contractCsv === undefined ? null : parseContractCsv(contractCsv);
114
+ const declarationSha = sha256(declarationCsv);
115
+ const contractSha = contractCsv === undefined ? null : sha256(contractCsv);
116
+ const batchId = `pg_${sha256(`${declarationSha}\u0000${contractSha ?? ''}`).slice(0, 16)}`;
117
+ const items = new Set(rows.map((row) => row.item_code)).size;
118
+ const batch = {
119
+ batch_id: batchId,
120
+ source_name: sourceName,
121
+ declaration_sha256: declarationSha,
122
+ contract_sha256: contractSha,
123
+ rows,
124
+ contract,
125
+ };
126
+ return {
127
+ batch,
128
+ summary: {
129
+ batch_id: batchId,
130
+ rows: rows.length,
131
+ items,
132
+ contract_items: contract?.length ?? 0,
133
+ declaration_sha256: declarationSha,
134
+ contract_sha256: contractSha ?? undefined,
135
+ },
136
+ };
137
+ }
138
+ const units = (milli) => milli / 1000;
139
+ function issueMessage(code, text, next) {
140
+ return `${text}(${code})|下一步:${next}`;
141
+ }
142
+ function resolveRow(row, contractMap, toleranceBps) {
143
+ const drafts = [];
144
+ let contractQty = row.contract_qty_milli;
145
+ let contractPrice = row.contract_unit_price_cents;
146
+ const baseline = contractMap?.get(row.item_code);
147
+ if (contractMap) {
148
+ if (!baseline) {
149
+ drafts.push({
150
+ code: 'E_PROGRESS_CONTRACT_MISSING',
151
+ kind: 'contract_missing',
152
+ field: 'item_code',
153
+ message: issueMessage('E_PROGRESS_CONTRACT_MISSING', `清单编码 ${row.item_code} 不在合同基准里`, '先补合同基准;补不了就单独确认这条清单的合同量和单价,不要直接计入本期'),
154
+ blocking: true,
155
+ });
156
+ }
157
+ else {
158
+ contractQty = baseline.contract_qty_milli;
159
+ contractPrice = baseline.contract_unit_price_cents;
160
+ if (row.contract_qty_milli !== baseline.contract_qty_milli) {
161
+ drafts.push({
162
+ code: 'E_PROGRESS_CONTRACT_MISMATCH',
163
+ kind: 'contract_qty_mismatch',
164
+ field: 'contract_qty',
165
+ message: issueMessage('E_PROGRESS_CONTRACT_MISMATCH', `申报合同量 ${formatQty(row.contract_qty_milli)} 与合同基准 ${formatQty(baseline.contract_qty_milli)} 不符`, '以合同基准为准改正申报;确需调整先办合同变更'),
166
+ blocking: true,
167
+ });
168
+ }
169
+ const tolerance = priceToleranceCents(baseline.contract_unit_price_cents, toleranceBps);
170
+ if (Math.abs(row.contract_unit_price_cents - baseline.contract_unit_price_cents) > tolerance) {
171
+ drafts.push({
172
+ code: 'E_PROGRESS_PRICE_MISMATCH',
173
+ kind: 'price_mismatch',
174
+ field: 'contract_unit_price',
175
+ message: issueMessage('E_PROGRESS_PRICE_MISMATCH', `申报合同单价 ${formatCents(row.contract_unit_price_cents)} 与合同基准 ${formatCents(baseline.contract_unit_price_cents)} 不符`, `本期金额按合同单价 ${formatCents(baseline.contract_unit_price_cents)} 重算,并要求申报方改正`),
176
+ blocking: true,
177
+ });
178
+ }
179
+ }
180
+ }
181
+ if (row.cumulative_qty_milli < row.period_qty_milli) {
182
+ drafts.push({
183
+ code: 'E_PROGRESS_QTY_INCONSISTENT',
184
+ kind: 'cumulative_below_period',
185
+ field: 'cumulative_qty',
186
+ message: issueMessage('E_PROGRESS_QTY_INCONSISTENT', `累计完成量 ${formatQty(row.cumulative_qty_milli)} 小于本期完成量 ${formatQty(row.period_qty_milli)}`, '核对上期累计数;累计完成量应当大于或等于本期完成量'),
187
+ blocking: true,
188
+ });
189
+ }
190
+ if (row.period_qty_milli > contractQty) {
191
+ drafts.push({
192
+ code: 'E_PROGRESS_OVER_QTY',
193
+ kind: 'period_over_contract',
194
+ field: 'period_qty',
195
+ message: issueMessage('E_PROGRESS_OVER_QTY', `本期完成量 ${formatQty(row.period_qty_milli)} 超过合同量 ${formatQty(contractQty)}`, '核对合同量和变更签证;超过合同量的部分不得计入本期'),
196
+ blocking: true,
197
+ });
198
+ }
199
+ if (row.cumulative_qty_milli > contractQty) {
200
+ drafts.push({
201
+ code: 'E_PROGRESS_OVER_QTY',
202
+ kind: 'cumulative_over_contract',
203
+ field: 'cumulative_qty',
204
+ message: issueMessage('E_PROGRESS_OVER_QTY', `累计完成量 ${formatQty(row.cumulative_qty_milli)} 超过合同量 ${formatQty(contractQty)}`, '核对合同量和变更签证;超量部分走变更或结算,不在本期支付'),
205
+ blocking: true,
206
+ });
207
+ }
208
+ const periodAmount = amountCents(row.period_qty_milli, contractPrice);
209
+ const cumulativeAmount = amountCents(row.cumulative_qty_milli, contractPrice);
210
+ const contractAmount = amountCents(contractQty, contractPrice);
211
+ return {
212
+ item_code: row.item_code,
213
+ item_name: row.item_name,
214
+ unit: row.unit,
215
+ declared_contract_qty_milli: row.contract_qty_milli,
216
+ declared_contract_unit_price_cents: row.contract_unit_price_cents,
217
+ contract_qty_milli: contractQty,
218
+ contract_unit_price_cents: contractPrice,
219
+ period_qty_milli: row.period_qty_milli,
220
+ cumulative_qty_milli: row.cumulative_qty_milli,
221
+ period_amount_cents: periodAmount,
222
+ declared_period_amount_cents: amountCents(row.period_qty_milli, row.contract_unit_price_cents),
223
+ cumulative_amount_cents: cumulativeAmount,
224
+ contract_amount_cents: contractAmount,
225
+ cumulative_ratio_pct: ratioPct(cumulativeAmount, contractAmount),
226
+ issues: drafts,
227
+ };
228
+ }
229
+ /** Audit one stored batch: amounts in integer cents, cumulative ratio, over-quantity, price and duplicate checks. */
230
+ export function auditBatch(batch, options = {}) {
231
+ const toleranceBps = options.price_tolerance_bps ?? 0;
232
+ if (!Number.isFinite(toleranceBps) || toleranceBps < 0) {
233
+ throw new ProgressError('E_PROGRESS_OPTION_INVALID', `price_tolerance_bps=${toleranceBps}`);
234
+ }
235
+ const contractMap = batch.contract ? new Map(batch.contract.map((row) => [row.item_code, row])) : null;
236
+ const firstRows = new Map();
237
+ const counts = new Map();
238
+ for (const row of batch.rows) {
239
+ if (!firstRows.has(row.item_code))
240
+ firstRows.set(row.item_code, row);
241
+ const bucket = counts.get(row.item_code) ?? [];
242
+ bucket.push(row);
243
+ counts.set(row.item_code, bucket);
244
+ }
245
+ const items = [];
246
+ const issues = [];
247
+ for (const row of firstRows.values()) {
248
+ const resolved = resolveRow(row, contractMap, toleranceBps);
249
+ for (const draft of resolved.issues) {
250
+ issues.push({
251
+ row: row.row,
252
+ item_code: resolved.item_code,
253
+ item_name: resolved.item_name,
254
+ code: draft.code,
255
+ kind: draft.kind,
256
+ field: draft.field,
257
+ message: draft.message,
258
+ blocking: draft.blocking,
259
+ });
260
+ }
261
+ items.push({
262
+ row: row.row,
263
+ item_code: resolved.item_code,
264
+ item_name: resolved.item_name,
265
+ unit: resolved.unit,
266
+ declared_contract_qty: units(resolved.declared_contract_qty_milli),
267
+ declared_contract_unit_price_cents: resolved.declared_contract_unit_price_cents,
268
+ contract_qty: units(resolved.contract_qty_milli),
269
+ contract_unit_price_cents: resolved.contract_unit_price_cents,
270
+ period_qty: units(resolved.period_qty_milli),
271
+ cumulative_qty: units(resolved.cumulative_qty_milli),
272
+ period_amount_cents: resolved.period_amount_cents,
273
+ declared_period_amount_cents: resolved.declared_period_amount_cents,
274
+ cumulative_amount_cents: resolved.cumulative_amount_cents,
275
+ contract_amount_cents: resolved.contract_amount_cents,
276
+ cumulative_ratio_pct: resolved.cumulative_ratio_pct,
277
+ issue_codes: resolved.issues.map((draft) => draft.code),
278
+ });
279
+ }
280
+ let duplicateRows = 0;
281
+ for (const [code, rows] of counts) {
282
+ if (rows.length < 2)
283
+ continue;
284
+ duplicateRows += rows.length - 1;
285
+ const message = issueMessage('E_PROGRESS_DUP_ITEM', `清单编码 ${code} 在本期申报里出现 ${rows.length} 次(行 ${rows.map((row) => row.row).join('、')})`, '确认哪一行是本期应报的;重复行不计入本期金额');
286
+ issues.push({
287
+ row: rows[1].row,
288
+ item_code: code,
289
+ item_name: rows[1].item_name,
290
+ code: 'E_PROGRESS_DUP_ITEM',
291
+ kind: 'duplicate_item',
292
+ field: 'item_code',
293
+ message,
294
+ blocking: true,
295
+ });
296
+ }
297
+ issues.sort((left, right) => left.row - right.row || left.code.localeCompare(right.code) || left.kind.localeCompare(right.kind));
298
+ for (const item of items)
299
+ item.issue_codes = [...new Set(item.issue_codes)].sort();
300
+ const periodAmount = items.reduce((total, item) => total + item.period_amount_cents, 0);
301
+ const declaredPeriodAmount = items.reduce((total, item) => total + item.declared_period_amount_cents, 0);
302
+ const cumulativeAmount = items.reduce((total, item) => total + item.cumulative_amount_cents, 0);
303
+ const contractAmount = items.reduce((total, item) => total + item.contract_amount_cents, 0);
304
+ const countCode = (code) => issues.filter((issue) => issue.code === code).length;
305
+ return {
306
+ batch_id: batch.batch_id,
307
+ currency: 'CNY',
308
+ items,
309
+ issues,
310
+ summary: {
311
+ items: items.length,
312
+ rows: batch.rows.length,
313
+ duplicate_rows: duplicateRows,
314
+ contract_items: batch.contract?.length ?? 0,
315
+ period_amount_cents: periodAmount,
316
+ declared_period_amount_cents: declaredPeriodAmount,
317
+ price_adjustment_cents: periodAmount - declaredPeriodAmount,
318
+ cumulative_amount_cents: cumulativeAmount,
319
+ contract_amount_cents: contractAmount,
320
+ cumulative_ratio_pct: ratioPct(cumulativeAmount, contractAmount),
321
+ issues: issues.length,
322
+ blocking_issues: issues.filter((issue) => issue.blocking).length,
323
+ over_qty: countCode('E_PROGRESS_OVER_QTY'),
324
+ price_mismatch: countCode('E_PROGRESS_PRICE_MISMATCH'),
325
+ duplicate_items: countCode('E_PROGRESS_DUP_ITEM'),
326
+ contract_missing: countCode('E_PROGRESS_CONTRACT_MISSING'),
327
+ contract_mismatch: countCode('E_PROGRESS_CONTRACT_MISMATCH'),
328
+ qty_inconsistent: countCode('E_PROGRESS_QTY_INCONSISTENT'),
329
+ status: issues.length === 0 ? 'ok' : 'needs_review',
330
+ },
331
+ };
332
+ }
333
+ /** Build one worksheet row per declaration line; repeated item codes are marked counted=false. */
334
+ export function buildWorksheet(batch, audit, options = {}) {
335
+ const contractMap = batch.contract ? new Map(batch.contract.map((row) => [row.item_code, row])) : null;
336
+ const seen = new Set();
337
+ const toleranceBps = options.price_tolerance_bps ?? 0;
338
+ return batch.rows.map((row) => {
339
+ const duplicate = seen.has(row.item_code);
340
+ seen.add(row.item_code);
341
+ const resolved = resolveRow(row, contractMap, toleranceBps);
342
+ const codes = resolved.issues.map((draft) => draft.code);
343
+ if (duplicate)
344
+ codes.push('E_PROGRESS_DUP_ITEM');
345
+ const uniqueCodes = [...new Set(codes)].sort();
346
+ const messages = resolved.issues.map((draft) => draft.message);
347
+ if (duplicate) {
348
+ messages.push(issueMessage('E_PROGRESS_DUP_ITEM', `清单编码 ${row.item_code} 在本期申报里重复出现`, '确认哪一行是本期应报的;重复行不计入本期金额'));
349
+ }
350
+ return {
351
+ row: row.row,
352
+ item_code: row.item_code,
353
+ item_name: row.item_name,
354
+ unit: row.unit,
355
+ contract_qty: units(resolved.contract_qty_milli),
356
+ contract_unit_price_cents: resolved.contract_unit_price_cents,
357
+ period_qty: units(resolved.period_qty_milli),
358
+ cumulative_qty: units(resolved.cumulative_qty_milli),
359
+ period_amount_cents: resolved.period_amount_cents,
360
+ cumulative_amount_cents: resolved.cumulative_amount_cents,
361
+ cumulative_ratio_pct: resolved.cumulative_ratio_pct,
362
+ counted: !duplicate,
363
+ issue_codes: uniqueCodes,
364
+ issue_text: messages.join(';'),
365
+ };
366
+ });
367
+ }
368
+ /** Human sentence for one audit result; used by tool renderers and the quickstart. */
369
+ export function auditSentence(audit) {
370
+ const { summary } = audit;
371
+ return `进度款审核:${summary.items} 个清单项,本期 ${formatCents(summary.period_amount_cents)} CNY,累计 ${formatCents(summary.cumulative_amount_cents)} CNY(${summary.cumulative_ratio_pct}%),${summary.issues} 个问题`;
372
+ }
@@ -0,0 +1,12 @@
1
+ /** A single export filename component: no separators, no whitespace, max 120 chars. */
2
+ export declare const SAFE_EXPORT_FILENAME: RegExp;
3
+ /**
4
+ * Resolve a caller-supplied export filename inside a base directory.
5
+ *
6
+ * 1. the filename must be one safe component (`[A-Za-z0-9._-]{1,120}`);
7
+ * 2. the resolved path must stay below `resolve(baseDir) + sep`;
8
+ * 3. the directory is created with `mkdir -p` and the path is re-anchored on
9
+ * `realpath(baseDir)` so a symlinked directory cannot redirect the write
10
+ * outside the canonical base.
11
+ */
12
+ export declare function safeExportPath(baseDir: string, filename: string): Promise<string>;
@@ -0,0 +1,31 @@
1
+ import { mkdir, realpath } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ProgressError } from './errors.js';
4
+ /** A single export filename component: no separators, no whitespace, max 120 chars. */
5
+ export const SAFE_EXPORT_FILENAME = /^[A-Za-z0-9._-]{1,120}$/;
6
+ /**
7
+ * Resolve a caller-supplied export filename inside a base directory.
8
+ *
9
+ * 1. the filename must be one safe component (`[A-Za-z0-9._-]{1,120}`);
10
+ * 2. the resolved path must stay below `resolve(baseDir) + sep`;
11
+ * 3. the directory is created with `mkdir -p` and the path is re-anchored on
12
+ * `realpath(baseDir)` so a symlinked directory cannot redirect the write
13
+ * outside the canonical base.
14
+ */
15
+ export async function safeExportPath(baseDir, filename) {
16
+ if (typeof filename !== 'string' || !SAFE_EXPORT_FILENAME.test(filename)) {
17
+ throw new ProgressError('E_PROGRESS_PATH_INVALID', `unsafe export filename: ${JSON.stringify(filename)}`);
18
+ }
19
+ const base = path.resolve(baseDir);
20
+ const candidate = path.resolve(base, filename);
21
+ if (!candidate.startsWith(base + path.sep)) {
22
+ throw new ProgressError('E_PROGRESS_PATH_NOT_ALLOWED', `export path escapes base directory: ${JSON.stringify(filename)}`);
23
+ }
24
+ await mkdir(base, { recursive: true });
25
+ const realBase = await realpath(base);
26
+ const realCandidate = path.resolve(realBase, filename);
27
+ if (!realCandidate.startsWith(realBase + path.sep)) {
28
+ throw new ProgressError('E_PROGRESS_PATH_NOT_ALLOWED', `export path escapes base directory after realpath: ${JSON.stringify(filename)}`);
29
+ }
30
+ return realCandidate;
31
+ }
@@ -0,0 +1,12 @@
1
+ import type { Context } from '@deepseek-ai/cordis';
2
+ import type { ProgressBatch } from '../core/progress.js';
3
+ export declare const name = "dsh-progress-payment";
4
+ export declare const inject: string[];
5
+ /** Local write root: `$DSH_PROGRESS_PAYMENT_HOME`, else `$DSH_HOME/dsh-progress-payment`. */
6
+ export declare function resolveBaseDir(env?: NodeJS.ProcessEnv): string;
7
+ /** Session-scoped batch table; tests may inject their own Map-backed implementation. */
8
+ export declare function createBatchTable(): {
9
+ get(key: string): ProgressBatch | undefined;
10
+ put(key: string, value: ProgressBatch): void;
11
+ };
12
+ export declare function apply(ctx: Context): Promise<void>;
@@ -0,0 +1,26 @@
1
+ import path from 'node:path';
2
+ import { createTools } from './tools.js';
3
+ export const name = 'dsh-progress-payment';
4
+ export const inject = ['tools'];
5
+ /** Local write root: `$DSH_PROGRESS_PAYMENT_HOME`, else `$DSH_HOME/dsh-progress-payment`. */
6
+ export function resolveBaseDir(env = process.env) {
7
+ const override = env.DSH_PROGRESS_PAYMENT_HOME?.trim();
8
+ if (override)
9
+ return override;
10
+ const dshHome = env.DSH_HOME?.trim();
11
+ return path.join(dshHome || process.cwd(), 'dsh-progress-payment');
12
+ }
13
+ /** Session-scoped batch table; tests may inject their own Map-backed implementation. */
14
+ export function createBatchTable() {
15
+ const map = new Map();
16
+ return {
17
+ get: (key) => map.get(key),
18
+ put: (key, value) => {
19
+ map.set(key, value);
20
+ },
21
+ };
22
+ }
23
+ export async function apply(ctx) {
24
+ for (const tool of createTools({ batches: createBatchTable(), baseDir: resolveBaseDir() }))
25
+ ctx.tools.register(tool);
26
+ }
@@ -0,0 +1,12 @@
1
+ import { type ProgressBatch } from '../core/progress.js';
2
+ export interface BatchTable {
3
+ get(key: string): ProgressBatch | undefined;
4
+ put(key: string, value: ProgressBatch): void | Promise<void>;
5
+ }
6
+ export interface ToolDeps {
7
+ /** Session-scoped batch table; the plugin creates one per apply(). */
8
+ batches: BatchTable;
9
+ /** Local write root; the plugin uses `$DSH_HOME/dsh-progress-payment`. */
10
+ baseDir: string;
11
+ }
12
+ export declare function createTools(deps: ToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition[];
@@ -0,0 +1,166 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { defineTool } from '@deepseek-ai/dsh-tools';
5
+ import { toCsv } from '../core/csv.js';
6
+ import { ProgressError } from '../core/errors.js';
7
+ import { formatCents } from '../core/money.js';
8
+ import { auditBatch, auditSentence, buildWorksheet, ingestDeclaration, } from '../core/progress.js';
9
+ import { safeExportPath } from '../core/safe-path.js';
10
+ const text = (value) => [{ type: 'text', text: value }];
11
+ const sha256 = (value) => createHash('sha256').update(value).digest('hex');
12
+ const WORKSHEET_HEADER = [
13
+ '行号',
14
+ '清单编码',
15
+ '名称',
16
+ '单位',
17
+ '合同量',
18
+ '合同单价(元)',
19
+ '本期完成量',
20
+ '累计完成量',
21
+ '本期金额(元)',
22
+ '累计金额(元)',
23
+ '累计比例(%)',
24
+ '是否计入',
25
+ '问题代码',
26
+ '问题说明',
27
+ ];
28
+ /** UTF-8 BOM so Excel on Windows opens the Chinese headers correctly. */
29
+ function worksheetCsv(batch, audit, toleranceBps) {
30
+ const rows = buildWorksheet(batch, audit, { price_tolerance_bps: toleranceBps }).map((row) => [
31
+ String(row.row),
32
+ row.item_code,
33
+ row.item_name,
34
+ row.unit,
35
+ String(row.contract_qty),
36
+ formatCents(row.contract_unit_price_cents),
37
+ String(row.period_qty),
38
+ String(row.cumulative_qty),
39
+ formatCents(row.period_amount_cents),
40
+ formatCents(row.cumulative_amount_cents),
41
+ row.cumulative_ratio_pct.toFixed(2),
42
+ row.counted ? '是' : '否',
43
+ row.issue_codes.join(';'),
44
+ row.issue_text,
45
+ ]);
46
+ return `\uFEFF${toCsv([WORKSHEET_HEADER, ...rows])}`;
47
+ }
48
+ function requireBatch(deps, batchId) {
49
+ const batch = batchId ? deps.batches.get(batchId) : undefined;
50
+ if (!batch)
51
+ throw new ProgressError('E_PROGRESS_BATCH_NOT_FOUND', `batch_id=${JSON.stringify(batchId ?? '')}`);
52
+ return batch;
53
+ }
54
+ export function createTools(deps) {
55
+ const ingest = defineTool({
56
+ name: 'progress.ingest',
57
+ description: 'Import one current-period progress declaration CSV (item code, name, unit, contract qty, contract unit price, period qty, cumulative qty) and an optional contract baseline; stores the batch in session memory; no file is written.',
58
+ parameters: {
59
+ declaration_csv: { type: 'string', required: true },
60
+ contract_csv: { type: 'string' },
61
+ source_name: { type: 'string' },
62
+ },
63
+ output: {
64
+ schema: {
65
+ type: 'object',
66
+ additionalProperties: false,
67
+ properties: {
68
+ batch_id: { type: 'string', required: true },
69
+ rows: { type: 'number', required: true },
70
+ items: { type: 'number', required: true },
71
+ contract_items: { type: 'number', required: true },
72
+ declaration_sha256: { type: 'string', required: true },
73
+ contract_sha256: { type: 'string' },
74
+ },
75
+ },
76
+ render: (_args, value) => text(`进度款申报:${value.items} 个清单项 / ${value.rows} 行,合同基准 ${value.contract_items} 项(${value.batch_id})`),
77
+ },
78
+ async execute(args) {
79
+ const { batch, summary } = ingestDeclaration(args.declaration_csv, args.contract_csv, args.source_name ?? 'declaration.csv');
80
+ await deps.batches.put(batch.batch_id, batch);
81
+ return summary;
82
+ },
83
+ });
84
+ const audit = defineTool({
85
+ name: 'progress.audit',
86
+ description: 'Audit a stored declaration: period amount in integer cents, cumulative ratio, over-contract quantity, contract/declared unit-price mismatch, cumulative-over-contract and duplicate item codes. Returns the problem list; amounts stay in integer cents. Read-only.',
87
+ parameters: {
88
+ batch_id: { type: 'string', required: true },
89
+ price_tolerance_bps: { type: 'number' },
90
+ },
91
+ output: {
92
+ schema: {
93
+ type: 'object',
94
+ additionalProperties: false,
95
+ properties: {
96
+ batch_id: { type: 'string', required: true },
97
+ currency: { type: 'string', required: true },
98
+ items: { type: 'json', required: true },
99
+ issues: { type: 'json', required: true },
100
+ summary: { type: 'json', required: true },
101
+ },
102
+ },
103
+ render: (_args, value) => text(auditSentence(value)),
104
+ },
105
+ async execute(args) {
106
+ const batch = requireBatch(deps, args.batch_id);
107
+ return auditBatch(batch, { price_tolerance_bps: args.price_tolerance_bps ?? 0 });
108
+ },
109
+ });
110
+ const exportCsv = defineTool({
111
+ name: 'progress.export',
112
+ description: 'Run the audit again and write the review worksheet CSV (one row per declaration line, duplicate rows marked counted=no) under $DSH_HOME/dsh-progress-payment/exports; returns path, rows and sha256. Local R1 write.',
113
+ parameters: {
114
+ batch_id: { type: 'string', required: true },
115
+ filename: { type: 'string' },
116
+ price_tolerance_bps: { type: 'number' },
117
+ },
118
+ output: {
119
+ schema: {
120
+ type: 'object',
121
+ additionalProperties: false,
122
+ properties: {
123
+ path: { type: 'string', required: true },
124
+ sha256: { type: 'string', required: true },
125
+ rows: { type: 'number', required: true },
126
+ bytes: { type: 'number', required: true },
127
+ items: { type: 'number', required: true },
128
+ issues: { type: 'number', required: true },
129
+ },
130
+ },
131
+ render: (_args, value) => text(`审核底稿 ${value.rows} 行 -> ${value.path}`),
132
+ },
133
+ async execute(args) {
134
+ const batch = requireBatch(deps, args.batch_id);
135
+ const toleranceBps = args.price_tolerance_bps ?? 0;
136
+ const auditResult = auditBatch(batch, { price_tolerance_bps: toleranceBps });
137
+ const csv = worksheetCsv(batch, auditResult, toleranceBps);
138
+ const suffix = batch.batch_id.replace(/^pg_/, '').slice(0, 8);
139
+ const filename = args.filename ?? `progress-audit-${suffix}.csv`;
140
+ let filePath;
141
+ try {
142
+ filePath = await safeExportPath(path.join(deps.baseDir, 'exports'), filename);
143
+ }
144
+ catch (error) {
145
+ if (error instanceof ProgressError)
146
+ throw error;
147
+ throw new ProgressError('E_PROGRESS_EXPORT_FAILED', String(error));
148
+ }
149
+ try {
150
+ await writeFile(filePath, csv, 'utf8');
151
+ }
152
+ catch (error) {
153
+ throw new ProgressError('E_PROGRESS_EXPORT_FAILED', String(error));
154
+ }
155
+ return {
156
+ path: filePath,
157
+ sha256: sha256(csv),
158
+ rows: batch.rows.length + 1,
159
+ bytes: Buffer.byteLength(csv, 'utf8'),
160
+ items: auditResult.summary.items,
161
+ issues: auditResult.summary.issues,
162
+ };
163
+ },
164
+ });
165
+ return [ingest, audit, exportCsv];
166
+ }