@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,440 @@
1
+ // 商品尺寸信息报表:每个商品生成一个 Excel 文件(工作表「尺寸指示」),
2
+ // 默认部位纵向、尺码横向;传 transpose 时转置为尺码纵向、部位横向。
3
+ // 同时导出洗前与洗后尺寸;部位图片下载后嵌入「图片」列(转置布局不嵌图)。
4
+ // 数据流:GET /api/agent/v1/goods/rich-list 分页取商品列表,
5
+ // 再逐商品 GET /api/agent/v1/goods/full-detail 取尺码轴、部位尺寸表与部位图片地址。
6
+ // 列名、尺码轴构建规则、文件名与原 Python 报表脚本 export_goods_measurements.py 一致。
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { call } = require('../cli/api');
10
+ const { timestamp, createThrottle, fetchAll } = require('./util');
11
+ const { downloadImage } = require('./image');
12
+ const { createWorkbook, saveWorkbook, embedImage } = require('./excel');
13
+
14
+ /** 列表分页大小(服务端单页上限 100) */
15
+ const PAGE_LIMIT = 100;
16
+ /** 逐商品详情请求的最小间隔毫秒数(服务端按 Key 每分钟限流约 30 次) */
17
+ const REQUEST_INTERVAL_MS = 2100;
18
+ /** 单个部位最多嵌入图片数、单个商品最多嵌入图片数(与原脚本一致) */
19
+ const MAX_IMAGES_PER_PART = 3;
20
+ const MAX_IMAGES_PER_GOODS = 120;
21
+ /** 部位图片的显示尺寸(像素),多张图嵌在同一单元格内 */
22
+ const PART_IMAGE_SIZE = { width: 64, height: 64 };
23
+
24
+ /** 表头底色:基础列浅蓝、洗前列浅绿、洗后列浅紫(与原脚本一致) */
25
+ const FILL_BASE = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9EAF7' } };
26
+ const FILL_BEFORE = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2F0D9' } };
27
+ const FILL_AFTER = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE4DFEC' } };
28
+ const THIN_BORDER = {
29
+ top: { style: 'thin', color: { argb: 'FFCCCCCC' } },
30
+ left: { style: 'thin', color: { argb: 'FFCCCCCC' } },
31
+ bottom: { style: 'thin', color: { argb: 'FFCCCCCC' } },
32
+ right: { style: 'thin', color: { argb: 'FFCCCCCC' } },
33
+ };
34
+
35
+ function text(value) {
36
+ return value === null || value === undefined ? '' : String(value).trim();
37
+ }
38
+
39
+ /** 取第一个非空值(null、undefined、空字符串视为空),保留原始类型 */
40
+ function firstValue(...values) {
41
+ for (const value of values) {
42
+ if (value !== null && value !== undefined && value !== '') {
43
+ return value;
44
+ }
45
+ }
46
+ return '';
47
+ }
48
+
49
+ /** 生成不含非法字符、不与已用名称冲突的文件名主体 */
50
+ function safeComponent(value, fallback) {
51
+ let v = String(value ?? '').normalize('NFKC').trim();
52
+ v = v.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/[.\s]+$/, '');
53
+ if (!v) {
54
+ v = fallback;
55
+ }
56
+ if (/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i.test(v)) {
57
+ v = `_${v}`;
58
+ }
59
+ return v.slice(0, 100).replace(/[.\s]+$/, '') || fallback;
60
+ }
61
+
62
+ /**
63
+ * 单元格尺寸值:尺寸显示方式为「1-小数」时取数值列(洗前 length / 洗后
64
+ * lengthAfterWash),否则优先取分数文字列、缺失时回退数值列。
65
+ */
66
+ function sizeValue(row, display, after) {
67
+ const numKey = after ? 'lengthAfterWash' : 'length';
68
+ const strKey = after ? 'lengthStrAfterWash' : 'lengthStr';
69
+ if (String(display) === '1') {
70
+ const value = row[numKey];
71
+ if (value === null || value === undefined || value === '' || typeof value === 'boolean') {
72
+ return '';
73
+ }
74
+ return value;
75
+ }
76
+ return firstValue(row[strKey], row[numKey]);
77
+ }
78
+
79
+ /**
80
+ * 把列表行与详情整理成报表数据结构:
81
+ * - 尺码轴按启用 SKU 的尺码名首次出现排序、按尺码 id 去重;
82
+ * 部位尺寸数据里出现而轴中没有的尺码名追加为兜底列。
83
+ * - 每个部位的尺寸值按「尺码 id(无 id 时用尺码名)」索引,分洗前/洗后两组。
84
+ */
85
+ function normalizeGoods(summary, detail) {
86
+ const display = firstValue(detail.sizeLenDisplay, '');
87
+ const sizes = [];
88
+ const seenIds = new Set();
89
+ const seenNames = new Set();
90
+ for (const sku of detail.goodsSkuList || []) {
91
+ if (sku.isDisable) {
92
+ continue;
93
+ }
94
+ const name = text(sku.sizeName);
95
+ if (!name || seenNames.has(name)) {
96
+ continue;
97
+ }
98
+ const key = sku.sizeId !== null && sku.sizeId !== undefined ? String(sku.sizeId) : name;
99
+ if (seenIds.has(key)) {
100
+ continue;
101
+ }
102
+ seenNames.add(name);
103
+ seenIds.add(key);
104
+ sizes.push({ id: sku.sizeId, name, key });
105
+ }
106
+ const parts = [];
107
+ for (const raw of detail.goodsPartList || []) {
108
+ const values = new Map();
109
+ for (const item of raw.sizeLengths || []) {
110
+ const name = text(item.sizeName);
111
+ const key = item.sizeId !== null && item.sizeId !== undefined ? String(item.sizeId) : name;
112
+ // 无 SKU 但有尺寸数据时补充尺码列,避免尺寸值没有列可展示
113
+ if (name && !seenNames.has(name) && !seenIds.has(key)) {
114
+ seenNames.add(name);
115
+ seenIds.add(key);
116
+ sizes.push({ id: item.sizeId, name, key });
117
+ }
118
+ values.set(key, { before: sizeValue(item, display, false), after: sizeValue(item, display, true) });
119
+ }
120
+ // 小数显示时误差范围优先数值列,分数显示时优先文字列
121
+ const errorRange = String(display) === '1'
122
+ ? firstValue(raw.errorRange, raw.errorRangeStr)
123
+ : firstValue(raw.errorRangeStr, raw.errorRange);
124
+ parts.push({
125
+ name: text(raw.name),
126
+ measureMethod: text(raw.measureMethod),
127
+ errorRange: text(errorRange),
128
+ sample: text(firstValue(raw.sampleLengthStr, raw.sampleLength)),
129
+ grading: text(raw.gradingDifference),
130
+ gradingAfter: text(raw.gradingDifferenceAfterWash),
131
+ attachments: raw.attachmentUrls || [],
132
+ values,
133
+ });
134
+ }
135
+ return {
136
+ id: firstValue(detail.id, summary.id),
137
+ goodsName: text(firstValue(detail.name, summary.name)),
138
+ goodsSn: text(firstValue(detail.goodsSn, summary.goodsSn)),
139
+ sampleSize: text(detail.partLenSampleSizeName),
140
+ sizes,
141
+ parts,
142
+ };
143
+ }
144
+
145
+ /** 下载各部位图片:每部位最多 3 张、每商品最多 120 张,失败记录到导出说明 */
146
+ async function downloadPartImages(item) {
147
+ const imagesByPart = new Map();
148
+ const failures = [];
149
+ let count = 0;
150
+ for (let index = 0; index < item.parts.length; index += 1) {
151
+ const part = item.parts[index];
152
+ const images = [];
153
+ for (const url of part.attachments.slice(0, MAX_IMAGES_PER_PART)) {
154
+ if (count >= MAX_IMAGES_PER_GOODS) {
155
+ break;
156
+ }
157
+ const image = await downloadImage(text(url));
158
+ if (image) {
159
+ images.push(image);
160
+ count += 1;
161
+ } else {
162
+ failures.push({ part: part.name || String(index + 1), url: text(url) });
163
+ }
164
+ }
165
+ imagesByPart.set(index, images);
166
+ }
167
+ return { imagesByPart, failures };
168
+ }
169
+
170
+ /** 图片下载失败地址脱敏:去掉 query 与 fragment,避免暴露签名参数 */
171
+ function sanitizeUrl(url) {
172
+ if (!url) {
173
+ return '';
174
+ }
175
+ try {
176
+ const parsed = new URL(url);
177
+ return `${parsed.origin}${parsed.pathname}`;
178
+ } catch (e) {
179
+ return '';
180
+ }
181
+ }
182
+
183
+ /** 表头单元格样式:按列底色加粗居中 */
184
+ function styleHeaderCell(cell, fill) {
185
+ cell.border = THIN_BORDER;
186
+ cell.fill = fill;
187
+ cell.font = { bold: true };
188
+ cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
189
+ }
190
+
191
+ /** 数据单元格样式:细边框、垂直居中、自动换行(与原脚本数据样式一致,不水平居中) */
192
+ function styleDataCell(cell) {
193
+ cell.border = THIN_BORDER;
194
+ cell.alignment = { vertical: 'middle', wrapText: true };
195
+ }
196
+
197
+ /** 尺寸值取用:部位 values 里按尺码 key 查洗前/洗后值,缺失返回空 */
198
+ function valueOf(part, size, after) {
199
+ const entry = part.values.get(size.key);
200
+ if (!entry) {
201
+ return '';
202
+ }
203
+ return after ? entry.after : entry.before;
204
+ }
205
+
206
+ /** 写「部位纵向、尺码横向」布局:一行表头 + 每部位一行数据 */
207
+ function writePartVertical(workbook, item, partImages) {
208
+ const sheet = workbook.addWorksheet('尺寸指示', { views: [{ state: 'frozen', ySplit: 1 }] });
209
+ const sampleTitle = `打样尺寸(${item.sampleSize || '样衣'})`;
210
+ const headers = ['部位', '测量方法', '图片', '误差范围(+-)', sampleTitle, '洗前尺寸:档差'];
211
+ for (const size of item.sizes) {
212
+ headers.push(`洗前尺寸:${size.name}`);
213
+ }
214
+ headers.push('洗后尺寸:档差');
215
+ for (const size of item.sizes) {
216
+ headers.push(`洗后尺寸:${size.name}`);
217
+ }
218
+ // 表头配色:基础 5 列蓝,洗前档差列加洗前尺码列浅绿,洗后档差列加洗后尺码列浅紫
219
+ const headerRow = sheet.getRow(1);
220
+ headerRow.values = headers;
221
+ headerRow.height = 28;
222
+ headerRow.eachCell({ includeEmpty: true }, (cell, colNumber) => {
223
+ styleHeaderCell(cell, colNumber <= 5 ? FILL_BASE : colNumber <= 6 + item.sizes.length ? FILL_BEFORE : FILL_AFTER);
224
+ });
225
+
226
+ item.parts.forEach((part, index) => {
227
+ const row = sheet.getRow(index + 2);
228
+ const values = [
229
+ part.name,
230
+ part.measureMethod,
231
+ '',
232
+ part.errorRange,
233
+ part.sample,
234
+ part.grading,
235
+ ...item.sizes.map((size) => valueOf(part, size, false)),
236
+ part.gradingAfter,
237
+ ...item.sizes.map((size) => valueOf(part, size, true)),
238
+ ];
239
+ row.values = values;
240
+ row.height = 54;
241
+ row.eachCell({ includeEmpty: true }, styleDataCell);
242
+ // 部位图片全部嵌在该行的图片列(第 3 列),多张图按原脚本叠放
243
+ for (const image of partImages.get(index) || []) {
244
+ embedImage(workbook, sheet, image, index + 2, 3, PART_IMAGE_SIZE);
245
+ }
246
+ });
247
+
248
+ headers.forEach((_, i) => {
249
+ sheet.getColumn(i + 1).width = i === 2 ? 16 : 22;
250
+ });
251
+ return sheet;
252
+ }
253
+
254
+ /**
255
+ * 写「尺码纵向、部位横向」布局(transpose):表头为各部位列,
256
+ * 行依次为每个尺码的洗前尺寸、每个尺码的洗后尺寸、测量方法、误差范围、打样尺寸。
257
+ * 该布局不嵌部位图片。
258
+ */
259
+ function writeSizeVertical(workbook, item) {
260
+ const sheet = workbook.addWorksheet('尺寸指示', { views: [{ state: 'frozen', ySplit: 1 }] });
261
+ const top = ['部位', ...item.parts.map((p, i) => p.name || `部位${i + 1}`)];
262
+ const headerRow = sheet.getRow(1);
263
+ headerRow.values = top;
264
+ headerRow.height = 28;
265
+ headerRow.eachCell({ includeEmpty: true }, (cell) => styleHeaderCell(cell, FILL_BASE));
266
+
267
+ const rows = [];
268
+ const rowFills = [];
269
+ for (const size of item.sizes) {
270
+ rows.push([`洗前尺寸${size.name}`, ...item.parts.map((p) => valueOf(p, size, false))]);
271
+ rowFills.push(FILL_BEFORE);
272
+ }
273
+ for (const size of item.sizes) {
274
+ rows.push([`洗后尺寸${size.name}`, ...item.parts.map((p) => valueOf(p, size, true))]);
275
+ rowFills.push(FILL_AFTER);
276
+ }
277
+ rows.push(['测量方法', ...item.parts.map((p) => p.measureMethod)]);
278
+ rowFills.push(FILL_BASE);
279
+ rows.push(['误差范围(+-)', ...item.parts.map((p) => p.errorRange)]);
280
+ rowFills.push(FILL_BASE);
281
+ rows.push([`打样尺寸(${item.sampleSize || '样衣'})`, ...item.parts.map((p) => p.sample)]);
282
+ rowFills.push(FILL_BASE);
283
+
284
+ rows.forEach((values, index) => {
285
+ const row = sheet.getRow(index + 2);
286
+ row.values = values;
287
+ row.height = 24;
288
+ row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
289
+ // 第一列是字段名,按所在行分组着色;其余列为数据
290
+ if (colNumber === 1) {
291
+ styleHeaderCell(cell, rowFills[index]);
292
+ } else {
293
+ styleDataCell(cell);
294
+ }
295
+ });
296
+ });
297
+
298
+ top.forEach((_, i) => {
299
+ sheet.getColumn(i + 1).width = i === 0 ? 20 : 22;
300
+ });
301
+ return sheet;
302
+ }
303
+
304
+ /** 图片下载失败时补一张「导出说明」工作表,记录失败部位与脱敏地址 */
305
+ function writeNotesSheet(workbook, failures) {
306
+ if (!failures.length) {
307
+ return;
308
+ }
309
+ const sheet = workbook.addWorksheet('导出说明');
310
+ const headers = ['类型', '部位', '说明', '地址(脱敏)'];
311
+ const headerRow = sheet.getRow(1);
312
+ headerRow.values = headers;
313
+ headerRow.height = 28;
314
+ headerRow.eachCell({ includeEmpty: true }, (cell) => styleHeaderCell(cell, FILL_BASE));
315
+ failures.forEach((failure, index) => {
316
+ const row = sheet.getRow(index + 2);
317
+ row.values = ['图片下载失败', failure.part, '下载失败或格式校验未通过', sanitizeUrl(failure.url)];
318
+ row.height = 22;
319
+ row.eachCell({ includeEmpty: true }, styleDataCell);
320
+ });
321
+ headers.forEach((_, i) => {
322
+ sheet.getColumn(i + 1).width = i === 0 ? 14 : 26;
323
+ });
324
+ }
325
+
326
+ /** 创建输出目录;同名目录已存在时追加序号,避免覆盖既有报表 */
327
+ function makeDirectory(parent, baseName) {
328
+ let dir = path.join(parent, baseName);
329
+ let seq = 2;
330
+ while (fs.existsSync(dir)) {
331
+ dir = path.join(parent, `${baseName}-${seq}`);
332
+ seq += 1;
333
+ }
334
+ fs.mkdirSync(dir, { recursive: true });
335
+ return dir;
336
+ }
337
+
338
+ /**
339
+ * 单个商品文件名:`商品名称(货号).xlsx`;同一批内重名先追加商品 id,
340
+ * 仍冲突再追加自增序号(与原脚本 file_plan 一致)。
341
+ */
342
+ function planFile(directory, item, used) {
343
+ const base = `${safeComponent(item.goodsName, '商品')}(${safeComponent(item.goodsSn, '无货号')})`;
344
+ let name = `${base}.xlsx`;
345
+ if (used.has(name.toLowerCase())) {
346
+ name = `${base}_${item.id}.xlsx`;
347
+ }
348
+ let seq = 2;
349
+ while (used.has(name.toLowerCase())) {
350
+ name = `${base}_${item.id}_${seq}.xlsx`;
351
+ seq += 1;
352
+ }
353
+ used.add(name.toLowerCase());
354
+ return path.join(directory, name);
355
+ }
356
+
357
+ /**
358
+ * 报表入口:分页取商品列表(可选按款号过滤),逐商品取详情生成尺寸表。
359
+ * 没有尺寸部位数据的商品不生成文件;单个商品详情失败会跳过并继续。
360
+ * argv:{ sn 款号(可选), limit 最多导出商品数(默认 100),
361
+ * transpose 转置为尺码纵向, out 输出目录 }。
362
+ */
363
+ async function generate(argv = {}) {
364
+ const limit = Number(argv.limit) > 0 ? Number(argv.limit) : 100;
365
+ const transpose = argv.transpose === true;
366
+ const outDir = argv.out ? path.resolve(String(argv.out)) : process.cwd();
367
+ const sn = text(argv.sn);
368
+ const throttle = createThrottle(REQUEST_INTERVAL_MS);
369
+
370
+ const { list } = await fetchAll(
371
+ (page) => throttle(() => call('GET', '/api/agent/v1/goods/rich-list', {
372
+ sn: sn || undefined,
373
+ isOnSale: 1,
374
+ skuIsDisable: 0,
375
+ page,
376
+ limit: PAGE_LIMIT,
377
+ })),
378
+ { limit: PAGE_LIMIT, maxPages: Math.max(1, Math.ceil(limit / PAGE_LIMIT)) }
379
+ );
380
+ const selected = list.slice(0, limit);
381
+ if (!selected.length) {
382
+ throw new Error('没有符合条件的商品,未发布空目录');
383
+ }
384
+
385
+ // 目录名用「商品尺寸信息 + 可选尺码纵向标记 + 时间戳」区分两种展示方向
386
+ const directory = makeDirectory(outDir, `商品尺寸信息${transpose ? '尺码纵向' : ''}${timestamp()}`);
387
+ const used = new Set();
388
+ const filePaths = [];
389
+ let detailFailed = 0;
390
+ let skippedExisting = 0;
391
+ for (let i = 0; i < selected.length; i += 1) {
392
+ const summary = selected[i];
393
+ let detail;
394
+ try {
395
+ detail = await throttle(() => call('GET', '/api/agent/v1/goods/full-detail', { id: summary.id }));
396
+ } catch (e) {
397
+ // 单个商品详情失败不中断其它商品
398
+ detailFailed += 1;
399
+ process.stdout.write(`商品 ${text(summary.goodsSn) || summary.id} 详情获取失败:${e.message}\n`);
400
+ continue;
401
+ }
402
+ const item = normalizeGoods(summary, detail);
403
+ if (!item.parts.length) {
404
+ continue;
405
+ }
406
+ const filePath = planFile(directory, item, used);
407
+ if (fs.existsSync(filePath)) {
408
+ skippedExisting += 1;
409
+ continue;
410
+ }
411
+ const workbook = createWorkbook();
412
+ if (transpose) {
413
+ writeSizeVertical(workbook, item);
414
+ } else {
415
+ const { imagesByPart, failures } = await downloadPartImages(item);
416
+ writePartVertical(workbook, item, imagesByPart);
417
+ writeNotesSheet(workbook, failures);
418
+ }
419
+ await saveWorkbook(workbook, filePath);
420
+ filePaths.push(filePath);
421
+ if ((i + 1) % 10 === 0) {
422
+ process.stdout.write(`已处理 ${i + 1}/${selected.length} 个商品\n`);
423
+ }
424
+ }
425
+ if (!filePaths.length) {
426
+ throw new Error('没有成功生成文件,未发布空目录');
427
+ }
428
+
429
+ let summaryText = `商品尺寸信息导出完成:商品 ${filePaths.length} 个`;
430
+ if (detailFailed) {
431
+ summaryText += `,详情获取失败 ${detailFailed} 个`;
432
+ }
433
+ if (skippedExisting) {
434
+ summaryText += `,跳过已存在文件 ${skippedExisting} 个`;
435
+ }
436
+ summaryText += `,报表目录:${directory}`;
437
+ return { filePaths, summary: summaryText };
438
+ }
439
+
440
+ module.exports = { generate };
@@ -0,0 +1,216 @@
1
+ // 报表嵌图公共模块:下载商品图、物料图等到内存,供 Excel 嵌入。
2
+ // 图片地址以 CDN 为主(不需要认证)。下载前统一转换为缩略地址,下载后做
3
+ // 大小与格式校验,任何一步失败都返回 null,报表跳过该图,不中断整体生成。
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { sleep } = require('./util');
8
+
9
+ /** 单张图片大小上限 5MB(与原报表脚本一致) */
10
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
11
+ /** 图片宽高上限与总像素上限(与原报表脚本一致,防御异常超大图) */
12
+ const MAX_DIMENSION = 8192;
13
+ const MAX_PIXELS = 20_000_000;
14
+ /** 用查询参数承载缩略样式的 CDN 域名 */
15
+ const CDN_QUERY_HOSTS = new Set(['is-scm.cdn.qinsilk.com', 'is-scm-dev.cdn.qinsilk.com']);
16
+
17
+ /** 每张图片的缓存目录(按服务器域名隔离,进程结束后由系统临时目录策略清理) */
18
+ function cacheDir() {
19
+ const dir = path.join(os.tmpdir(), 'scm-agent-images');
20
+ fs.mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+
24
+ /**
25
+ * 把接口返回的图片地址转换为缩略下载地址(约 200px,Excel 嵌图 60-80px 足够)。
26
+ * 规则与原报表脚本一致:
27
+ * - qinsilk 域名下 http 图片升级为 https;
28
+ * - 地址已带查询参数、已带 @! 处理后缀或是 gif,说明已处理过,原样返回;
29
+ * - is-scm*.cdn 域名用查询参数 x-image-process=style/pic200wh;
30
+ * - 其他 qinsilk 域名在路径末尾追加 @!pic200wh;
31
+ * - 其他域名原样返回(不掌握其图片服务能力,不做处理)。
32
+ * 无法解析的地址返回 null。
33
+ */
34
+ function toThumbUrl(rawUrl, baseUrl) {
35
+ const text = String(rawUrl || '').trim();
36
+ if (!text) {
37
+ return null;
38
+ }
39
+ let url;
40
+ try {
41
+ url = new URL(text);
42
+ } catch (e) {
43
+ if (!baseUrl) {
44
+ return null;
45
+ }
46
+ try {
47
+ url = new URL(text, baseUrl.endsWith('/') ? baseUrl : baseUrl + '/');
48
+ } catch (e2) {
49
+ return null;
50
+ }
51
+ }
52
+ const host = (url.hostname || '').toLowerCase();
53
+ if (url.protocol === 'http:' && host.endsWith('.qinsilk.com')) {
54
+ url.protocol = 'https:';
55
+ }
56
+ let decodedPath = url.pathname;
57
+ try {
58
+ decodedPath = decodeURIComponent(url.pathname).toLowerCase();
59
+ } catch (e) {
60
+ decodedPath = url.pathname.toLowerCase();
61
+ }
62
+ if (url.search || url.pathname.includes('@!') || decodedPath.endsWith('.gif')) {
63
+ return url.href;
64
+ }
65
+ if (CDN_QUERY_HOSTS.has(host)) {
66
+ url.search = 'x-image-process=style/pic200wh';
67
+ return url.href;
68
+ }
69
+ if (host === 'qinsilk.com' || host.endsWith('.qinsilk.com')) {
70
+ return url.origin + url.pathname + '@!pic200wh';
71
+ }
72
+ return url.href;
73
+ }
74
+
75
+ /** 下载后的内存缓存(键为接口返回的原始地址),同一份报表内重复图片只下载一次 */
76
+ const memory = new Map();
77
+
78
+ /**
79
+ * 下载图片并返回 { buffer, extension };地址非法、下载失败或校验不通过返回 null。
80
+ * 重试一次后放弃,保证网络波动不会让整份报表失败。
81
+ */
82
+ async function downloadImage(url, { baseUrl } = {}) {
83
+ if (!url) {
84
+ return null;
85
+ }
86
+ if (memory.has(url)) {
87
+ return memory.get(url);
88
+ }
89
+ const result = await downloadWithRetry(toThumbUrl(url, baseUrl));
90
+ memory.set(url, result);
91
+ return result;
92
+ }
93
+
94
+ async function downloadWithRetry(thumbUrl) {
95
+ if (!thumbUrl) {
96
+ return null;
97
+ }
98
+ for (let attempt = 0; attempt < 2; attempt += 1) {
99
+ try {
100
+ const response = await fetch(thumbUrl, { redirect: 'follow' });
101
+ if (!response.ok) {
102
+ throw new Error(`HTTP ${response.status}`);
103
+ }
104
+ const declaredLength = Number(response.headers.get('content-length') || 0);
105
+ if (declaredLength > MAX_IMAGE_BYTES) {
106
+ return null;
107
+ }
108
+ const buffer = Buffer.from(await response.arrayBuffer());
109
+ const extension = validateImage(buffer);
110
+ if (!extension) {
111
+ return null;
112
+ }
113
+ return { buffer, extension };
114
+ } catch (e) {
115
+ if (attempt === 1) {
116
+ return null;
117
+ }
118
+ await sleep(500);
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** 读 PNG 头部的宽高(IHDR 固定偏移 16/20) */
125
+ function pngSize(buffer) {
126
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
127
+ }
128
+
129
+ /**
130
+ * 按 JPEG 段结构定位 SOF 段读取宽高。
131
+ * 0xC4/0xC8/0xCC 不是尺寸段必须排除;0xFF 填充与 0xD0-0xD7 独立标记无长度字段直接跳过。
132
+ */
133
+ function jpegSize(buffer) {
134
+ let index = 2;
135
+ while (index < buffer.length - 1) {
136
+ if (buffer[index] !== 0xff) {
137
+ index += 1;
138
+ continue;
139
+ }
140
+ const marker = buffer[index + 1];
141
+ if (marker === 0xff || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
142
+ index += 2;
143
+ continue;
144
+ }
145
+ if (marker === 0xd9 || index + 4 > buffer.length) {
146
+ return null;
147
+ }
148
+ const isSizeMarker = (marker >= 0xc0 && marker < 0xc4) || (marker >= 0xc5 && marker < 0xc8)
149
+ || (marker >= 0xc9 && marker < 0xcc) || (marker >= 0xcd && marker < 0xd0);
150
+ if (isSizeMarker) {
151
+ if (index + 9 > buffer.length) {
152
+ return null;
153
+ }
154
+ return { width: buffer.readUInt16BE(index + 7), height: buffer.readUInt16BE(index + 5) };
155
+ }
156
+ index += 2 + buffer.readUInt16BE(index + 2);
157
+ }
158
+ return null;
159
+ }
160
+
161
+ /**
162
+ * 校验图片内容并返回扩展名(png / jpg),不合格返回 null:
163
+ * 只接受 PNG 与 JPEG;超过 5MB、宽高超过 8192、总像素超过 2000 万的都拒绝。
164
+ * 校验以内容魔数为准(URL 后缀可能不准确)。
165
+ */
166
+ function validateImage(buffer) {
167
+ if (!buffer || !buffer.length || buffer.length > MAX_IMAGE_BYTES) {
168
+ return null;
169
+ }
170
+ let size = null;
171
+ let extension = null;
172
+ if (buffer.length >= 24 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47) {
173
+ size = pngSize(buffer);
174
+ extension = 'png';
175
+ } else if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
176
+ size = jpegSize(buffer);
177
+ extension = 'jpg';
178
+ } else {
179
+ return null;
180
+ }
181
+ if (!size || !size.width || !size.height) {
182
+ return null;
183
+ }
184
+ if (size.width > MAX_DIMENSION || size.height > MAX_DIMENSION || size.width * size.height > MAX_PIXELS) {
185
+ return null;
186
+ }
187
+ return extension;
188
+ }
189
+
190
+ /**
191
+ * 下载一批图片(地址去重、剔除空值),返回 原始地址 → { buffer, extension } 的 Map。
192
+ * 并发 4 个,避免图片较多时压满连接。
193
+ */
194
+ async function downloadImages(urls, { onProgress, baseUrl } = {}) {
195
+ const unique = [...new Set((urls || []).filter(Boolean))];
196
+ const map = new Map();
197
+ let done = 0;
198
+ const queue = [...unique];
199
+ async function worker() {
200
+ while (queue.length) {
201
+ const url = queue.shift();
202
+ const image = await downloadImage(url, { baseUrl });
203
+ if (image) {
204
+ map.set(url, image);
205
+ }
206
+ done += 1;
207
+ if (onProgress) {
208
+ onProgress(done, unique.length);
209
+ }
210
+ }
211
+ }
212
+ await Promise.all(Array.from({ length: Math.min(4, unique.length) }, worker));
213
+ return map;
214
+ }
215
+
216
+ module.exports = { downloadImage, downloadImages, toThumbUrl, validateImage };