@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,694 @@
1
+ // 漏料检测日报:每个生产单导出一个「物料数据」Excel,内容为生产单详情页
2
+ // 物料 BOM 表的完整内置列(75 列),默认隐藏 0 用量行。
3
+ // 预计总用量公式:下单数 × 单件用量 × (1 + 损耗率%) ÷ (1 - 缩水率%),
4
+ // 逐行保留 4 位小数后再分组累加;分组键为 物料id + 物料单品id。
5
+ // 数据流:GET /api/agent/v1/produce/list 取近 N 天第一页 100 张生产单,
6
+ // 逐单 GET /api/agent/v1/produce/detail 取物料行 materialList、BOM 映射行
7
+ // bomList 与颜色尺码明细 orderGoods,
8
+ // 再按 20 单一批 GET /api/agent/v1/produce/material-data 取占用与缺料数据
9
+ // (按 materialSkuId 合并进物料行)。
10
+ // 生成逻辑从原 Python 报表脚本 export_produce_material_daily.py 移植,
11
+ // 列名、列序、公式与文件名规则保持一致。与原脚本的差异与缺口:
12
+ // 1. 原脚本按更新时间倒序取第一页 100 单;agent 列表接口排序固定为
13
+ // 生产状态序(草稿、待生产、生产中、生产完成、撤销),因此改为按业务
14
+ // 日期近 N 天(默认 30 天,可用 days 参数调整)取第一页 100 单。
15
+ // 2. 物料图片列嵌入图片:物料单品首图优先,无则用物料主图(与后端行级
16
+ // 展示图的取图顺序一致),下载失败时跳过该行图片,不中断生成。
17
+ // 3. 地址列只输出详细地址;省市区名称需要行政区划 id 映射数据,本 CLI 不带
18
+ // 该数据,因此省市区段留空。
19
+ // 4. 物料备注列留空:生产单物料行的来源数据没有该字段(原脚本读
20
+ // materialRemark 与 materialSkuDTO.remark,在这个接口里同样取不到值)。
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const { call } = require('../cli/api');
24
+ const { timestamp, shiftDate, createThrottle } = require('./util');
25
+ const { createWorkbook, saveWorkbook, embedImage } = require('./excel');
26
+ const { downloadImages } = require('./image');
27
+
28
+ /** 服务器对同一 AI Key 每分钟限 30 次请求,两次请求间隔至少 2100 毫秒 */
29
+ const REQUEST_INTERVAL_MS = 2100;
30
+ /** 列表单页大小(服务端单页上限 100,同时是默认处理的生产单数上限) */
31
+ const PAGE_LIMIT = 100;
32
+ /** 物料占用接口一次最多查询的生产单数 */
33
+ const OCCUPATION_BATCH = 20;
34
+ /** 取单的业务日期窗口天数(原脚本无日期窗口,按更新时间取最近 100 单) */
35
+ const DEFAULT_DAYS = 30;
36
+ const SHEET_NAME = '物料数据';
37
+
38
+ /** 表头底色(浅蓝,与原脚本一致) */
39
+ const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9EAF7' } };
40
+ const BORDER = {
41
+ top: { style: 'thin', color: { argb: 'FFCCCCCC' } },
42
+ left: { style: 'thin', color: { argb: 'FFCCCCCC' } },
43
+ bottom: { style: 'thin', color: { argb: 'FFCCCCCC' } },
44
+ right: { style: 'thin', color: { argb: 'FFCCCCCC' } },
45
+ };
46
+ const CENTER = { vertical: 'middle', horizontal: 'center', wrapText: true };
47
+ const LEFT = { vertical: 'middle', horizontal: 'left', wrapText: true };
48
+
49
+ /** 首列物料图片的嵌入尺寸(像素),与数据行高约 40 像素匹配 */
50
+ const IMAGE_SIZE = { width: 38, height: 38 };
51
+
52
+ /** 75 个内置列的字段与列名(顺序对照页面 MaterialBom 内置列清单) */
53
+ const COLUMNS = [
54
+ ['materialImgUrl', '图片'],
55
+ ['materialName', '物料名称'],
56
+ ['materialSn', '物料货号'],
57
+ ['useState', '用料状态'],
58
+ ['inventoryEstimate', '库存预估'],
59
+ ['isComplete', '齐料状态'],
60
+ ['issueState', '发料状态'],
61
+ ['supplierName', '供应商'],
62
+ ['supplierAddressText', '地址'],
63
+ ['supplierCellPhone', '联系电话'],
64
+ ['materialType', '类型'],
65
+ ['fabricType', '面料属性'],
66
+ ['materialColor', '物料颜色'],
67
+ ['matSpec', '物料规格'],
68
+ ['purchaseUserItems', '采购员'],
69
+ ['bomSupplierColor', '色号'],
70
+ ['materialSkuSupplierColor', '物料色号'],
71
+ ['materialFabricWidth', '幅宽(cm)'],
72
+ ['materialGramWeight', '克重(g/㎡)'],
73
+ ['materialQunce', '安数'],
74
+ ['shrinkRates', '缩水率(%)'],
75
+ ['materialLossRates', '损耗率(%)'],
76
+ ['materialComposition', '成分'],
77
+ ['materialPart', '使用部位'],
78
+ ['materialPostProcess', '后处理'],
79
+ ['price', '基础单价'],
80
+ ['avgGoodsNumber', '单件用量'],
81
+ ['avgGoodsIncludingLossNumber', '含损耗单件用量'],
82
+ ['avgProduceNumber', '生产单件用量'],
83
+ ['materialUnit', '基础单位'],
84
+ ['number', '预计总用量(下单数)'],
85
+ ['predictNumber', '预计总用量(裁床数)'],
86
+ ['cost', '物料成本'],
87
+ ['convertRate', '转换系数'],
88
+ ['predictPurchaseNumber', '预计采购数'],
89
+ ['purchasePrice', '采购单价'],
90
+ ['purchaseUnitName', '采购单位'],
91
+ ['predictWeight', '预计净重(kg)'],
92
+ ['materialNetWeight', '净重(kg)'],
93
+ ['predictPower', '预计匹数'],
94
+ ['materialMeterOutput', '出米数'],
95
+ ['materialAvailableNumber', '可用库存'],
96
+ ['factoryMaterialAvailableNumber', '加工厂可用库存'],
97
+ ['factoryMaterialStoredNumber', '加工厂当前库存'],
98
+ ['purchaseNumber', '采购数'],
99
+ ['purchaseWinNumber', '采购待入库数'],
100
+ ['issueNumber', '发料数'],
101
+ ['issuedPreCutNumber', '发料预裁数'],
102
+ ['waitIssueNumber', '待发数'],
103
+ ['ableIssueNumber', '可发数'],
104
+ ['issueWoutNumber', '发料待出库数'],
105
+ ['issueWinNumber', '发料待入库数'],
106
+ ['transferNumber', '调拨数'],
107
+ ['transferWoutNumber', '调拨待出库'],
108
+ ['transferWinNumber', '调拨待入库'],
109
+ ['pickNumber', '已领料'],
110
+ ['pickedPreCutNumber', '领料预裁数'],
111
+ ['pickOutNumber', '领料已出库'],
112
+ ['pickWoutNumber', '领料待出库'],
113
+ ['pickRefundNumber', '已退料'],
114
+ ['pickRefundInNumber', '退料已入库'],
115
+ ['pickRefundWinNumber', '退料待入库'],
116
+ ['practicalNumber', '实用量'],
117
+ ['cuttingLoss', '裁床等损耗'],
118
+ ['lackPickNumber', '本单缺料'],
119
+ ['surplusPickNumber', '本单余料'],
120
+ ['recoveryNumber', '回料数'],
121
+ ['recoveryWoutNumber', '回料待出库数'],
122
+ ['recoveryWinNumber', '回料待入库数'],
123
+ ['remark', '备注'],
124
+ ['occupationNumber', '本单临时占用'],
125
+ ['otherOccupationNumber', '其他单占用'],
126
+ ['planPurchaseNumber', '建议采购'],
127
+ ['materialRemark', '物料备注'],
128
+ ['materialCategoryName', '物料分类'],
129
+ ];
130
+
131
+ // ---------- 数值与文本辅助 ----------
132
+
133
+ /** 空值转空字符串,其余转去空白字符串 */
134
+ function text(value) {
135
+ return value === null || value === undefined ? '' : String(value).trim();
136
+ }
137
+
138
+ /** 取第一个非空值(null、undefined、空字符串视为空) */
139
+ function first(...values) {
140
+ const found = values.find((v) => v !== null && v !== undefined && v !== '');
141
+ return found === undefined ? '' : found;
142
+ }
143
+
144
+ /** 数值容错:空值与无法解析的值返回 0 */
145
+ function dec(value) {
146
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
147
+ return 0;
148
+ }
149
+ const number = Number(value);
150
+ return Number.isFinite(number) ? number : 0;
151
+ }
152
+
153
+ /** 保留指定小数位(四舍五入) */
154
+ function roundPlaces(value, places = 4) {
155
+ return Math.round(value * 10 ** places) / 10 ** places;
156
+ }
157
+
158
+ function round4(value) {
159
+ return roundPlaces(value, 4);
160
+ }
161
+
162
+ /** 单位换算行:列表第一行,无则空对象 */
163
+ function unitConvert(row) {
164
+ const list = row.materialSkuUnitConvertList;
165
+ return Array.isArray(list) && list.length && list[0] && typeof list[0] === 'object' ? list[0] : {};
166
+ }
167
+
168
+ /** 地址文本:agent 详情只提供详细地址(省市区 id 无名称映射数据) */
169
+ function addressText(row) {
170
+ return text(row.supplierAddress);
171
+ }
172
+
173
+ /** 行级展示图地址:物料单品首图优先,无则用物料主图 */
174
+ function rowImageUrl(row) {
175
+ return text(first(row.materialSkuImgUrl, row.materialImgUrl));
176
+ }
177
+
178
+ /** 枚举字段转文字 */
179
+ const STATE_TEXT = {
180
+ useState: { 0: '进行中', 1: '已结束' },
181
+ isComplete: { 0: '未齐', 1: '齐料' },
182
+ issueState: { 0: '空', 1: '发料中', 2: '无需发料', 3: '发料结束' },
183
+ fabricType: { 1: '针织', 2: '梭织' },
184
+ };
185
+
186
+ function stateText(field, raw) {
187
+ if (raw === null || raw === undefined || raw === '') {
188
+ return '';
189
+ }
190
+ const map = STATE_TEXT[field];
191
+ if (map) {
192
+ return map[raw] ?? map[String(raw)] ?? text(raw);
193
+ }
194
+ return text(raw);
195
+ }
196
+
197
+ /**
198
+ * 按页面默认口径动态计算的列(纯 HTTP 无法取得用户偏好,固定采用
199
+ * 详情页默认:裁床数口径 + 按单占用库存)。返回 null 表示该列不走动态逻辑。
200
+ */
201
+ function displayDynamic(row, field) {
202
+ const isFabric = text(row.materialTypeName) === '面料';
203
+ const useCutBed = true;
204
+ if (field === 'predictPurchaseNumber') {
205
+ const rate = dec(row.convertRate);
206
+ return rate ? roundPlaces(dec(row.number) / rate) : '-';
207
+ }
208
+ if (field === 'occupationNumber' || field === 'otherOccupationNumber'
209
+ || field === 'planPurchaseNumber' || field === 'inventoryEstimate') {
210
+ if (isFabric && useCutBed) {
211
+ return '-';
212
+ }
213
+ if (field === 'occupationNumber') {
214
+ return roundPlaces(Math.max(dec(row.occupationByCutBedNumber), 0));
215
+ }
216
+ if (field === 'otherOccupationNumber') {
217
+ return roundPlaces(Math.max(dec(row.otherOccupationCutBedNumber), 0));
218
+ }
219
+ const plan = Math.max(dec(row.planPurchaseCutBedNumber), 0);
220
+ if (field === 'inventoryEstimate') {
221
+ return plan > 0 ? '缺' : '足';
222
+ }
223
+ return roundPlaces(plan);
224
+ }
225
+ return null;
226
+ }
227
+
228
+ /** 单个列的显示值 */
229
+ function columnValue(row, field) {
230
+ const dynamic = displayDynamic(row, field);
231
+ if (dynamic !== null) {
232
+ return dynamic;
233
+ }
234
+ switch (field) {
235
+ case 'useState':
236
+ case 'isComplete':
237
+ case 'issueState':
238
+ case 'fabricType':
239
+ return stateText(field, row[field]);
240
+ case 'materialColor':
241
+ return first(row.materialColor, row.materialColorName, row.colorName);
242
+ case 'matSpec':
243
+ return first(row.matSpec, row.materialSkuPropertyText, row.spec);
244
+ case 'supplierCellPhone':
245
+ return first(row.supplierCellPhone, row.supplierCellphone);
246
+ case 'materialPart':
247
+ return first(row.materialPart, row.materialParts);
248
+ case 'materialPostProcess':
249
+ return first(row.materialPostProcess, row.materialPostProcesses);
250
+ case 'purchaseUnitName':
251
+ return first(row.purchaseUnitName, unitConvert(row).purchaseUnitName);
252
+ case 'convertRate':
253
+ return first(row.convertRate, unitConvert(row).convertRate);
254
+ case 'purchasePrice':
255
+ return first(row.purchasePrice, unitConvert(row).purchasePrice);
256
+ case 'supplierAddressText':
257
+ return addressText(row);
258
+ case 'materialName':
259
+ return first(row.materialName, row.name);
260
+ case 'materialSn':
261
+ return first(row.materialSn, row.sn);
262
+ case 'materialImgUrl':
263
+ return '';
264
+ case 'purchaseUserItems':
265
+ return text(row.purchaseUserNames);
266
+ case 'materialType':
267
+ return first(row.materialTypeName, row.materialType);
268
+ case 'materialUnit':
269
+ return first(row.materialUnitName, row.materialUnit, row.unitName);
270
+ case 'shrinkRates':
271
+ return first(row.shrinkRates, row.shrinkRate);
272
+ case 'materialLossRates':
273
+ return first(row.materialLossRates, row.materialLossRate);
274
+ default: {
275
+ const value = row[field];
276
+ return value === undefined || value === null ? '' : value;
277
+ }
278
+ }
279
+ }
280
+
281
+ // ---------- 数据组装 ----------
282
+
283
+ /**
284
+ * 把物料占用数据按 materialSkuId 合并进生产单物料行。
285
+ * 复制原脚本的使用状态与 8 个缺料/占用字段;可用库存、采购待入库数、
286
+ * 已领料数、实际用量这四列详情物料行本身已提供(与原脚本同源),因此
287
+ * 仅在详情值为空时用占用接口的值回退(对应 DTO 的 pickedNumber 与
288
+ * actualUseNumber)。
289
+ */
290
+ function mergeOccupation(detail, occupations) {
291
+ const bySku = new Map();
292
+ for (const item of occupations || []) {
293
+ if (item && typeof item === 'object') {
294
+ bySku.set(String(item.materialSkuId), item);
295
+ }
296
+ }
297
+ for (const item of detail.materialList || []) {
298
+ const extra = bySku.get(String(item.materialSkuId));
299
+ if (!extra) {
300
+ continue;
301
+ }
302
+ item.useState = extra.state;
303
+ const copyFields = [
304
+ 'lackNumberByOrderNumber', 'lackNumberByCutBedNumber',
305
+ 'occupationByOrderNumber', 'occupationByCutBedNumber',
306
+ 'otherOccupationOrderNumber', 'otherOccupationCutBedNumber',
307
+ 'planPurchaseOrderNumber', 'planPurchaseCutBedNumber',
308
+ ];
309
+ for (const field of copyFields) {
310
+ if (extra[field] !== null && extra[field] !== undefined) {
311
+ item[field] = extra[field];
312
+ }
313
+ }
314
+ const isEmpty = (v) => v === null || v === undefined || v === '';
315
+ if (isEmpty(item.materialAvailableNumber) && extra.materialAvailableNumber != null) {
316
+ item.materialAvailableNumber = extra.materialAvailableNumber;
317
+ }
318
+ if (isEmpty(item.purchaseWinNumber) && extra.purchaseWinNumber != null) {
319
+ item.purchaseWinNumber = extra.purchaseWinNumber;
320
+ }
321
+ if (isEmpty(item.pickNumber) && extra.pickedNumber != null) {
322
+ item.pickNumber = extra.pickedNumber;
323
+ }
324
+ if (isEmpty(item.practicalNumber) && extra.actualUseNumber != null) {
325
+ item.practicalNumber = extra.actualUseNumber;
326
+ }
327
+ }
328
+ }
329
+
330
+ /**
331
+ * 由生产单详情组装报表行:
332
+ * 1. BOM 映射行合并物料行同名字段(物料行空值不覆盖);
333
+ * 2. 按 物料id+物料单品id 分组,逐行计算预计用量后累加;
334
+ * 3. 齐料状态为空时推断:面料按采购数、辅料按发料已入库数。
335
+ */
336
+ function buildRows(detail) {
337
+ const materialList = detail.materialList || [];
338
+ const mappings = detail.bomList || [];
339
+
340
+ const materialIndex = new Map();
341
+ for (const item of materialList) {
342
+ if (item && typeof item === 'object') {
343
+ materialIndex.set(`${item.materialId}\u0001${item.materialSkuId}`, item);
344
+ }
345
+ }
346
+
347
+ // 颜色尺码明细的单品下单数:goodsSkuId → number
348
+ const quantities = new Map();
349
+ for (const goods of detail.orderGoods || []) {
350
+ const key = goods.goodsSkuId;
351
+ if (key !== null && key !== undefined && key !== '') {
352
+ quantities.set(String(key), dec(goods.number));
353
+ }
354
+ }
355
+
356
+ const intermediate = [];
357
+ for (const raw of mappings) {
358
+ if (!raw || typeof raw !== 'object') {
359
+ continue;
360
+ }
361
+ const source = materialIndex.get(`${raw.materialId}\u0001${raw.materialSkuId}`) || {};
362
+ const row = { ...raw };
363
+ for (const [key, value] of Object.entries(source)) {
364
+ if (value !== null && value !== undefined && value !== '') {
365
+ row[key] = value;
366
+ }
367
+ }
368
+ row.materialId = raw.materialId;
369
+ row.materialSkuId = raw.materialSkuId;
370
+ row._number = dec(raw.number);
371
+ row.goodsSkuId = raw.goodsSkuId !== null && raw.goodsSkuId !== undefined && raw.goodsSkuId !== ''
372
+ ? String(raw.goodsSkuId)
373
+ : '';
374
+ row.proNumber = quantities.has(row.goodsSkuId) ? quantities.get(row.goodsSkuId) : 0;
375
+ intermediate.push(row);
376
+ }
377
+ // 旧单没有 BOM 映射行时,直接用物料行输出,保持详情可见数据
378
+ if (!intermediate.length) {
379
+ for (const item of materialList) {
380
+ if (!item || typeof item !== 'object') {
381
+ continue;
382
+ }
383
+ intermediate.push({ ...item, _number: dec(item.number), proNumber: 0, goodsSkuId: '' });
384
+ }
385
+ }
386
+
387
+ const groups = new Map();
388
+ for (const row of intermediate) {
389
+ const key = `${row.materialId}\u0001${row.materialSkuId}`;
390
+ let group = groups.get(key);
391
+ if (!group) {
392
+ group = {
393
+ row: { ...row },
394
+ number: 0,
395
+ numberSum: 0,
396
+ including: 0,
397
+ sku: new Set(),
398
+ produce: 0,
399
+ produceSku: new Set(),
400
+ };
401
+ groups.set(key, group);
402
+ }
403
+ const pn = row.proNumber;
404
+ const unit = row._number;
405
+ const loss = dec(row.materialLossRate);
406
+ const shrink = dec(row.shrinkRate);
407
+ const divisor = 1 - shrink / 100;
408
+ const expected = divisor === 0 ? 0 : round4((pn * unit * (1 + loss / 100)) / divisor);
409
+ const including = divisor === 0 ? 0 : (unit * (1 + loss / 100)) / divisor;
410
+ group.number += expected;
411
+ group.numberSum += unit;
412
+ group.including += including;
413
+ if (unit && row.goodsSkuId) {
414
+ group.sku.add(row.goodsSkuId);
415
+ }
416
+ if (unit && pn && !group.produceSku.has(row.goodsSkuId)) {
417
+ group.produceSku.add(row.goodsSkuId);
418
+ group.produce += pn;
419
+ }
420
+ }
421
+
422
+ const result = [];
423
+ for (const group of groups.values()) {
424
+ const row = group.row;
425
+ row.number = round4(group.number);
426
+ row.cost = round4(group.number * dec(row.price));
427
+ const convert = unitConvert(row);
428
+ for (const field of ['convertRate', 'purchasePrice', 'purchaseUnitName']) {
429
+ if ((row[field] === null || row[field] === undefined || row[field] === '')
430
+ && convert[field] !== null && convert[field] !== undefined) {
431
+ row[field] = convert[field];
432
+ }
433
+ }
434
+ row.supplierAddressText = addressText(row);
435
+ const count = group.sku.size;
436
+ row.avgGoodsNumber = round4(count ? group.numberSum / count : 0);
437
+ row.avgGoodsIncludingLossNumber = round4(count ? group.including / count : 0);
438
+ row.avgProduceNumber = round4(group.produce ? group.number / group.produce : 0);
439
+ if (row.isComplete === null || row.isComplete === undefined || row.isComplete === '') {
440
+ row.isComplete = text(row.materialTypeName) === '面料'
441
+ ? (dec(row.purchaseNumber) >= group.number ? 1 : 0)
442
+ : (dec(row.issueInNumber) >= dec(row.predictNumber) ? 1 : 0);
443
+ }
444
+ row.lackPickNumber = first(row.lackNumberByOrderNumber, row.lackPickNumber);
445
+ row.occupationNumber = first(row.occupationByOrderNumber, row.occupationNumber);
446
+ row.otherOccupationNumber = first(row.otherOccupationOrderNumber, row.otherOccupationNumber);
447
+ row.planPurchaseNumber = first(row.planPurchaseOrderNumber, row.planPurchaseNumber);
448
+ result.push(row);
449
+ }
450
+ return result;
451
+ }
452
+
453
+ /** 过滤显示行:剔除按采购员隐藏的行;默认再剔除 0 用量行 */
454
+ function displayRows(detail, includeZero) {
455
+ return buildRows(detail).filter((row) => !row.isHideByPurchaseUser && (includeZero || dec(row.number) !== 0));
456
+ }
457
+
458
+ // ---------- Excel 生成 ----------
459
+
460
+ /** 表头/标签样式:加粗、浅蓝底、细边框、居中换行 */
461
+ function styleHeaderCells(row, height) {
462
+ row.height = height;
463
+ row.eachCell({ includeEmpty: true }, (cell) => {
464
+ cell.font = { bold: true };
465
+ cell.fill = HEADER_FILL;
466
+ cell.border = BORDER;
467
+ cell.alignment = CENTER;
468
+ });
469
+ }
470
+
471
+ /** 生产单信息键值对样式:奇数位是标签(表头样式),偶数位是值(细边框左对齐) */
472
+ function styleInfoRow(row, count) {
473
+ row.height = 24;
474
+ for (let col = 1; col <= count; col += 1) {
475
+ const cell = row.getCell(col);
476
+ cell.border = BORDER;
477
+ if (col % 2 === 1) {
478
+ cell.font = { bold: true };
479
+ cell.fill = HEADER_FILL;
480
+ cell.alignment = CENTER;
481
+ } else {
482
+ cell.alignment = LEFT;
483
+ }
484
+ }
485
+ }
486
+
487
+ /**
488
+ * 写一个生产单的工作表:标题行、信息行、表头行(第 4 行)与数据行。
489
+ * images 是 地址 → 图片对象 的 Map,数据行首列按行嵌入对应图片。
490
+ */
491
+ function addOrderSheet(workbook, order, rows, images) {
492
+ const count = COLUMNS.length;
493
+ const sheet = workbook.addWorksheet(SHEET_NAME, { views: [{ state: 'frozen', ySplit: 4 }] });
494
+
495
+ // 第 1 行:整行合并的表标题
496
+ const titleRow = sheet.getRow(1);
497
+ titleRow.getCell(1).value = '生产单物料数据';
498
+ titleRow.height = 30;
499
+ titleRow.eachCell({ includeEmpty: true }, (cell) => {
500
+ cell.font = { bold: true, size: 11 };
501
+ cell.fill = HEADER_FILL;
502
+ cell.border = BORDER;
503
+ cell.alignment = LEFT;
504
+ });
505
+ sheet.mergeCells(1, 1, 1, count);
506
+
507
+ // 第 2 行:生产批次号、商品名、商品货号、生产单状态、导出时间
508
+ const infoPairs = [
509
+ ['生产批次号', first(order.produceBatchSn, order.ordersSn)],
510
+ ['商品名', order.goodsName],
511
+ ['商品货号', order.goodsSn],
512
+ ['生产单状态', order.stateName],
513
+ ['导出时间', formatNow()],
514
+ ];
515
+ const infoRow = sheet.getRow(2);
516
+ infoPairs.forEach(([label, value], i) => {
517
+ infoRow.getCell(i * 2 + 1).value = label;
518
+ infoRow.getCell(i * 2 + 2).value = value;
519
+ });
520
+ styleInfoRow(infoRow, count);
521
+
522
+ // 第 4 行:列表头
523
+ const headerRow = sheet.getRow(4);
524
+ COLUMNS.forEach(([, header], i) => {
525
+ headerRow.getCell(i + 1).value = header;
526
+ });
527
+ styleHeaderCells(headerRow, 32);
528
+
529
+ // 第 5 行起:物料数据行;首列有图片时嵌入图片(单元格本身留空)
530
+ rows.forEach((row, index) => {
531
+ const dataRow = sheet.getRow(5 + index);
532
+ COLUMNS.forEach(([field], i) => {
533
+ dataRow.getCell(i + 1).value = columnValue(row, field);
534
+ });
535
+ dataRow.height = 30;
536
+ dataRow.eachCell({ includeEmpty: true }, (cell) => {
537
+ cell.border = BORDER;
538
+ cell.alignment = LEFT;
539
+ });
540
+ const image = images ? images.get(rowImageUrl(row)) : null;
541
+ if (image) {
542
+ embedImage(workbook, sheet, image, 5 + index, 1, IMAGE_SIZE);
543
+ }
544
+ });
545
+
546
+ // 列宽:前三列 18,其余 14
547
+ for (let i = 1; i <= count; i += 1) {
548
+ sheet.getColumn(i).width = i < 4 ? 18 : 14;
549
+ }
550
+ return sheet;
551
+ }
552
+
553
+ /** 当前时间 yyyy-MM-dd HH:mm:ss */
554
+ function formatNow() {
555
+ const pad = (v) => String(v).padStart(2, '0');
556
+ const now = new Date();
557
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} `
558
+ + `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
559
+ }
560
+
561
+ /** 文件名组成部分安全处理:全角归一、替换非法字符、截断 100 字符 */
562
+ function safeComponent(value, fallback) {
563
+ let v = String(value ?? '').normalize('NFKC').trim();
564
+ v = v.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/[.\s]+$/, '');
565
+ if (!v) {
566
+ v = fallback;
567
+ }
568
+ if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(v)) {
569
+ v = `_${v}`;
570
+ }
571
+ return v.slice(0, 100).replace(/[.\s]+$/, '') || fallback;
572
+ }
573
+
574
+ /** 单个生产单的文件名:批次号(商品名).xlsx,重名追加序号 */
575
+ function planFile(directory, order, used) {
576
+ const base = `${safeComponent(first(order.produceBatchSn, order.ordersSn), '无批次号')}`
577
+ + `(${safeComponent(order.goodsName, '无商品名')})`;
578
+ let name = `${base}.xlsx`;
579
+ let suffix = 2;
580
+ while (used.has(name.toLowerCase())) {
581
+ name = `${base}_${suffix}.xlsx`;
582
+ suffix += 1;
583
+ }
584
+ used.add(name.toLowerCase());
585
+ return path.join(directory, name);
586
+ }
587
+
588
+ // ---------- 报表入口 ----------
589
+
590
+ /**
591
+ * 生成漏料检测日报。
592
+ * argv:{ limit 最多处理的生产单数(默认 100),
593
+ * includeZero 是否包含 0 用量行(默认隐藏),
594
+ * days 取单的业务日期窗口天数(默认 30), out 输出目录 }。
595
+ * 输出目录名固定为 漏料检测日报+时间戳,已存在时报错;单个文件不覆盖。
596
+ */
597
+ async function generate(argv = {}) {
598
+ const limit = Number(argv.limit) > 0 ? Number(argv.limit) : PAGE_LIMIT;
599
+ const includeZero = argv.includeZero === true || argv.includeZero === 'true';
600
+ const days = Number(argv.days) > 0 ? Number(argv.days) : DEFAULT_DAYS;
601
+ const outDir = argv.out ? path.resolve(String(argv.out)) : process.cwd();
602
+ const throttle = createThrottle(REQUEST_INTERVAL_MS);
603
+
604
+ // 后端对业务日期结束时间用严格小于比较,因此结束日期传明天才能包含今天
605
+ const from = shiftDate(-(days - 1));
606
+ const to = shiftDate(1);
607
+ const page = await throttle(() => call('GET', '/api/agent/v1/produce/list', {
608
+ from,
609
+ to,
610
+ page: 1,
611
+ limit: PAGE_LIMIT,
612
+ }));
613
+ const orders = (page && page.list ? page.list : []).slice(0, limit);
614
+ if (!orders.length) {
615
+ throw new Error('没有符合条件的生产单');
616
+ }
617
+
618
+ // 逐单取详情:物料行与 BOM 映射行只在详情接口返回
619
+ const details = [];
620
+ let failedCount = 0;
621
+ for (let i = 0; i < orders.length; i += 1) {
622
+ const sn = text(orders[i].ordersSn);
623
+ if (!sn) {
624
+ failedCount += 1;
625
+ continue;
626
+ }
627
+ try {
628
+ details.push(await throttle(() => call('GET', '/api/agent/v1/produce/detail', { sn })));
629
+ } catch (e) {
630
+ failedCount += 1;
631
+ }
632
+ if ((i + 1) % 10 === 0) {
633
+ process.stdout.write(`漏料检测日报进度:已取生产单详情 ${i + 1}/${orders.length}\n`);
634
+ }
635
+ }
636
+ if (!details.length) {
637
+ throw new Error('没有成功取得生产单详情');
638
+ }
639
+
640
+ // 物料占用数据按 20 单一批查询,按生产单号分组
641
+ const occupationsBySn = new Map();
642
+ for (let i = 0; i < details.length; i += OCCUPATION_BATCH) {
643
+ const batch = details
644
+ .slice(i, i + OCCUPATION_BATCH)
645
+ .map((detail) => text(detail.ordersSn))
646
+ .filter(Boolean);
647
+ if (!batch.length) {
648
+ continue;
649
+ }
650
+ const rows = (await throttle(() => call('GET', '/api/agent/v1/produce/material-data', {
651
+ sns: batch.join(','),
652
+ }))) || [];
653
+ for (const row of rows) {
654
+ const sn = text(row.produceOrdersSn);
655
+ if (!sn) {
656
+ continue;
657
+ }
658
+ if (!occupationsBySn.has(sn)) {
659
+ occupationsBySn.set(sn, []);
660
+ }
661
+ occupationsBySn.get(sn).push(row);
662
+ }
663
+ }
664
+
665
+ const directory = path.resolve(outDir, `漏料检测日报${timestamp()}`);
666
+ if (fs.existsSync(directory)) {
667
+ throw new Error(`输出目录已存在:${directory}`);
668
+ }
669
+ fs.mkdirSync(directory, { recursive: true });
670
+
671
+ const used = new Set();
672
+ const filePaths = [];
673
+ for (const detail of details) {
674
+ mergeOccupation(detail, occupationsBySn.get(text(detail.ordersSn)));
675
+ const rows = displayRows(detail, includeZero);
676
+ // 批量下载本单行的展示图(地址去重,失败自动跳过)
677
+ const urls = rows.map(rowImageUrl).filter(Boolean);
678
+ const images = urls.length ? await downloadImages(urls) : new Map();
679
+ const workbook = createWorkbook();
680
+ addOrderSheet(workbook, detail, rows, images);
681
+ const filePath = planFile(directory, detail, used);
682
+ await saveWorkbook(workbook, filePath);
683
+ filePaths.push(filePath);
684
+ }
685
+
686
+ let summary = `漏料检测日报导出完成:生产单 ${filePaths.length} 个,文件 ${filePaths.length} 个,`
687
+ + `${includeZero ? '包含' : '不包含'} 0 用量行;报表目录:${directory}`;
688
+ if (failedCount) {
689
+ summary += `;${failedCount} 张生产单详情获取失败已跳过`;
690
+ }
691
+ return { filePaths, summary };
692
+ }
693
+
694
+ module.exports = { generate };