@qinsilk/scm-agent-tools 0.0.0-stage → 0.2.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.
Files changed (52) hide show
  1. package/README.md +48 -2
  2. package/cli/api.js +114 -0
  3. package/cli/commands/data.js +112 -0
  4. package/cli/commands/docs.js +121 -0
  5. package/cli/commands/log.js +152 -0
  6. package/cli/commands/query.js +285 -0
  7. package/cli/commands/report.js +213 -0
  8. package/cli/commands/staff.js +505 -0
  9. package/cli/config.js +46 -0
  10. package/cli/datasets.js +541 -0
  11. package/cli/scm-agent.js +110 -0
  12. package/cli/skills.js +89 -0
  13. package/cli/table.js +43 -0
  14. package/docs/api.md +2067 -0
  15. package/docs/cli.md +252 -0
  16. package/package.json +27 -4
  17. package/reports/excel.js +107 -0
  18. package/reports/factoryEvaluation.js +128 -0
  19. package/reports/goodsBom.js +648 -0
  20. package/reports/goodsFirstOrder.js +409 -0
  21. package/reports/goodsInventory.js +220 -0
  22. package/reports/goodsSize.js +440 -0
  23. package/reports/image.js +216 -0
  24. package/reports/leakCheck.js +694 -0
  25. package/reports/materialPurchaseExport.js +393 -0
  26. package/reports/materialTransit.js +593 -0
  27. package/reports/processFollowup.js +312 -0
  28. package/reports/produceBoard.js +353 -0
  29. package/reports/produceCost.js +366 -0
  30. package/reports/produceCostDetail.js +622 -0
  31. package/reports/produceDaily.js +485 -0
  32. package/reports/storeInGoods.js +276 -0
  33. package/reports/supplierBalance.js +671 -0
  34. package/reports/ticketStatistics.js +289 -0
  35. package/reports/util.js +83 -0
  36. package/skills/bom/345/225/206/345/223/201/344/277/241/346/201/257/SKILL.md +40 -0
  37. package/skills/qinsilk-scm/SKILL.md +126 -0
  38. package/skills//344/276/233/345/272/224/345/225/206/345/257/271/350/264/246/345/257/274/345/207/272/SKILL.md +38 -0
  39. package/skills//345/225/206/345/223/201/345/260/272/345/257/270/344/277/241/346/201/257/SKILL.md +40 -0
  40. package/skills//345/225/206/345/223/201/351/246/226/345/215/225/351/235/242/346/226/231/345/267/245/345/272/217/345/215/225/344/273/267/SKILL.md +39 -0
  41. package/skills//345/267/245/345/216/202/350/257/204/344/274/260/346/212/245/350/241/250/SKILL.md +39 -0
  42. package/skills//346/210/220/345/223/201/345/205/245/345/272/223/346/212/245/350/241/250/SKILL.md +40 -0
  43. package/skills//346/234/215/350/243/205/346/210/220/345/223/201/347/233/230/345/255/230/350/241/250/SKILL.md +39 -0
  44. package/skills//346/274/217/346/226/231/346/243/200/346/265/213/346/227/245/346/212/245/SKILL.md +40 -0
  45. package/skills//347/211/251/346/226/231/345/234/250/351/200/224/347/212/266/346/200/201/346/212/245/350/241/250/SKILL.md +40 -0
  46. package/skills//347/211/251/346/226/231/351/207/207/350/264/255/345/257/274/345/207/272/SKILL.md +39 -0
  47. package/skills//347/224/237/344/272/247/345/215/225/345/267/245/345/272/217/350/267/237/350/277/233/350/241/250/SKILL.md +39 -0
  48. package/skills//347/224/237/344/272/247/346/210/220/346/234/254/346/212/245/350/241/250/SKILL.md +41 -0
  49. package/skills//347/224/237/344/272/247/346/210/220/346/234/254/346/212/245/350/241/250/346/230/216/347/273/206/SKILL.md +40 -0
  50. package/skills//347/224/237/344/272/247/346/227/245/346/212/245/SKILL.md +42 -0
  51. package/skills//347/224/237/344/272/247/347/212/266/345/206/265/347/273/237/350/256/241/350/241/250/SKILL.md +39 -0
  52. package/skills//347/224/237/344/272/247/350/212/202/347/202/271/347/234/213/346/235/277/SKILL.md +39 -0
