@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,648 @@
1
+ // BOM 商品信息报表:每个商品生成一个 Excel 文件,文件内分三段:
2
+ // 1. 商品横向信息一行(44 列);2. 默认只读物料清单 BOM——30 个固定列加
3
+ // 按颜色/尺码动态展开的列组,每组三列「物料规格/用量/含损耗用量」;
4
+ // 3. 物料单品明细(23 列,按 materialSkuId 聚合后的单件用量口径)。
5
+ // 数据流:GET /api/agent/v1/goods/rich-list 分页取商品列表,
6
+ // 再逐商品 GET /api/agent/v1/goods/full-detail 取 BOM 行与档案字段,
7
+ // 商品主图与物料图下载后嵌入 A 列。列名、列序、合并规则、文件名与
8
+ // 原 Python 报表脚本 export_goods_bom.py 一致。
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const { call } = require('../cli/api');
12
+ const { timestamp, createThrottle, fetchAll } = require('./util');
13
+ const { downloadImage } = require('./image');
14
+ const { createWorkbook, saveWorkbook, embedImage } = require('./excel');
15
+
16
+ /** 列表分页大小(服务端单页上限 100) */
17
+ const PAGE_LIMIT = 100;
18
+ /** 逐商品详情请求的最小间隔毫秒数(服务端按 Key 每分钟限流约 30 次) */
19
+ const REQUEST_INTERVAL_MS = 2100;
20
+ /** 每张图片嵌入的显示尺寸(像素) */
21
+ const MAIN_IMAGE_SIZE = { width: 90, height: 90 };
22
+ const BOM_IMAGE_SIZE = { width: 38, height: 38 };
23
+ const DETAIL_IMAGE_SIZE = { width: 34, height: 34 };
24
+
25
+ const SHEET_NAME = '商品信息';
26
+ const PRODUCT_HEADERS = ['图片', '商品名称', '商品货号', '颜色', '尺码', '分类', '标签', 'BOM完成状态', 'BOM锁定状态', '委外加工费', '封样状态', '打版师', '当前库存', '待入库', '待出库', '可用库存', '生产中', 'IE核价', '成本价', '吊牌价', '建议零售价', '批发价', '零售价', '物料', '客户', '开发日期', '品牌', '设计款号', '供应商货号', '设计师', '跟单员', '年份', '季节', '克重(g/㎡)', '成分', '性别', '波段', '商品条码', '状态', '备注', '创建人', '创建时间', '最后更新人', '更新时间'];
27
+ const BOM_FIXED_HEADERS = ['图片', '物料名称', '物料货号', '供应商', '供应商货号', '地址', '联系电话', '类型', '面料属性', '基础单位', '基础单价', '转换系数', '采购单位', '采购单价', '采购员', '出米数(m/kg)', '纸筒(kg)', '空差(kg)', '净重(kg)', '幅宽(cm)', '克重(g/㎡)', '安数', '损耗率(%)', '缩水率(%)', '采购损耗(%)', '成分', '使用部位', '后处理', '物料备注', '物料分类'];
28
+ /** 物料单品明细列顺序(与商品编辑页 material-sku-bom.vue 默认列一致) */
29
+ const MATERIAL_SKU_HEADERS = ['图片', '物料名称', '物料货号', '供应商', '供应商货号', '地址', '联系电话', '类型', '物料分类', '物料颜色', '物料规格', '采购员', '色号', '物料色号', '成衣颜色', '基础单价', '单件用量', '含损耗单件用量', '基础单位', '转换系数', '采购单位', '采购单价', '物料备注'];
30
+ const BOM_FIXED_WIDTHS = [18, 20, 16, 18, 16, 18, 18, 14, 14, 12, 12, 12, 12, 12, 16, 12, 12, 12, 12, 12, 12, 10, 12, 12, 12, 16, 16, 16, 16, 16];
31
+ const PRODUCT_WIDTHS = [18, 20, 16, 18, 16, 14, 14, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 12, 18, 14, 14, 14, 14, 14, 14, 14, 12, 12, 12, 16, 10, 12, 16, 12, 16, 18, 16, 18];
32
+
33
+ /** 表头底色(浅蓝,与原脚本一致) */
34
+ const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9EAF7' } };
35
+ const THIN_BORDER = {
36
+ top: { style: 'thin', color: { argb: 'FFCCCCCC' } },
37
+ left: { style: 'thin', color: { argb: 'FFCCCCCC' } },
38
+ bottom: { style: 'thin', color: { argb: 'FFCCCCCC' } },
39
+ right: { style: 'thin', color: { argb: 'FFCCCCCC' } },
40
+ };
41
+
42
+ /** 空值转空字符串,其余转去空白字符串 */
43
+ function text(value) {
44
+ return value === null || value === undefined ? '' : String(value).trim();
45
+ }
46
+
47
+ /** 取第一个非空值(null、undefined、空字符串视为空),保留原始类型 */
48
+ function firstValue(...values) {
49
+ for (const value of values) {
50
+ if (value !== null && value !== undefined && value !== '') {
51
+ return value;
52
+ }
53
+ }
54
+ return '';
55
+ }
56
+
57
+ /** 数值转 number(写入 Excel 数值单元格),无法解析返回空字符串 */
58
+ function numeric(value) {
59
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
60
+ return '';
61
+ }
62
+ const n = Number(value);
63
+ return Number.isFinite(n) ? n : '';
64
+ }
65
+
66
+ function pad2(n) {
67
+ return String(n).padStart(2, '0');
68
+ }
69
+
70
+ /** 时间戳毫秒或日期字符串转「yyyy-MM-dd HH:mm:ss」,解析失败返回原始文字 */
71
+ function formatDateTime(value) {
72
+ if (value === null || value === undefined || value === '') {
73
+ return '';
74
+ }
75
+ let date = null;
76
+ if (typeof value === 'number') {
77
+ date = new Date(value);
78
+ } else {
79
+ const s = String(value).trim();
80
+ // 纯数字字符串按毫秒时间戳处理(Java Timestamp 的常见序列化形式)
81
+ if (/^\d+(\.\d+)?$/.test(s)) {
82
+ date = new Date(Number(s));
83
+ } else if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
84
+ date = new Date(s);
85
+ }
86
+ if (!date || Number.isNaN(date.getTime())) {
87
+ return s;
88
+ }
89
+ }
90
+ if (Number.isNaN(date.getTime())) {
91
+ return text(value);
92
+ }
93
+ return (
94
+ `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
95
+ `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
96
+ );
97
+ }
98
+
99
+ /** 日期部分 yyyy-MM-dd */
100
+ function formatDate(value) {
101
+ return formatDateTime(value).slice(0, 10);
102
+ }
103
+
104
+ /** 枚举 1/0 转展示文字 */
105
+ function stateText(value, yes, no) {
106
+ return value === 1 ? yes : no;
107
+ }
108
+
109
+ function sexText(value) {
110
+ return ({ 0: '通款', 1: '男款', 2: '女款', 3: '童装' })[value] ?? text(value);
111
+ }
112
+
113
+ function fabricText(value) {
114
+ return ({ 1: '针织', 2: '梭织' })[value] ?? '-';
115
+ }
116
+
117
+ function bomFinishText(value) {
118
+ return ({ 1: '完成', 0: '待完成', 2: '' })[value] ?? '';
119
+ }
120
+
121
+ /** 生成不与已用名称冲突、不含非法字符的文件名主体 */
122
+ function safeComponent(value, fallback) {
123
+ let v = String(value ?? '').normalize('NFKC').trim();
124
+ v = v.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/[.\s]+$/, '');
125
+ if (!v) {
126
+ v = fallback;
127
+ }
128
+ if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(v)) {
129
+ v = `_${v}`;
130
+ }
131
+ return v.slice(0, 100).replace(/[.\s]+$/, '') || fallback;
132
+ }
133
+
134
+ /**
135
+ * 含损耗用量:用量 × (1 + 损耗率%) ÷ (1 - 缩水率%),四舍五入保留 4 位小数。
136
+ * 缩水率等于 100 时分母为零,返回空字符串(与原脚本一致)。
137
+ */
138
+ function lossQuantity(number, loss, shrink) {
139
+ const amount = numeric(number);
140
+ if (amount === '') {
141
+ return '';
142
+ }
143
+ const l = numeric(loss) === '' ? 0 : Number(loss);
144
+ const s = numeric(shrink) === '' ? 0 : Number(shrink);
145
+ if (100 - s === 0) {
146
+ return '';
147
+ }
148
+ return Math.round((amount * (1 + l / 100) / (1 - s / 100)) * 10000) / 10000;
149
+ }
150
+
151
+ /** 把 BOM 行(full-detail 的 BomRow)整理成报表需要的聚合基础结构 */
152
+ function bomRecord(row) {
153
+ return {
154
+ key: text(row.materialSkuId),
155
+ materialSkuId: text(row.materialSkuId),
156
+ goodsSkuId: text(row.goodsSkuId),
157
+ hidden: row.hidden === true,
158
+ goodsColor: text(row.goodsColorName),
159
+ goodsSize: text(row.goodsSizeName),
160
+ colorId: row.goodsColorId,
161
+ sizeId: row.goodsSizeId,
162
+ number: numeric(row.number),
163
+ loss: numeric(row.materialLossRate),
164
+ shrink: numeric(row.shrinkRate),
165
+ property: text(row.propertyText),
166
+ raw: row,
167
+ };
168
+ }
169
+
170
+ /** 颜色/尺码轴:详情的颜色轴、尺码轴在前,BOM 行里出现而轴中没有的追加到末尾 */
171
+ function axisOf(detail, records) {
172
+ const colors = [];
173
+ const sizes = [];
174
+ for (const c of detail.goodsColors || []) {
175
+ if (text(c.colorName)) {
176
+ colors.push([text(c.colorName), c.colorId]);
177
+ }
178
+ }
179
+ for (const s of detail.goodsSizes || []) {
180
+ if (text(s.sizeName)) {
181
+ sizes.push([text(s.sizeName), s.sizeId]);
182
+ }
183
+ }
184
+ for (const r of records) {
185
+ if (!colors.some(([n, id]) => n === r.goodsColor && id === r.colorId)) {
186
+ colors.push([r.goodsColor, r.colorId]);
187
+ }
188
+ if (!sizes.some(([n, id]) => n === r.goodsSize && id === r.sizeId)) {
189
+ sizes.push([r.goodsSize, r.sizeId]);
190
+ }
191
+ }
192
+ return {
193
+ colors: colors.filter(([n]) => n),
194
+ sizes: sizes.filter(([n]) => n),
195
+ };
196
+ }
197
+
198
+ /**
199
+ * 物料单品分组:按 materialSkuId 聚合全部未隐藏 BOM 行。
200
+ * 单件用量 = 该物料全部 BOM 行用量合计 ÷ 覆盖的单品数(按 goodsSkuId 去重),
201
+ * 含损耗单件用量同理对含损耗用量合计后平均;合计为零时显示「-」。
202
+ */
203
+ function materialSkuGroups(records) {
204
+ const groups = [];
205
+ const index = new Map();
206
+ for (const record of records) {
207
+ if (record.hidden) {
208
+ continue;
209
+ }
210
+ let group = index.get(record.materialSkuId);
211
+ if (!group) {
212
+ group = { first: record, records: [], skuKeys: [], colors: [] };
213
+ index.set(record.materialSkuId, group);
214
+ groups.push(group);
215
+ }
216
+ group.records.push(record);
217
+ const key = record.goodsSkuId || `${record.goodsColor}-${record.goodsSize}`;
218
+ if (!group.skuKeys.includes(key)) {
219
+ group.skuKeys.push(key);
220
+ }
221
+ if (record.goodsColor && !group.colors.includes(record.goodsColor)) {
222
+ group.colors.push(record.goodsColor);
223
+ }
224
+ }
225
+ for (const group of groups) {
226
+ let total = 0;
227
+ let lossTotal = 0;
228
+ for (const r of group.records) {
229
+ total += Number(r.number) || 0;
230
+ const loss = lossQuantity(r.number, r.loss, r.shrink);
231
+ lossTotal += loss === '' ? 0 : loss;
232
+ }
233
+ const count = Math.max(1, group.skuKeys.length);
234
+ const divide = (v) => Math.round((v / count) * 10000) / 10000;
235
+ group.unitNumber = total ? divide(total) : '-';
236
+ group.unitLoss = lossTotal ? divide(lossTotal) : '-';
237
+ }
238
+ return groups;
239
+ }
240
+
241
+ /** BOM 固定 30 列的值(取分组第一行) */
242
+ function fixedBomValues(record) {
243
+ const row = record.raw;
244
+ return [
245
+ '',
246
+ text(row.materialName),
247
+ text(row.materialSn),
248
+ text(row.supplierName),
249
+ text(row.supplierSn),
250
+ text(row.supplierAddress),
251
+ text(row.supplierCellPhone),
252
+ text(row.materialTypeName),
253
+ fabricText(row.materialFabricType),
254
+ text(row.materialUnitName),
255
+ numeric(row.price),
256
+ numeric(row.convertRate),
257
+ text(row.purchaseUnitName),
258
+ numeric(row.purchasePrice),
259
+ text(row.purchaseUserNames),
260
+ numeric(row.materialMeterOutput),
261
+ numeric(row.materialPaperBucket),
262
+ numeric(row.materialEmptyGap),
263
+ numeric(row.materialNetWeight),
264
+ numeric(row.materialFabricWidth),
265
+ numeric(row.materialGramWeight),
266
+ numeric(row.materialQunce),
267
+ numeric(row.materialLossRate),
268
+ numeric(row.shrinkRate),
269
+ numeric(row.purchaseLossRate),
270
+ text(row.materialComposition),
271
+ text(row.materialPart),
272
+ text(row.materialPostProcess),
273
+ text(row.materialRemark),
274
+ text(row.materialCategoryName),
275
+ ];
276
+ }
277
+
278
+ /** 物料单品明细 23 列的值。物料色号一列后端 DTO 暂无对应字段,输出为空 */
279
+ function materialSkuValues(group) {
280
+ const row = group.first.raw;
281
+ const colors = group.colors.join('、') || '-';
282
+ return [
283
+ '',
284
+ text(row.materialName),
285
+ text(row.materialSn),
286
+ text(row.supplierName),
287
+ text(row.supplierSn),
288
+ text(row.supplierAddress),
289
+ text(row.supplierCellPhone),
290
+ text(row.materialTypeName),
291
+ text(row.materialCategoryName),
292
+ text(row.materialColorName),
293
+ group.first.property || '-',
294
+ text(row.purchaseUserNames),
295
+ text(row.bomSupplierColor),
296
+ '',
297
+ colors,
298
+ numeric(row.price),
299
+ group.unitNumber,
300
+ group.unitLoss,
301
+ text(row.materialUnitName),
302
+ numeric(row.convertRate),
303
+ text(row.purchaseUnitName),
304
+ numeric(row.purchasePrice),
305
+ text(row.materialRemark),
306
+ ];
307
+ }
308
+
309
+ /** 商品横向信息 44 列(详情字段优先,列表行字段回退) */
310
+ function productValues(summary, detail) {
311
+ const colors = (detail.goodsColors || []).map((c) => text(c.colorName)).filter(Boolean);
312
+ const sizes = (detail.goodsSizes || []).map((s) => text(s.sizeName)).filter(Boolean);
313
+ const materialText = (summary.materialNames || []).join('、');
314
+ return [
315
+ '',
316
+ text(firstValue(detail.name, summary.name)),
317
+ text(firstValue(detail.goodsSn, summary.goodsSn)),
318
+ colors.join(',') || (summary.colorNames || []).join(','),
319
+ sizes.join(',') || (summary.sizeNames || []).join(','),
320
+ text(firstValue(detail.categoryName, summary.categoryName)),
321
+ (summary.tagNames || []).join('、'),
322
+ bomFinishText(firstValue(detail.bomFinish, summary.bomFinish)),
323
+ stateText(firstValue(detail.bomState, summary.bomState), '已锁定', '未锁定'),
324
+ (summary.externalProcessingFees || []).join(';'),
325
+ stateText(firstValue(detail.isSealedSample, summary.isSealedSample), '封样', '未封样'),
326
+ text(firstValue(detail.patternMakerName, summary.patternMakerName)),
327
+ numeric(firstValue(detail.storedNumber, summary.storedNumber)),
328
+ numeric(firstValue(detail.winNumber, summary.winNumber)),
329
+ numeric(firstValue(detail.woutNumber, summary.woutNumber)),
330
+ numeric(firstValue(detail.availableNumber, summary.availableNumber)),
331
+ numeric(firstValue(detail.produceNumber, summary.produceNumber)),
332
+ numeric(firstValue(detail.iePrice, summary.iePrice)),
333
+ numeric(firstValue(detail.costPrice, summary.costPrice)),
334
+ numeric(firstValue(detail.tagPrice, summary.tagPrice)),
335
+ numeric(firstValue(detail.retailPriceFactory, summary.retailPriceFactory)),
336
+ numeric(firstValue(detail.tradePrice, summary.tradePrice)),
337
+ numeric(firstValue(detail.retailPriceArea, summary.retailPriceArea)),
338
+ materialText,
339
+ text(firstValue(detail.clientName, summary.clientName)),
340
+ formatDate(firstValue(detail.devTime, summary.devTime)),
341
+ text(firstValue(detail.brandName, summary.brandName)),
342
+ text(firstValue(detail.designSn, summary.designSn)),
343
+ text(firstValue(detail.supplierSn, summary.supplierSn)),
344
+ text(firstValue(detail.designerName, summary.designerName)),
345
+ text(firstValue(detail.followerName, summary.followerName)),
346
+ text(firstValue(detail.year, summary.year)),
347
+ text(firstValue(detail.seasonName, summary.seasonName)),
348
+ numeric(firstValue(detail.gramWeight, summary.gramWeight)),
349
+ text(firstValue(detail.composition, summary.composition)),
350
+ sexText(firstValue(detail.sex, summary.sex)),
351
+ text(firstValue(detail.rangesName, summary.rangesName)),
352
+ text(firstValue(detail.barCode, summary.barCode)),
353
+ stateText(firstValue(detail.isOnSale, summary.isOnSale), '启用', '停用'),
354
+ text(firstValue(detail.remark, summary.remark)),
355
+ text(firstValue(detail.handlerName, summary.handlerName)),
356
+ formatDateTime(firstValue(detail.createTime, summary.createTime)),
357
+ text(firstValue(detail.lastHandlerName, summary.lastHandlerName)),
358
+ formatDateTime(firstValue(detail.updateTime, summary.updateTime)),
359
+ ];
360
+ }
361
+
362
+ /** 显式行号写一行值(values 数组第 0 位对应第 1 列) */
363
+ function setRow(sheet, rowNumber, values) {
364
+ const row = sheet.getRow(rowNumber);
365
+ row.values = values;
366
+ return row;
367
+ }
368
+
369
+ /** 表头样式:加粗、浅蓝底、细边框、居中换行 */
370
+ function styleHeaderRow(row, height) {
371
+ row.height = height;
372
+ row.eachCell({ includeEmpty: true }, (cell) => {
373
+ cell.border = THIN_BORDER;
374
+ cell.fill = HEADER_FILL;
375
+ cell.font = { bold: true };
376
+ cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
377
+ });
378
+ }
379
+
380
+ /** 数据行样式:细边框;leftCols 里的列左对齐,其余居中 */
381
+ function styleDataRow(row, height, leftCols) {
382
+ row.height = height;
383
+ const left = new Set(leftCols);
384
+ row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
385
+ cell.border = THIN_BORDER;
386
+ cell.alignment = { vertical: 'middle', horizontal: left.has(colNumber) ? 'left' : 'center', wrapText: true };
387
+ });
388
+ }
389
+
390
+ /** 创建输出目录;同名目录已存在时追加序号,避免覆盖既有报表 */
391
+ function makeDirectory(parent, baseName) {
392
+ let dir = path.join(parent, baseName);
393
+ let seq = 2;
394
+ while (fs.existsSync(dir)) {
395
+ dir = path.join(parent, `${baseName}-${seq}`);
396
+ seq += 1;
397
+ }
398
+ fs.mkdirSync(dir, { recursive: true });
399
+ return dir;
400
+ }
401
+
402
+ /**
403
+ * 单个商品文件名:`商品名称(货号).xlsx`;同一批内重名先追加商品 id,
404
+ * 仍冲突再追加自增序号(与原脚本 file_plan 一致)。
405
+ */
406
+ function planFile(directory, goods, used) {
407
+ const base = `${safeComponent(goods.name, '商品')}(${safeComponent(goods.goodsSn, '无货号')})`;
408
+ let name = `${base}.xlsx`;
409
+ if (used.has(name.toLowerCase())) {
410
+ name = `${base}_${goods.id}.xlsx`;
411
+ }
412
+ let seq = 2;
413
+ while (used.has(name.toLowerCase())) {
414
+ name = `${base}_${goods.id}_${seq}.xlsx`;
415
+ seq += 1;
416
+ }
417
+ used.add(name.toLowerCase());
418
+ return path.join(directory, name);
419
+ }
420
+
421
+ /** 下载商品主图与各物料图(按 materialSkuId 去重),失败自动跳过 */
422
+ async function downloadGoodsImages(detail, summary, records) {
423
+ const firstImg = (detail.goodsImgList || []).map((i) => text(i.url)).find(Boolean);
424
+ const mainImage = await downloadImage(text(firstValue(detail.goodsImgUrl, summary.goodsImgUrl)) || firstImg);
425
+ const materialImages = new Map();
426
+ for (const record of records) {
427
+ if (record.materialSkuId && !materialImages.has(record.materialSkuId)) {
428
+ materialImages.set(record.materialSkuId, await downloadImage(text(record.raw.materialImgUrl)));
429
+ }
430
+ }
431
+ return { mainImage, materialImages };
432
+ }
433
+
434
+ /**
435
+ * 生成单个商品的 Excel:商品信息行 + 三层表头的 BOM 区 + 物料单品明细区。
436
+ * BOM 行按 materialSkuId 分组,每组一行;颜色×尺码单元格的用量是该组内
437
+ * 相同(颜色,尺码) 行的用量合计,含损耗用量按合计后的值重新计算。
438
+ */
439
+ async function writeGoodsFile(filePath, summary, detail) {
440
+ const records = (detail.goodsMaterialList || []).map(bomRecord);
441
+ const { colors, sizes } = axisOf(detail, records);
442
+ const dynamicCount = colors.length * sizes.length;
443
+ const fixedCount = BOM_FIXED_HEADERS.length;
444
+ const bomTotal = fixedCount + dynamicCount * 3;
445
+ const sheetTotal = Math.max(PRODUCT_HEADERS.length, bomTotal);
446
+ const pad = (values) => [...values, ...new Array(Math.max(0, sheetTotal - values.length)).fill('')];
447
+
448
+ const { mainImage, materialImages } = await downloadGoodsImages(detail, summary, records);
449
+
450
+ const workbook = createWorkbook();
451
+ const sheet = workbook.addWorksheet(SHEET_NAME, { views: [{ state: 'frozen', ySplit: 7 }] });
452
+
453
+ // 第 1 行表头、第 2 行商品信息(第 3 行留空,与原脚本行结构一致)
454
+ styleHeaderRow(setRow(sheet, 1, pad(PRODUCT_HEADERS)), 30);
455
+ styleDataRow(setRow(sheet, 2, pad(productValues(summary, detail))), 75, [1, 2, 3, 4, 5, 7, 10, 24, 40, 41, 43]);
456
+ if (mainImage) {
457
+ embedImage(workbook, sheet, mainImage, 2, 1, MAIN_IMAGE_SIZE);
458
+ }
459
+
460
+ // 第 4 行「物料清单BOM」整行合并
461
+ const bomTitle = pad(['物料清单BOM']);
462
+ styleHeaderRow(setRow(sheet, 4, bomTitle), 24);
463
+ sheet.mergeCells(4, 1, 4, sheetTotal);
464
+
465
+ // 第 5~7 行三层表头:固定列纵向合并三行;颜色在第 5 行跨该颜色全部列,
466
+ // 尺码在第 6 行跨 3 列,第 7 行固定为「物料规格/用量/含损耗用量」。
467
+ const top = new Array(sheetTotal).fill('');
468
+ const middle = new Array(sheetTotal).fill('');
469
+ const bottom = new Array(sheetTotal).fill('');
470
+ BOM_FIXED_HEADERS.forEach((h, i) => {
471
+ top[i] = h;
472
+ });
473
+ let col = fixedCount;
474
+ for (const [colorName] of colors) {
475
+ top[col] = colorName;
476
+ col += sizes.length * 3;
477
+ }
478
+ col = fixedCount;
479
+ for (const [sizeName] of sizes) {
480
+ middle[col] = sizeName;
481
+ col += 3;
482
+ }
483
+ col = fixedCount;
484
+ for (let i = 0; i < dynamicCount; i += 1) {
485
+ bottom[col] = '物料规格';
486
+ bottom[col + 1] = '用量';
487
+ bottom[col + 2] = '含损耗用量';
488
+ col += 3;
489
+ }
490
+ styleHeaderRow(setRow(sheet, 5, top), 28);
491
+ styleHeaderRow(setRow(sheet, 6, middle), 28);
492
+ styleHeaderRow(setRow(sheet, 7, bottom), 28);
493
+ for (let c = 1; c <= fixedCount; c += 1) {
494
+ sheet.mergeCells(5, c, 7, c);
495
+ }
496
+ col = fixedCount + 1;
497
+ for (const [colorName] of colors) {
498
+ sheet.mergeCells(5, col, 5, col + sizes.length * 3 - 1);
499
+ for (const [sizeName] of sizes) {
500
+ sheet.mergeCells(6, col, 6, col + 2);
501
+ col += 3;
502
+ }
503
+ }
504
+
505
+ // BOM 数据行:按 materialSkuId 分组(插入顺序),一组一行
506
+ const grouped = [];
507
+ const groupIndex = new Map();
508
+ for (const record of records) {
509
+ let list = groupIndex.get(record.key);
510
+ if (!list) {
511
+ list = [];
512
+ groupIndex.set(record.key, list);
513
+ grouped.push(list);
514
+ }
515
+ list.push(record);
516
+ }
517
+ let rowNo = 8;
518
+ for (const list of grouped) {
519
+ const values = [...fixedBomValues(list[0])];
520
+ // 相同(颜色,尺码)的多行合并用量;损耗率/缩水率/规格取第一个非空值
521
+ const pivot = new Map();
522
+ for (const r of list) {
523
+ const k = `${r.goodsColor}\u0000${r.goodsSize}`;
524
+ const old = pivot.get(k) || { number: 0, loss: '', shrink: '', property: '' };
525
+ old.number += Number(r.number) || 0;
526
+ if (r.loss !== '') {
527
+ old.loss = r.loss;
528
+ }
529
+ if (r.shrink !== '') {
530
+ old.shrink = r.shrink;
531
+ }
532
+ if (!old.property) {
533
+ old.property = r.property;
534
+ }
535
+ pivot.set(k, old);
536
+ }
537
+ for (const [colorName] of colors) {
538
+ for (const [sizeName] of sizes) {
539
+ const p = pivot.get(`${colorName}\u0000${sizeName}`);
540
+ if (p) {
541
+ values.push(p.property, numeric(p.number), lossQuantity(p.number, p.loss, p.shrink));
542
+ } else {
543
+ values.push('-', '', '');
544
+ }
545
+ }
546
+ }
547
+ styleDataRow(setRow(sheet, rowNo, pad(values)), 32, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 26, 27, 28, 29, 30]);
548
+ const image = materialImages.get(list[0].materialSkuId);
549
+ if (image) {
550
+ embedImage(workbook, sheet, image, rowNo, 1, BOM_IMAGE_SIZE);
551
+ }
552
+ rowNo += 1;
553
+ }
554
+
555
+ // 物料单品明细区:标题整行合并 + 表头 + 聚合行
556
+ styleHeaderRow(setRow(sheet, rowNo, pad(['物料单品明细'])), 24);
557
+ sheet.mergeCells(rowNo, 1, rowNo, sheetTotal);
558
+ const detailHeaderRow = rowNo + 1;
559
+ styleHeaderRow(setRow(sheet, detailHeaderRow, pad(MATERIAL_SKU_HEADERS)), 28);
560
+ rowNo = detailHeaderRow + 1;
561
+ for (const group of materialSkuGroups(records)) {
562
+ styleDataRow(setRow(sheet, rowNo, pad(materialSkuValues(group))), 28, [1, 2, 3, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 23]);
563
+ const image = materialImages.get(group.first.materialSkuId);
564
+ if (image) {
565
+ embedImage(workbook, sheet, image, rowNo, 1, DETAIL_IMAGE_SIZE);
566
+ }
567
+ rowNo += 1;
568
+ }
569
+
570
+ // 列宽:BOM 固定列宽 + 每个动态列组 [14,12,14];超出部分沿用商品信息区列宽
571
+ const widths = [...BOM_FIXED_WIDTHS];
572
+ for (let i = 0; i < dynamicCount; i += 1) {
573
+ widths.push(14, 12, 14);
574
+ }
575
+ for (let i = widths.length; i < sheetTotal; i += 1) {
576
+ widths.push(PRODUCT_WIDTHS[i] || 14);
577
+ }
578
+ widths.forEach((w, i) => {
579
+ sheet.getColumn(i + 1).width = w;
580
+ });
581
+
582
+ await saveWorkbook(workbook, filePath);
583
+ return { bomRows: grouped.length };
584
+ }
585
+
586
+ /**
587
+ * 报表入口:分页取商品列表(可选按款号过滤),逐商品取详情并生成文件。
588
+ * argv:{ sn 款号(可选), limit 最多导出商品数(默认 100), out 输出目录 }。
589
+ */
590
+ async function generate(argv = {}) {
591
+ const limit = Number(argv.limit) > 0 ? Number(argv.limit) : 100;
592
+ const outDir = argv.out ? path.resolve(String(argv.out)) : process.cwd();
593
+ const sn = text(argv.sn);
594
+ const throttle = createThrottle(REQUEST_INTERVAL_MS);
595
+
596
+ const { list } = await fetchAll(
597
+ (page) => throttle(() => call('GET', '/api/agent/v1/goods/rich-list', {
598
+ sn: sn || undefined,
599
+ isOnSale: 1,
600
+ skuIsDisable: 0,
601
+ page,
602
+ limit: PAGE_LIMIT,
603
+ })),
604
+ { limit: PAGE_LIMIT, maxPages: Math.max(1, Math.ceil(limit / PAGE_LIMIT)) }
605
+ );
606
+ const selected = list.slice(0, limit);
607
+ if (!selected.length) {
608
+ throw new Error('没有符合条件的商品');
609
+ }
610
+
611
+ const directory = makeDirectory(outDir, `bom商品信息${timestamp()}`);
612
+ const used = new Set();
613
+ const filePaths = [];
614
+ let noBomCount = 0;
615
+ let skippedExisting = 0;
616
+ for (let i = 0; i < selected.length; i += 1) {
617
+ const summary = selected[i];
618
+ const detail = await throttle(() => call('GET', '/api/agent/v1/goods/full-detail', { id: summary.id }));
619
+ const goods = {
620
+ id: summary.id,
621
+ name: text(firstValue(detail.name, summary.name)),
622
+ goodsSn: text(firstValue(detail.goodsSn, summary.goodsSn)),
623
+ };
624
+ const filePath = planFile(directory, goods, used);
625
+ if (fs.existsSync(filePath)) {
626
+ skippedExisting += 1;
627
+ continue;
628
+ }
629
+ const meta = await writeGoodsFile(filePath, summary, detail);
630
+ filePaths.push(filePath);
631
+ if (!meta.bomRows) {
632
+ noBomCount += 1;
633
+ }
634
+ if ((i + 1) % 10 === 0) {
635
+ process.stdout.write(`已处理 ${i + 1}/${selected.length} 个商品\n`);
636
+ }
637
+ }
638
+
639
+ let summaryText = `BOM 商品信息导出完成:商品 ${filePaths.length} 个,无BOM商品 ${noBomCount} 个` +
640
+ (skippedExisting ? `,跳过已存在文件 ${skippedExisting} 个` : '') +
641
+ `,报表目录:${directory}`;
642
+ if (sn && !selected.some((g) => text(g.goodsSn) === sn)) {
643
+ summaryText += `;未匹配货号:${sn}`;
644
+ }
645
+ return { filePaths, summary: summaryText };
646
+ }
647
+
648
+ module.exports = { generate };