@qinsilk/scm-agent-tools 0.0.0-stage → 0.1.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 (48) hide show
  1. package/README.md +48 -2
  2. package/cli/api.js +114 -0
  3. package/cli/commands/log.js +152 -0
  4. package/cli/commands/query.js +229 -0
  5. package/cli/commands/report.js +213 -0
  6. package/cli/commands/staff.js +505 -0
  7. package/cli/config.js +46 -0
  8. package/cli/scm-agent.js +105 -0
  9. package/cli/skills.js +89 -0
  10. package/cli/table.js +43 -0
  11. package/docs/cli.md +180 -0
  12. package/package.json +27 -4
  13. package/reports/excel.js +107 -0
  14. package/reports/factoryEvaluation.js +128 -0
  15. package/reports/goodsBom.js +648 -0
  16. package/reports/goodsFirstOrder.js +409 -0
  17. package/reports/goodsInventory.js +220 -0
  18. package/reports/goodsSize.js +440 -0
  19. package/reports/image.js +216 -0
  20. package/reports/leakCheck.js +694 -0
  21. package/reports/materialPurchaseExport.js +393 -0
  22. package/reports/materialTransit.js +593 -0
  23. package/reports/processFollowup.js +312 -0
  24. package/reports/produceBoard.js +353 -0
  25. package/reports/produceCost.js +366 -0
  26. package/reports/produceCostDetail.js +622 -0
  27. package/reports/produceDaily.js +485 -0
  28. package/reports/storeInGoods.js +276 -0
  29. package/reports/supplierBalance.js +671 -0
  30. package/reports/ticketStatistics.js +289 -0
  31. package/reports/util.js +83 -0
  32. package/skills/bom/345/225/206/345/223/201/344/277/241/346/201/257/SKILL.md +40 -0
  33. package/skills/qinsilk-scm/SKILL.md +95 -0
  34. 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
  35. package/skills//345/225/206/345/223/201/345/260/272/345/257/270/344/277/241/346/201/257/SKILL.md +40 -0
  36. 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
  37. package/skills//345/267/245/345/216/202/350/257/204/344/274/260/346/212/245/350/241/250/SKILL.md +39 -0
  38. package/skills//346/210/220/345/223/201/345/205/245/345/272/223/346/212/245/350/241/250/SKILL.md +40 -0
  39. 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
  40. package/skills//346/274/217/346/226/231/346/243/200/346/265/213/346/227/245/346/212/245/SKILL.md +40 -0
  41. 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
  42. package/skills//347/211/251/346/226/231/351/207/207/350/264/255/345/257/274/345/207/272/SKILL.md +39 -0
  43. 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
  44. package/skills//347/224/237/344/272/247/346/210/220/346/234/254/346/212/245/350/241/250/SKILL.md +41 -0
  45. 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
  46. package/skills//347/224/237/344/272/247/346/227/245/346/212/245/SKILL.md +42 -0
  47. 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
  48. 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,393 @@