@@ -0,0 +1,485 @@
1
+ // 生产日报 HTML 报表:今日生产入库汇总 + 计划出货 + 生产状态 + 风险待料四个区块。
2
+ // 数据来自 agent 接口(produce-store-in 列表/详情、produce 列表),
3
+ // 聚合与 HTML 结构从原 Python 报表脚本移植,区块结构、合并单元格与颜色标记保持一致。
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { call } = require('../cli/api');
7
+ const { readConfig, DEFAULT_SERVER } = require('../cli/config');
8
+ const { timestamp, dateStr, shiftDate, createThrottle, fetchAll } = require('./util');
9
+
10
+ /** 服务器对同一 AI Key 每分钟限 30 次请求,两次请求间隔至少 2100 毫秒 */
11
+ const REQUEST_INTERVAL_MS = 2100;
12
+
13
+ /** 生产单链接域名:在 render 前按本机配置的服务器地址确定,各渲染函数共用 */
14
+ let server = DEFAULT_SERVER;
15
+
16
+ /** HTML 转义,等价于 Python html.escape(quote=True) */
17
+ function escapeHtml(value) {
18
+ return String(value)
19
+ .replace(/&/g, '&')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&#x27;');
24
+ }
25
+
26
+ /** 字符串比较(按 Unicode 码点,与 Python sorted 对字符串的排序一致) */
27
+ function cmpStr(a, b) {
28
+ return a < b ? -1 : a > b ? 1 : 0;
29
+ }
30
+
31
+ /** 按多个字段依次比较,等价于 Python 按元组排序 */
32
+ function tupleCmp(keys) {
33
+ return (a, b) => {
34
+ for (const key of keys) {
35
+ const result = cmpStr(String(a[key]), String(b[key]));
36
+ if (result !== 0) {
37
+ return result;
38
+ }
39
+ }
40
+ return 0;
41
+ };
42
+ }
43
+
44
+ /** 取第一个非空值(null、undefined、空字符串视为空) */
45
+ function first(...values) {
46
+ const found = values.find((v) => v !== null && v !== undefined && v !== '');
47
+ return found === undefined ? '' : found;
48
+ }
49
+
50
+ /** 数值容错:无法转数字或负数返回 0 */
51
+ function nonNegativeNumber(raw) {
52
+ const number = Number(raw);
53
+ if (!Number.isFinite(number) || number < 0) {
54
+ return 0;
55
+ }
56
+ return number;
57
+ }
58
+
59
+ /**
60
+ * 解析交货日期为 yyyy-MM-dd。
61
+ * agent 接口正常返回 yyyy-MM-dd 字符串或 null;数字按时间戳容错(秒/毫秒自动判断)。
62
+ * 无法解析返回 null,与原脚本的 parse_delivery_date 行为一致。
63
+ */
64
+ function parseDate(raw) {
65
+ if (raw === null || raw === undefined || raw === '' || raw === 0) {
66
+ return null;
67
+ }
68
+ if (typeof raw === 'number' && Number.isFinite(raw)) {
69
+ const ms = Math.abs(raw) > 10_000_000_000 ? raw : raw * 1000;
70
+ const d = new Date(ms);
71
+ return Number.isNaN(d.getTime()) ? null : dateStr(d);
72
+ }
73
+ const matched = /^\d{4}-\d{2}-\d{2}/.exec(String(raw).trim());
74
+ return matched ? matched[0] : null;
75
+ }
76
+
77
+ /**
78
+ * 拉取一天的生产入库单列表。
79
+ * agent 接口的 from/to 查询参数即业务日期区间;结束时间由服务端自动加一天,传当天即覆盖全天。
80
+ */
81
+ async function fetchStoreInRows(day, throttle) {
82
+ const { list } = await fetchAll(
83
+ (page, limit) => throttle(() => call('GET', '/api/agent/v1/produce-store-in/list', { from: day, to: day, page, limit })),
84
+ { limit: 100 },
85
+ );
86
+ return list;
87
+ }
88
+
89
+ /**
90
+ * 补齐入库单的供应商、客户、款号、数量。
91
+ * 列表接口已经直接给出这四个字段,只有缺失时才逐单调详情接口补齐(按单号缓存,同一单只调一次),
92
+ * 因此与原脚本「每单必调详情」相比请求量更少,数据完整时结果相同。
93
+ * 补齐规则与原脚本一致:供应商取生产单供应商或供应商;款号在行上为空且明细恰好一行时用明细款号;
94
+ * 数量两个汇总字段都为空时按明细数量求和;客户只在行上为空时用详情值。
95
+ */
96
+ async function enrichRows(rows, throttle) {
97
+ const detailCache = new Map();
98
+ const result = [];
99
+ for (const row of rows) {
100
+ const ordersSn = String(first(row.ordersSn));
101
+ let quantity = first(row.sumStoreInNumber, row.sumInNumber);
102
+ if (quantity === '') {
103
+ quantity = null;
104
+ }
105
+ const needDetail = !first(row.produceSupplierName, row.supplierName)
106
+ || !row.produceOrderClientName
107
+ || !row.goodsSn
108
+ || quantity === null;
109
+ let detail = null;
110
+ if (needDetail && ordersSn) {
111
+ if (!detailCache.has(ordersSn)) {
112
+ detailCache.set(ordersSn, await throttle(() => call('GET', '/api/agent/v1/produce-store-in/detail', { sn: ordersSn })));
113
+ }
114
+ detail = detailCache.get(ordersSn);
115
+ }
116
+ const source = detail || row;
117
+ const goodsList = (detail && detail.orderGoods) || [];
118
+ const goods = first(row.goodsSn, goodsList.length === 1 ? goodsList[0].goodsSn : '');
119
+ if (quantity === null) {
120
+ quantity = goodsList.length
121
+ ? goodsList.reduce((sum, item) => sum + Number(item.number || 0), 0)
122
+ : 0;
123
+ }
124
+ result.push({
125
+ supplier: String(first(source.produceSupplierName, source.supplierName) || '未填写供应商'),
126
+ goods: String(goods || '未填写货号'),
127
+ client: String(source.produceOrderClientName || '未填写客户'),
128
+ quantity,
129
+ });
130
+ }
131
+ return result;
132
+ }
133
+
134
+ /**
135
+ * 按「供应商 × 款号 × 客户」聚合入库数量。
136
+ * Map 保持首次出现的顺序,因此表格行的排列顺序与原脚本一致。
137
+ */
138
+ function aggregateRows(rows) {
139
+ const aggregated = new Map();
140
+ for (const row of rows) {
141
+ const key = `${row.supplier}\u0001${row.goods}\u0001${row.client}`;
142
+ let item = aggregated.get(key);
143
+ if (!item) {
144
+ item = { supplier: row.supplier, goods: row.goods, client: row.client, quantity: 0 };
145
+ aggregated.set(key, item);
146
+ }
147
+ const number = Number(row.quantity);
148
+ if (Number.isFinite(number)) {
149
+ item.quantity += number;
150
+ }
151
+ }
152
+ for (const item of aggregated.values()) {
153
+ // 聚合结果最多保留 4 位小数,整数不带小数点
154
+ item.quantity = Math.round(item.quantity * 10000) / 10000;
155
+ }
156
+ return [...aggregated.values()];
157
+ }
158
+
159
+ /**
160
+ * 拉取生产中与待生产的生产单(状态 2、4),用于计划出货、生产状态、风险待料三个区块。
161
+ * agent 接口的 state 参数只接受单个状态,因此按状态分别取全量后合并;
162
+ * 两个状态的结果理论上不重叠,合并时仍按生产单号去重,防止服务端重复返回。
163
+ */
164
+ async function fetchProduceOrders(throttle) {
165
+ const merged = [];
166
+ const seen = new Set();
167
+ for (const state of [2, 4]) {
168
+ const { list } = await fetchAll(
169
+ (page, limit) => throttle(() => call('GET', '/api/agent/v1/produce/list', { state, page, limit })),
170
+ { limit: 100 },
171
+ );
172
+ for (const row of list) {
173
+ const key = String(row.ordersSn || '');
174
+ // 生产单号为空时不参与去重,避免多张无单号的单被错误合并
175
+ if (key && seen.has(key)) {
176
+ continue;
177
+ }
178
+ if (key) {
179
+ seen.add(key);
180
+ }
181
+ merged.push(row);
182
+ }
183
+ }
184
+ return merged;
185
+ }
186
+
187
+ /**
188
+ * 把接口行标准化为报表内部结构。
189
+ * 单号显示规则:只要任意一张单带生产批次号,全部单优先显示批次号,否则显示生产单号。
190
+ */
191
+ function normalizeProduceOrders(rows) {
192
+ const useBatch = rows.some((row) => first(row.produceBatchSn) !== '');
193
+ return rows.map((row) => {
194
+ const ordersSn = String(first(row.ordersSn));
195
+ const batchSn = String(first(row.produceBatchSn));
196
+ return {
197
+ date: parseDate(row.deliveryTime),
198
+ supplier: String(first(row.supplierName) || '未填写供应商/加工厂'),
199
+ displaySn: String((useBatch && batchSn) || ordersSn || '未填写单号'),
200
+ ordersSn,
201
+ goods: String(first(row.goodsSn) || '未填写货号'),
202
+ client: String(first(row.clientName) || '未填写客户'),
203
+ issueNotCompleteNumber: nonNegativeNumber(row.issueNotCompleteNumber),
204
+ cutBedState: row.cutBedState,
205
+ };
206
+ });
207
+ }
208
+
209
+ /**
210
+ * 按裁床状态归类生产状态:裁床完成为「已裁剪」,
211
+ * 待裁床或裁床中为「待裁剪」,其余(不含裁床、未开始裁床)为「生产中」。
212
+ */
213
+ function classifyProductionStatus(rows) {
214
+ return rows.map((row) => ({
215
+ ...row,
216
+ status: row.cutBedState === 4 ? '已裁剪' : (row.cutBedState === 2 || row.cutBedState === 3) ? '待裁剪' : '生产中',
217
+ }));
218
+ }
219
+
220
+ /**
221
+ * 按「供应商 × 生产状态 × 款号 × 客户」分组,每组渲染为状态表的一行。
222
+ * Map 保持首次出现的顺序。
223
+ */
224
+ function aggregateProductionStatus(rows) {
225
+ const aggregated = new Map();
226
+ for (const row of rows) {
227
+ const key = `${row.supplier}\u0001${row.status}\u0001${row.goods}\u0001${row.client}`;
228
+ if (!aggregated.has(key)) {
229
+ aggregated.set(key, { supplier: row.supplier, status: row.status, goods: row.goods, client: row.client });
230
+ }
231
+ }
232
+ return [...aggregated.values()];
233
+ }
234
+
235
+ /** 风险待料:未齐料数大于 0 的生产单,按供应商、单号、款号排序 */
236
+ function materialRiskRows(rows) {
237
+ return rows
238
+ .filter((row) => row.issueNotCompleteNumber > 0)
239
+ .sort(tupleCmp(['supplier', 'displaySn', 'goods', 'ordersSn']));
240
+ }
241
+
242
+ /**
243
+ * 生产单链接:单号存在时渲染为指向生产单详情页的超链接,文本为「单号(款号)」。
244
+ * 链接域名取本机配置的服务器地址,与登录环境保持一致。
245
+ */
246
+ function orderLink(order) {
247
+ const text = escapeHtml(`${order.displaySn}(${order.goods})`);
248
+ if (!order.ordersSn) {
249
+ return text;
250
+ }
251
+ const target = `${server}/scm-front/#/production/production-order-details?ordersSn=${encodeURIComponent(order.ordersSn)}`;
252
+ return `<a href="${escapeHtml(target)}" target="_blank" rel="noopener noreferrer">${text}</a>`;
253
+ }
254
+
255
+ /**
256
+ * 渲染「今日已发出」主表。
257
+ * 合并单元格规则:供应商列在该供应商全部明细行数上合并(rowspan=明细行数),
258
+ * 款号列在同一款号的行数上合并;子分组顺序保持聚合结果的首次出现顺序。
259
+ */
260
+ function renderDailyTable(rows, day) {
261
+ const groups = new Map();
262
+ for (const row of rows) {
263
+ if (!groups.has(row.supplier)) {
264
+ groups.set(row.supplier, []);
265
+ }
266
+ groups.get(row.supplier).push(row);
267
+ }
268
+ const body = [];
269
+ for (const [supplier, items] of groups) {
270
+ const goodsGroups = new Map();
271
+ for (const item of items) {
272
+ if (!goodsGroups.has(item.goods)) {
273
+ goodsGroups.set(item.goods, []);
274
+ }
275
+ goodsGroups.get(item.goods).push(item);
276
+ }
277
+ let supplierIndex = 0;
278
+ for (const [goods, goodsItems] of goodsGroups) {
279
+ goodsItems.forEach((item, goodsIndex) => {
280
+ const supplierCell = supplierIndex === 0 ? `<th rowspan="${items.length}">${escapeHtml(supplier)}</th>` : '';
281
+ const goodsCell = goodsIndex === 0 ? `<th rowspan="${goodsItems.length}" class="goods">${escapeHtml(goods)}</th>` : '';
282
+ body.push(`<tr>${supplierCell}${goodsCell}<td>${escapeHtml(item.client)}</td><td class="num">${escapeHtml(item.quantity)}</td></tr>`);
283
+ supplierIndex += 1;
284
+ });
285
+ }
286
+ }
287
+ return (
288
+ `<section><h1>✅ 今日已发出</h1><p>业务日期:${escapeHtml(day)} 共 ${rows.length} 条汇总明细</p>` +
289
+ `<table><thead><tr><th>供应商/加工厂</th><th>款号(商品货号)</th><th>客户</th><th>生产入库数量</th></tr></thead><tbody>${body.join('')}</tbody></table></section>`
290
+ );
291
+ }
292
+
293
+ /**
294
+ * 渲染「计划出货时间」区块,分三个桶:
295
+ * 计划出货(今日起 30 个日历日内)、已过期(交货日期早于今日)、未设置日期。
296
+ * 已过期与未设置两个桶默认折叠(details 元素),计划出货展开。
297
+ * 排序:先按交货日期(无日期排在最前),再按供应商、单号、款号。
298
+ * 合并单元格规则:同一交货日期在全部行数上合并(rowspan=该日期行数),
299
+ * 日期组内同一供应商再按行数合并;行背景色按桶区分(过期红底、未设置黄底、计划浅蓝底)。
300
+ */
301
+ function renderDeliveryTable(rows, today) {
302
+ const end = shiftDate(30, new Date(`${today}T00:00:00`));
303
+ const buckets = [
304
+ { title: '计划出货', rows: rows.filter((r) => r.date && today <= r.date && r.date <= end), collapsed: false },
305
+ { title: '已过期', rows: rows.filter((r) => r.date && r.date < today), collapsed: true },
306
+ { title: '未设置日期', rows: rows.filter((r) => !r.date), collapsed: true },
307
+ ];
308
+ const sortByDateAndOrder = (a, b) => cmpStr(a.date || '', b.date || '') || tupleCmp(['supplier', 'displaySn', 'goods'])(a, b);
309
+ const output = ['<section class="delivery"><h2>📆计划出货时间</h2><p>计划范围:今日至未来 30 个日历日;过期及未设置日期单独列示。</p>'];
310
+ for (const bucket of buckets) {
311
+ const open = bucket.collapsed ? '<details class="delivery-group">' : '<div class="delivery-group">';
312
+ const close = bucket.collapsed ? '</details>' : '</div>';
313
+ output.push(bucket.collapsed ? `${open}<summary>${bucket.title}(${bucket.rows.length} 条)</summary>` : open);
314
+ output.push('<table><thead><tr><th>预计交货日期</th><th>供应商/加工厂</th><th>生产单号/生产批次号(商品)</th></tr></thead><tbody>');
315
+ if (!bucket.rows.length) {
316
+ output.push('<tr><td colspan="3" class="empty">暂无数据</td></tr>');
317
+ output.push(`</tbody></table>${close}`);
318
+ continue;
319
+ }
320
+ const sorted = [...bucket.rows].sort(sortByDateAndOrder);
321
+ const dateGroups = new Map();
322
+ for (const row of sorted) {
323
+ const key = row.date || '';
324
+ if (!dateGroups.has(key)) {
325
+ dateGroups.set(key, []);
326
+ }
327
+ dateGroups.get(key).push(row);
328
+ }
329
+ for (const [date, dateItems] of dateGroups) {
330
+ const supplierGroups = new Map();
331
+ for (const row of dateItems) {
332
+ if (!supplierGroups.has(row.supplier)) {
333
+ supplierGroups.set(row.supplier, []);
334
+ }
335
+ supplierGroups.get(row.supplier).push(row);
336
+ }
337
+ // 日期标签:已过期桶显示完整日期;计划桶显示「几号」;无日期显示「未设置」
338
+ const dateLabel = bucket.title === '已过期' ? date : date ? `${Number(date.slice(8, 10))} 号` : '未设置';
339
+ let dateIndex = 0;
340
+ for (const [supplier, supplierItems] of supplierGroups) {
341
+ supplierItems.forEach((row, index) => {
342
+ const dateCell = dateIndex === 0 ? `<th rowspan="${dateItems.length}">${escapeHtml(dateLabel)}</th>` : '';
343
+ const supplierCell = index === 0 ? `<th rowspan="${supplierItems.length}">${escapeHtml(supplier)}</th>` : '';
344
+ const rowClass = bucket.title === '已过期' ? 'overdue' : !date ? 'nodate' : 'planned';
345
+ output.push(`<tr class="${rowClass}">${dateCell}${supplierCell}<td>${orderLink(row)}</td></tr>`);
346
+ dateIndex += 1;
347
+ });
348
+ }
349
+ }
350
+ output.push(`</tbody></table>${close}`);
351
+ }
352
+ output.push('</section>');
353
+ return output.join('');
354
+ }
355
+
356
+ /**
357
+ * 渲染「生产状态」区块,同时输出全部、已裁剪、待裁剪三张表,
358
+ * 页面脚本按筛选按钮切换显示。
359
+ * 行排序:供应商按字典序;同一供应商内状态按「生产中、已裁剪、待裁剪」;
360
+ * 同一状态内按款号、客户排序。供应商列按该供应商全部行数合并,状态列按该状态行数合并。
361
+ */
362
+ function renderProductionStatusTable(rows) {
363
+ const statusOrder = { 生产中: 0, 已裁剪: 1, 待裁剪: 2 };
364
+
365
+ function renderTable(tableId, selectedRows) {
366
+ const supplierGroups = new Map();
367
+ for (const row of selectedRows) {
368
+ if (!supplierGroups.has(row.supplier)) {
369
+ supplierGroups.set(row.supplier, new Map());
370
+ }
371
+ const statusGroups = supplierGroups.get(row.supplier);
372
+ if (!statusGroups.has(row.status)) {
373
+ statusGroups.set(row.status, []);
374
+ }
375
+ statusGroups.get(row.status).push(row);
376
+ }
377
+ const output = [`<table id="${tableId}" class="status-table"><thead><tr><th>供应商/加工厂</th><th>裁床状态</th><th>商品货号</th><th>客户</th></tr></thead><tbody>`];
378
+ const orderedSuppliers = [...supplierGroups.entries()].sort((a, b) => cmpStr(a[0], b[0]));
379
+ for (const [supplier, statusGroups] of orderedSuppliers) {
380
+ const total = [...statusGroups.values()].reduce((sum, items) => sum + items.length, 0);
381
+ let supplierIndex = 0;
382
+ const orderedStatus = [...statusGroups.entries()].sort((a, b) => (statusOrder[a[0]] ?? 9) - (statusOrder[b[0]] ?? 9));
383
+ for (const [status, items] of orderedStatus) {
384
+ items.sort(tupleCmp(['goods', 'client']));
385
+ items.forEach((row, index) => {
386
+ const supplierCell = supplierIndex === 0 ? `<th rowspan="${total}">${escapeHtml(supplier)}</th>` : '';
387
+ const statusCell = index === 0 ? `<th rowspan="${items.length}">${escapeHtml(status)}</th>` : '';
388
+ output.push(`<tr>${supplierCell}${statusCell}<td>${escapeHtml(row.goods)}</td><td>${escapeHtml(row.client)}</td></tr>`);
389
+ supplierIndex += 1;
390
+ });
391
+ }
392
+ }
393
+ if (!selectedRows.length) {
394
+ output.push('<tr><td colspan="4" class="empty">暂无数据</td></tr>');
395
+ }
396
+ output.push('</tbody></table>');
397
+ return output.join('');
398
+ }
399
+
400
+ const output = [
401
+ '<section class="status"><h2>🔁生产状态</h2><div class="status-filters">',
402
+ '<button type="button" class="active" data-status-filter="全部">生产中</button>',
403
+ '<button type="button" data-status-filter="已裁剪">已裁剪</button>',
404
+ '<button type="button" data-status-filter="待裁剪">待裁剪</button>',
405
+ '</div><p id="status-count"></p>',
406
+ ];
407
+ output.push(renderTable('status-table-all', rows));
408
+ output.push(renderTable('status-table-done', rows.filter((row) => row.status === '已裁剪')));
409
+ output.push(renderTable('status-table-wait', rows.filter((row) => row.status === '待裁剪')));
410
+ output.push('</section>');
411
+ return output.join('');
412
+ }
413
+
414
+ /**
415
+ * 渲染「风险&待料」区块:未齐料的生产单按供应商分组。
416
+ * 输入行已按供应商、单号、款号排好序,分组保持该顺序,供应商列按组内行数合并。
417
+ */
418
+ function renderMaterialRiskTable(rows) {
419
+ const supplierGroups = new Map();
420
+ for (const row of rows) {
421
+ if (!supplierGroups.has(row.supplier)) {
422
+ supplierGroups.set(row.supplier, []);
423
+ }
424
+ supplierGroups.get(row.supplier).push(row);
425
+ }
426
+ const output = ['<section class="material-risk"><h2>🚨风险&待料</h2><table><thead><tr><th>供应商/加工厂</th><th>生产单号/生产批次号(商品)</th><th>未齐料数</th></tr></thead><tbody>'];
427
+ for (const [supplier, items] of supplierGroups) {
428
+ items.forEach((row, index) => {
429
+ const supplierCell = index === 0 ? `<th rowspan="${items.length}">${escapeHtml(supplier)}</th>` : '';
430
+ output.push(`<tr>${supplierCell}<td>${orderLink(row)}</td><td class="num">${escapeHtml(row.issueNotCompleteNumber)}</td></tr>`);
431
+ });
432
+ }
433
+ if (!rows.length) {
434
+ output.push('<tr><td colspan="3" class="empty">暂无未齐料生产单</td></tr>');
435
+ }
436
+ output.push('</tbody></table></section>');
437
+ return output.join('');
438
+ }
439
+
440
+ /** 组装完整 HTML 文档(样式与筛选按钮脚本与原脚本一致) */
441
+ function render(rows, produceRows, statusRows, materialRisks, day) {
442
+ const daily = renderDailyTable(rows, day);
443
+ const delivery = renderDeliveryTable(produceRows, day);
444
+ const status = renderProductionStatusTable(statusRows);
445
+ const risk = renderMaterialRiskTable(materialRisks);
446
+ return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>生产日报 - ${escapeHtml(day)}</title><style>
447
+ body{font-family:"Microsoft YaHei",Arial,sans-serif;color:#1f2937;margin:28px;background:#f8fafc}h1,h2{font-size:24px;margin:0 0 8px}h2{font-size:22px}h3{font-size:18px;margin:18px 0 8px}p{color:#64748b;margin:0 0 18px}.report{max-width:1100px}section{background:#fff;border:1px solid #dbe3ee;border-radius:8px;padding:18px}section+section{margin-top:30px}.delivery-group{margin-top:14px}summary{cursor:pointer;background:#dbeafe;color:#1e3a5f;font-weight:600;padding:10px 12px;border:1px solid #cbd5e1}.status-filters{margin:8px 0 14px}.status-filters button{margin-right:8px;padding:6px 14px;border:1px solid #cbd5e1;border-radius:4px;background:#fff;cursor:pointer}.status-filters button.active{background:#2563eb;color:#fff;border-color:#2563eb}.status-filter-head,.status-filter-cell{display:none}.status-merged-head{display:table-cell}table{border-collapse:collapse;width:100%;font-size:14px}th,td{border:1px solid #cbd5e1;padding:10px 12px;text-align:left}thead th{background:#e8f1fb;color:#1e3a5f}tbody th{background:#f0f7ff;font-weight:600;vertical-align:middle}.goods{background:#f8fbff}.num{text-align:right}.planned td{background:#f8fbff}.overdue td{background:#fff1f2}.nodate td{background:#fffbeb}.empty{text-align:center;color:#64748b}a{color:#2563eb;text-decoration:none}tbody tr:hover td{filter:brightness(.98)}</style></head><body><div class="report">${daily}${delivery}${status}${risk}</div><script>
448
+ const buttons=document.querySelectorAll('[data-status-filter]');const tables={全部:document.getElementById('status-table-all'),已裁剪:document.getElementById('status-table-done'),待裁剪:document.getElementById('status-table-wait')};const count=document.getElementById('status-count');function filterStatus(value){Object.entries(tables).forEach(([name,table])=>{if(table)table.style.display=name===value?'table':'none'});if(count)count.textContent='显示 '+(tables[value]?tables[value].querySelectorAll('tbody tr').length:0)+' 条生产状态明细'}buttons.forEach(button=>button.addEventListener('click',()=>{buttons.forEach(item=>item.classList.toggle('active',item===button));filterStatus(button.dataset.statusFilter)}));filterStatus('全部');</script></body></html>`;
449
+ }
450
+
451
+ /**
452
+ * 生成生产日报 HTML。
453
+ * 数据流:当天入库单列表(缺失字段时逐单调详情补齐)→ 供应商×款号×客户聚合;
454
+ * 生产中/待生产生产单列表 → 计划出货分桶、裁床状态归类、未齐料筛选。
455
+ * 输出文件已存在时报错,不覆盖。
456
+ */
457
+ async function generate(argv = {}) {
458
+ const throttle = createThrottle(REQUEST_INTERVAL_MS);
459
+ const day = parseDate(argv.date) || dateStr();
460
+ const config = readConfig() || {};
461
+ server = config.server || DEFAULT_SERVER;
462
+
463
+ const storeInRows = await fetchStoreInRows(day, throttle);
464
+ const aggregated = aggregateRows(await enrichRows(storeInRows, throttle));
465
+
466
+ const produceOrders = await fetchProduceOrders(throttle);
467
+ const normalized = normalizeProduceOrders(produceOrders);
468
+ const statusRows = aggregateProductionStatus(classifyProductionStatus(normalized));
469
+ const risks = materialRiskRows(normalized);
470
+
471
+ const document = render(aggregated, normalized, statusRows, risks, day);
472
+ const outDir = argv.out || process.cwd();
473
+ const filePath = path.resolve(outDir, `生产日报${timestamp()}.html`);
474
+ if (fs.existsSync(filePath)) {
475
+ throw new Error(`输出文件已存在:${filePath}`);
476
+ }
477
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
478
+ fs.writeFileSync(filePath, document, 'utf8');
479
+ return {
480
+ filePaths: [filePath],
481
+ summary: `生产日报已生成:入库汇总 ${aggregated.length} 条、生产单 ${normalized.length} 张、风险待料 ${risks.length} 单`,
482
+ };
483
+ }
484
+
485
+ module.exports = { generate };