1
+ // 物料采购导出:以物料采购页面的导出口径生成 Excel。
2
+ // 两级表头:单据级列纵向合并两行,中间 10 列合并为「采购物料明细」组;
3
+ // 同一采购单展开成多个明细行后,单据级列做纵向合并。明细列嵌物料图。
4
+ // 默认导出近三个月业务日期、五种进行中状态的数据(不传状态时接口默认同此口径)。
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { call } = require('../cli/api');
8
+ const { dateStr, timestamp, fetchAll } = require('./util');
9
+ const { createWorkbook, saveWorkbook, embedImage } = require('./excel');
10
+ const { downloadImages } = require('./image');
11
+
12
+ const SHEET_NAME = '物料采购';
13
+ // 单据状态与发货状态文案(与页面一致)
14
+ const STATE_NAMES = { 1: '草稿', 2: '待入库', 3: '部分入库', 4: '全部入库', 9: '已撤销', 10: '待审核', 12: '审核拒绝' };
15
+ const DELIVERY_NAMES = { 2: '待发货', 3: '部分发货', 4: '已发货' };
16
+ // Excel 单工作表行数上限(留两行表头)
17
+ const MAX_EXCEL_ROWS = 1048574;
18
+
19
+ // 单据级列(第一段 + 第三段,取值函数 getValue(row))
20
+ const PARENT_COLUMNS = [
21
+ { header: '单据状态', field: 'stateText' },
22
+ { header: '发货状态', field: 'orderDeliveryStateText' },
23
+ { header: '采购单号', field: 'ordersSn' },
24
+ { header: '关联生产单', field: 'produceOrdersSns', width: 28 },
25
+ { header: '床次', field: 'bedNumbersText' },
26
+ { header: '供应商', field: 'supplierName', width: 20 },
27
+ { header: '标签', field: 'tagsText', width: 20 },
28
+ { header: '采购数', field: 'purchaseUnitNumber' },
29
+ ];
30
+ // 物料明细列(第二段;detailField 标记取值来自明细行)
31
+ const DETAIL_COLUMNS = [
32
+ { header: '图片', field: 'materialImgUrl', kind: 'image' },
33
+ { header: '物料名称', field: 'materialName', width: 18 },
34
+ { header: '物料货号', field: 'materialSn', width: 18 },
35
+ { header: '颜色', field: 'colorName' },
36
+ { header: '规格', field: 'specText', width: 18 },
37
+ { header: '色号', field: 'materialSkuSupplierColor', width: 14 },
38
+ { header: '幅宽', field: 'fabricWidth' },
39
+ { header: '采购数', field: 'purchaseUnitNumber' },
40
+ { header: '采购单价', field: 'purchasePrice' },
41
+ { header: '采购金额', field: 'purchaseUnitAmount' },
42
+ ];
43
+ const TAIL_COLUMNS = [
44
+ { header: '预计入库数', field: 'purchaseNumber' },
45
+ { header: '基础发货', field: 'deliverNumber' },
46
+ { header: '采购待入库', field: 'purchaseStoreWinNumber' },
47
+ { header: '采购已入库', field: 'purchaseStoreInNumber' },
48
+ { header: '采购放弃入库', field: 'purchaseTerminateInNumber', width: 16 },
49
+ { header: '基础待入库', field: 'storeWinNumber' },
50
+ { header: '基础已入库', field: 'storeInNumber' },
51
+ { header: '基础放弃入库', field: 'terminateInNumber', width: 16 },
52
+ { header: '采购金额', field: 'amountText' },
53
+ { header: '抹零金额', field: 'totalWipeZeroText' },
54
+ { header: '合计金额', field: 'trueAmountText' },
55
+ { header: '已付金额', field: 'paymentText' },
56
+ { header: '客户', field: 'clientNameText', width: 18 },
57
+ { header: '供应商单号', field: 'supplierSn', width: 18 },
58
+ { header: '预计到料', field: 'expectTime' },
59
+ { header: '实际入库', field: 'purchaseStoreInTimesText', width: 24 },
60
+ { header: '业务时间', field: 'businessTimeText', width: 18 },
61
+ { header: '创建人', field: 'handlerName' },
62
+ { header: '创建时间', field: 'createTime', width: 18 },
63
+ { header: '更新人', field: 'lastHandlerName' },
64
+ { header: '更新时间', field: 'updateTime', width: 18 },
65
+ ];
66
+ const COLUMNS = [...PARENT_COLUMNS, ...DETAIL_COLUMNS, ...TAIL_COLUMNS];
67
+ const DETAIL_FIELDS = new Set(DETAIL_COLUMNS.map((column) => column.field));
68
+ // 左对齐的长文本列(与原脚本一致)
69
+ const LEFT_FIELDS = new Set(['ordersSn', 'produceOrdersSns', 'supplierName', 'tagsText', 'clientNameText']);
70
+
71
+ const BORDER = {
72
+ top: { style: 'thin', color: { argb: 'FFCCCCCC' } },
73
+ left: { style: 'thin', color: { argb: 'FFCCCCCC' } },
74
+ bottom: { style: 'thin', color: { argb: 'FFCCCCCC' } },
75
+ right: { style: 'thin', color: { argb: 'FFCCCCCC' } },
76
+ };
77
+ // 表头底色:浅蓝(与原脚本一致)
78
+ const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9EAF7' } };
79
+
80
+ /** 文本归一化:空值返回空字符串 */
81
+ function text(value) {
82
+ return value == null ? '' : String(value).trim();
83
+ }
84
+
85
+ /** 数量取值:无法转数字按原文返回 */
86
+ function numeric(value) {
87
+ if (value === null || value === undefined || value === '') {
88
+ return '';
89
+ }
90
+ const parsed = Number(value);
91
+ return Number.isFinite(parsed) ? parsed : text(value);
92
+ }
93
+
94
+ /** 金额两位小数四舍五入;缺失返回空 */
95
+ function money(value) {
96
+ if (value === null || value === undefined || value === '') {
97
+ return '';
98
+ }
99
+ const parsed = Number(value);
100
+ if (!Number.isFinite(parsed)) {
101
+ return '';
102
+ }
103
+ const factor = 100;
104
+ const magnitude = Math.abs(parsed) * factor;
105
+ const rounded = Math.round(magnitude + Number.EPSILON * magnitude);
106
+ return (parsed < 0 ? -rounded : rounded) / factor;
107
+ }
108
+
109
+ /**
110
+ * 今天往前推 months 个自然月的同号日期;目标月没有该号时取月末
111
+ * (与原脚本 months_ago 一致)。
112
+ */
113
+ function monthsAgo(base, months) {
114
+ const year = base.getFullYear();
115
+ const month = base.getMonth();
116
+ const day = base.getDate();
117
+ let targetYear = year;
118
+ let targetMonth = month - months;
119
+ while (targetMonth < 0) {
120
+ targetMonth += 12;
121
+ targetYear -= 1;
122
+ }
123
+ const lastDay = new Date(targetYear, targetMonth + 1, 0).getDate();
124
+ return new Date(targetYear, targetMonth, Math.min(day, lastDay));
125
+ }
126
+
127
+ /** 规格文本:属性值用 - 连接(与页面 specFormatter 一致) */
128
+ function specText(value) {
129
+ if (!Array.isArray(value) || !value.length) {
130
+ return '';
131
+ }
132
+ return value.map((item) => text(item.propertyValue)).join('-');
133
+ }
134
+
135
+ /** 关联生产单文本:每项「单号或批次号(商品货号)」用 、连接 */
136
+ function produceOrdersSnsText(relations) {
137
+ if (!Array.isArray(relations)) {
138
+ return '';
139
+ }
140
+ return relations
141
+ .map((relation) => {
142
+ const sn = text(relation.produceOrdersSn) || text(relation.produceBatchSn);
143
+ const goodsSn = text(relation.goodsSn);
144
+ return sn && goodsSn ? `${sn}(${goodsSn})` : sn;
145
+ })
146
+ .filter(Boolean)
147
+ .join('、');
148
+ }
149
+
150
+ /**
151
+ * 把一条采购单记录加工成单据级展示字段(金额量化、状态翻译、列表拼接)。
152
+ */
153
+ function decorate(record) {
154
+ const relations = Array.isArray(record.produceRelations) ? record.produceRelations : [];
155
+ return {
156
+ stateText: STATE_NAMES[record.state] || text(record.state),
157
+ orderDeliveryStateText: DELIVERY_NAMES[record.orderDeliveryState] || text(record.orderDeliveryState),
158
+ ordersSn: text(record.ordersSn),
159
+ produceOrdersSns: produceOrdersSnsText(relations),
160
+ bedNumbersText: Array.isArray(record.bedNumbers) && record.bedNumbers.length
161
+ ? record.bedNumbers.map(text).filter(Boolean).join('、') || '-'
162
+ : '-',
163
+ supplierName: text(record.supplierName),
164
+ tagsText: Array.isArray(record.tags) ? record.tags.map(text).filter(Boolean).join('、') : '',
165
+ purchaseUnitNumber: numeric(record.purchaseUnitNumber),
166
+ purchaseNumber: numeric(record.purchaseNumber),
167
+ deliverNumber: numeric(record.deliverNumber),
168
+ purchaseStoreWinNumber: numeric(record.purchaseStoreWinNumber),
169
+ purchaseStoreInNumber: numeric(record.purchaseStoreInNumber),
170
+ purchaseTerminateInNumber: numeric(record.purchaseTerminateInNumber),
171
+ storeWinNumber: numeric(record.storeWinNumber),
172
+ storeInNumber: numeric(record.storeInNumber),
173
+ terminateInNumber: numeric(record.terminateInNumber),
174
+ amountText: money(record.amount),
175
+ totalWipeZeroText: money(record.totalWipeZero),
176
+ // 合计金额 = 采购金额 - 抹零金额,下限 0
177
+ trueAmountText: (() => {
178
+ const amount = Number(record.amount);
179
+ const wipe = Number(record.totalWipeZero);
180
+ if (!Number.isFinite(amount) && !Number.isFinite(wipe)) {
181
+ return '';
182
+ }
183
+ return money(Math.max((Number.isFinite(amount) ? amount : 0) - (Number.isFinite(wipe) ? wipe : 0), 0));
184
+ })(),
185
+ paymentText: money(record.payment),
186
+ clientNameText: relations.length
187
+ ? relations.map((relation) => text(relation.produceOrderClientName)).filter(Boolean).join('、') || '-'
188
+ : '-',
189
+ supplierSn: text(record.supplierSn),
190
+ expectTime: text(record.expectTime),
191
+ purchaseStoreInTimesText: Array.isArray(record.purchaseStoreInTimes) && record.purchaseStoreInTimes.length
192
+ ? record.purchaseStoreInTimes.map(text).filter(Boolean).join('、') || '-'
193
+ : '-',
194
+ businessTimeText: text(record.businessTimeText),
195
+ handlerName: text(record.handlerName),
196
+ createTime: text(record.createTime),
197
+ lastHandlerName: text(record.lastHandlerName),
198
+ updateTime: text(record.updateTime),
199
+ };
200
+ }
201
+
202
+ /**
203
+ * 把采购单列表展开成报表行:有物料明细时每个明细一行,没有时单据一行。
204
+ * 明细行的单据级字段与单据行相同。
205
+ */
206
+ function flatten(records) {
207
+ const rows = [];
208
+ for (const record of records) {
209
+ const parent = decorate(record);
210
+ const details = Array.isArray(record.details) ? record.details : [];
211
+ if (!details.length) {
212
+ rows.push({ parent, detail: null, hasImage: false, ordersSn: parent.ordersSn });
213
+ continue;
214
+ }
215
+ for (const detail of details) {
216
+ const hasFabricWidth = Boolean(detail) && detail.fabricWidth != null && detail.fabricWidth !== '';
217
+ const fabricWidth = hasFabricWidth ? numeric(detail.fabricWidth) : '';
218
+ rows.push({
219
+ parent,
220
+ detail,
221
+ hasImage: Boolean(text(detail && detail.materialImgUrl)),
222
+ ordersSn: parent.ordersSn,
223
+ fabricWidth,
224
+ });
225
+ }
226
+ }
227
+ if (rows.length > MAX_EXCEL_ROWS) {
228
+ throw new Error(`展开后 ${rows.length} 行超过 Excel 单工作表上限,请缩小筛选范围`);
229
+ }
230
+ return rows;
231
+ }
232
+
233
+ /** 明细列取值:色号只在有幅宽时显示(与原脚本一致) */
234
+ function detailValue(row, column) {
235
+ const detail = row.detail || {};
236
+ switch (column.field) {
237
+ case 'materialImgUrl':
238
+ return '';
239
+ case 'specText':
240
+ return specText(detail.skuPropertyList);
241
+ case 'materialSkuSupplierColor':
242
+ return row.fabricWidth === '' ? '' : text(detail.materialSkuSupplierColor);
243
+ case 'fabricWidth':
244
+ return row.fabricWidth;
245
+ case 'purchaseUnitNumber':
246
+ case 'purchasePrice':
247
+ case 'purchaseUnitAmount':
248
+ return column.field === 'purchaseUnitNumber' ? numeric(detail.purchaseUnitNumber) : money(detail[column.field]);
249
+ default:
250
+ return text(detail[column.field]);
251
+ }
252
+ }
253
+
254
+ /** 一行的单元格值:明细列取明细行,其余取单据级字段 */
255
+ function cellValue(row, column) {
256
+ return DETAIL_FIELDS.has(column.field) && row.detail
257
+ ? detailValue(row, column)
258
+ : row.parent[column.field] != null ? row.parent[column.field] : '';
259
+ }
260
+
261
+ /**
262
+ * 生成物料采购导出 Excel。
263
+ * argv.from/to:业务日期区间,默认结束日今天、起始日往前三个自然月;
264
+ * argv.state:单据状态值(不传则接口默认查五种进行中状态);argv.searchKey:关键词;argv.out:输出目录。
265
+ */
266
+ async function generate(argv = {}) {
267
+ const today = new Date();
268
+ const to = argv.to || dateStr(today);
269
+ const from = argv.from || dateStr(monthsAgo(today, 3));
270
+ if (from > to) {
271
+ throw new Error('开始日期不能晚于结束日期');
272
+ }
273
+
274
+ const outDir = argv.out || process.cwd();
275
+ const filePath = path.resolve(outDir, `物料采购_${timestamp()}.xlsx`);
276
+ if (fs.existsSync(filePath)) {
277
+ throw new Error(`输出文件已存在:${filePath};如需替换请先删除旧文件或更换输出目录`);
278
+ }
279
+
280
+ const { list: records } = await fetchAll(
281
+ (page, limit) => call('GET', '/api/agent/v1/material-purchase/list', {
282
+ searchKey: argv.searchKey || argv.sn,
283
+ state: argv.state,
284
+ from,
285
+ to,
286
+ page,
287
+ limit,
288
+ })
289
+ );
290
+ if (!records.length) {
291
+ throw new Error('没有符合条件的物料采购数据');
292
+ }
293
+
294
+ const rows = flatten(records);
295
+
296
+ // 批量下载物料图;下载失败的地址对应单元格留空
297
+ const imageUrls = rows.map((row) => (row.detail ? text(row.detail.materialImgUrl) : ''));
298
+ const images = await downloadImages(imageUrls);
299
+
300
+ const workbook = createWorkbook();
301
+ const sheet = workbook.addWorksheet(SHEET_NAME, {
302
+ views: [{ state: 'frozen', xSplit: 2, ySplit: 2, topLeftCell: 'C3' }],
303
+ pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0 },
304
+ });
305
+
306
+ // 两级表头:单据级列纵向合并两行;明细区第一行合并显示「采购物料明细」
307
+ const detailStart = PARENT_COLUMNS.length + 1;
308
+ const detailEnd = detailStart + DETAIL_COLUMNS.length - 1;
309
+ const topRow = sheet.getRow(1);
310
+ const subRow = sheet.getRow(2);
311
+ COLUMNS.forEach((column, index) => {
312
+ const col = index + 1;
313
+ if (col >= detailStart && col <= detailEnd) {
314
+ topRow.getCell(col).value = col === detailStart ? '采购物料明细' : '';
315
+ subRow.getCell(col).value = column.header;
316
+ } else {
317
+ topRow.getCell(col).value = column.header;
318
+ subRow.getCell(col).value = '';
319
+ }
320
+ });
321
+ topRow.height = 30;
322
+ subRow.height = 30;
323
+ for (const row of [topRow, subRow]) {
324
+ for (let col = 1; col <= COLUMNS.length; col += 1) {
325
+ const cell = row.getCell(col);
326
+ cell.font = { bold: true };
327
+ cell.fill = HEADER_FILL;
328
+ cell.border = BORDER;
329
+ cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
330
+ }
331
+ }
332
+ sheet.mergeCells(1, detailStart, 1, detailEnd);
333
+ for (let col = 1; col < detailStart; col += 1) {
334
+ sheet.mergeCells(1, col, 2, col);
335
+ }
336
+ for (let col = detailEnd + 1; col <= COLUMNS.length; col += 1) {
337
+ sheet.mergeCells(1, col, 2, col);
338
+ }
339
+
340
+ // 数据行:有图片的行高 48,其余 24;长文本列左对齐,其余居中
341
+ const imageCol = DETAIL_COLUMNS.findIndex((column) => column.field === 'materialImgUrl') + PARENT_COLUMNS.length + 1;
342
+ rows.forEach((row, index) => {
343
+ const rowNumber = index + 3;
344
+ const added = sheet.addRow(COLUMNS.map((column) => cellValue(row, column)));
345
+ added.height = row.hasImage ? 48 : 24;
346
+ added.eachCell((cell, colNumber) => {
347
+ const column = COLUMNS[colNumber - 1];
348
+ cell.border = BORDER;
349
+ cell.alignment = {
350
+ vertical: 'middle',
351
+ horizontal: LEFT_FIELDS.has(column.field) ? 'left' : 'center',
352
+ wrapText: true,
353
+ };
354
+ });
355
+ const url = imageUrls[index];
356
+ if (url && images.has(url)) {
357
+ embedImage(workbook, sheet, images.get(url), rowNumber, imageCol, { width: 60, height: 60 });
358
+ }
359
+ });
360
+
361
+ // 同一采购单的连续行:非明细列纵向合并
362
+ let start = 0;
363
+ while (start < rows.length) {
364
+ let end = start;
365
+ while (end + 1 < rows.length && rows[start].ordersSn && rows[start].ordersSn === rows[end + 1].ordersSn) {
366
+ end += 1;
367
+ }
368
+ if (end > start) {
369
+ COLUMNS.forEach((column, index) => {
370
+ if (!DETAIL_FIELDS.has(column.field)) {
371
+ sheet.mergeCells(start + 3, index + 1, end + 3, index + 1);
372
+ }
373
+ });
374
+ }
375
+ start = end + 1;
376
+ }
377
+
378
+ COLUMNS.forEach((column, index) => {
379
+ sheet.getColumn(index + 1).width = column.width
380
+ || (column.kind === 'image' ? 16 : column.field === 'ordersSn' ? 20 : 12);
381
+ });
382
+
383
+ await saveWorkbook(workbook, filePath);
384
+
385
+ const skipped = imageUrls.filter((url) => url && !images.has(url)).length;
386
+ return {
387
+ filePaths: [filePath],
388
+ summary: `物料采购导出已生成:业务日期 ${from} ~ ${to},采购单 ${records.length} 条展开为 ${rows.length} 行 × ${COLUMNS.length} 列,嵌物料图 ${images.size} 张` +
389
+ (skipped ? `(${skipped} 张下载失败已跳过)` : ''),
390
+ };
391
+ }
392
+
393
+ module.exports = { generate };