@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.
- package/README.md +48 -2
- package/cli/api.js +114 -0
- package/cli/commands/data.js +112 -0
- package/cli/commands/docs.js +121 -0
- package/cli/commands/log.js +152 -0
- package/cli/commands/query.js +285 -0
- package/cli/commands/report.js +213 -0
- package/cli/commands/staff.js +505 -0
- package/cli/config.js +46 -0
- package/cli/datasets.js +541 -0
- package/cli/scm-agent.js +110 -0
- package/cli/skills.js +89 -0
- package/cli/table.js +43 -0
- package/docs/api.md +2067 -0
- package/docs/cli.md +252 -0
- package/package.json +27 -4
- package/reports/excel.js +107 -0
- package/reports/factoryEvaluation.js +128 -0
- package/reports/goodsBom.js +648 -0
- package/reports/goodsFirstOrder.js +409 -0
- package/reports/goodsInventory.js +220 -0
- package/reports/goodsSize.js +440 -0
- package/reports/image.js +216 -0
- package/reports/leakCheck.js +694 -0
- package/reports/materialPurchaseExport.js +393 -0
- package/reports/materialTransit.js +593 -0
- package/reports/processFollowup.js +312 -0
- package/reports/produceBoard.js +353 -0
- package/reports/produceCost.js +366 -0
- package/reports/produceCostDetail.js +622 -0
- package/reports/produceDaily.js +485 -0
- package/reports/storeInGoods.js +276 -0
- package/reports/supplierBalance.js +671 -0
- package/reports/ticketStatistics.js +289 -0
- package/reports/util.js +83 -0
- package/skills/bom/345/225/206/345/223/201/344/277/241/346/201/257/SKILL.md +40 -0
- package/skills/qinsilk-scm/SKILL.md +126 -0
- 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
- package/skills//345/225/206/345/223/201/345/260/272/345/257/270/344/277/241/346/201/257/SKILL.md +40 -0
- 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
- package/skills//345/267/245/345/216/202/350/257/204/344/274/260/346/212/245/350/241/250/SKILL.md +39 -0
- package/skills//346/210/220/345/223/201/345/205/245/345/272/223/346/212/245/350/241/250/SKILL.md +40 -0
- 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
- package/skills//346/274/217/346/226/231/346/243/200/346/265/213/346/227/245/346/212/245/SKILL.md +40 -0
- 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
- package/skills//347/211/251/346/226/231/351/207/207/350/264/255/345/257/274/345/207/272/SKILL.md +39 -0
- 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
- package/skills//347/224/237/344/272/247/346/210/220/346/234/254/346/212/245/350/241/250/SKILL.md +41 -0
- 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
- package/skills//347/224/237/344/272/247/346/227/245/346/212/245/SKILL.md +42 -0
- 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
- 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,409 @@
|
|
|
1
|
+
// 商品首单面料工序单价报表:全部商品写入同一个 Excel(37 列、双行表头)。
|
|
2
|
+
// 四段列组:商品信息(1~21 列)、首单面料(22~32 列,表头合并为「首单面料信息」)、
|
|
3
|
+
// 首单信息(33~35 列)、工序(36~37 列)。每个商品占 lcm(面料数, 工序数) 行:
|
|
4
|
+
// 商品列与首单信息列纵向合并整块,每条面料、每条工序各自纵向合并,
|
|
5
|
+
// 让面料行数与工序行数互相对齐(最小公倍数规则,与原脚本一致)。
|
|
6
|
+
// 数据流:GET /api/agent/v1/goods/rich-list 分页取商品(列表行自带首单面料),
|
|
7
|
+
// 再逐商品 GET /api/agent/v1/goods/full-detail 取工序与档案字段,商品主图嵌入 A 列。
|
|
8
|
+
// 列名、列序、合并规则、文件名与原 Python 脚本 export_goods_first_order_material_process.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 MAX_ROWS_PER_GOODS = 5000;
|
|
22
|
+
const MAX_TOTAL_ROWS = 200000;
|
|
23
|
+
|
|
24
|
+
const SHEET_NAME = '商品首单面料工序';
|
|
25
|
+
const HEADERS = [
|
|
26
|
+
'图片', '商品货号', '成本价', '尺码', '分类', '执行标准', '安全类别', '可用库存', '生产中', 'IE核价', '颜色', '商品名称', '供应商货号', '设计师', '跟单员', '年份', '月份', '季节', '成分', '性别', '状态',
|
|
27
|
+
'物料名称', '物料货号', '物料颜色', '物料规格', '默认供应商', '安数', '采购单价', '基础单价', '幅宽', '面料成分', '备注',
|
|
28
|
+
'新款/翻单', '首单日期', '创建人', '工序名称', '单价',
|
|
29
|
+
];
|
|
30
|
+
/** 四段列组的列号范围(1 开始) */
|
|
31
|
+
const GOODS_COLS = [];
|
|
32
|
+
for (let c = 1; c <= 21; c += 1) GOODS_COLS.push(c);
|
|
33
|
+
const MATERIAL_COLS = [];
|
|
34
|
+
for (let c = 22; c <= 32; c += 1) MATERIAL_COLS.push(c);
|
|
35
|
+
const FIRST_ORDER_COLS = [33, 34, 35];
|
|
36
|
+
const PROCESS_COLS = [36, 37];
|
|
37
|
+
const COLUMN_WIDTHS = [22, 16, 12, 16, 14, 14, 14, 12, 12, 12, 16, 22, 16, 14, 14, 10, 10, 12, 16, 10, 12, 18, 16, 16, 20, 18, 10, 12, 12, 10, 16, 20, 14, 14, 14, 18, 12];
|
|
38
|
+
/** 左对齐列:图片、商品名称、物料名称、物料规格、备注 */
|
|
39
|
+
const LEFT_COLS = new Set([1, 12, 22, 25, 32]);
|
|
40
|
+
|
|
41
|
+
const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9EAF7' } };
|
|
42
|
+
const THIN_BORDER = {
|
|
43
|
+
top: { style: 'thin', color: { argb: 'FFCCCCCC' } },
|
|
44
|
+
left: { style: 'thin', color: { argb: 'FFCCCCCC' } },
|
|
45
|
+
bottom: { style: 'thin', color: { argb: 'FFCCCCCC' } },
|
|
46
|
+
right: { style: 'thin', color: { argb: 'FFCCCCCC' } },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function text(value) {
|
|
50
|
+
return value === null || value === undefined ? '' : String(value).trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 取第一个非空值(null、undefined、空字符串视为空),保留原始类型 */
|
|
54
|
+
function firstValue(...values) {
|
|
55
|
+
for (const value of values) {
|
|
56
|
+
if (value !== null && value !== undefined && value !== '') {
|
|
57
|
+
return value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pad2(n) {
|
|
64
|
+
return String(n).padStart(2, '0');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 时间戳毫秒或日期字符串转「yyyy-MM-dd HH:mm:ss」,解析失败返回空字符串 */
|
|
68
|
+
function formatDateTime(value) {
|
|
69
|
+
if (value === null || value === undefined || value === '') {
|
|
70
|
+
return '';
|
|
71
|
+
}
|
|
72
|
+
let date = null;
|
|
73
|
+
if (typeof value === 'number') {
|
|
74
|
+
date = new Date(value);
|
|
75
|
+
} else {
|
|
76
|
+
const s = String(value).trim();
|
|
77
|
+
if (/^\d+(\.\d+)?$/.test(s)) {
|
|
78
|
+
date = new Date(Number(s));
|
|
79
|
+
} else if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
|
80
|
+
date = new Date(s);
|
|
81
|
+
}
|
|
82
|
+
if (!date || Number.isNaN(date.getTime())) {
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (Number.isNaN(date.getTime())) {
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
return (
|
|
90
|
+
`${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ` +
|
|
91
|
+
`${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function gcd(a, b) {
|
|
96
|
+
while (b) {
|
|
97
|
+
[a, b] = [b, a % b];
|
|
98
|
+
}
|
|
99
|
+
return a;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function lcm(a, b) {
|
|
103
|
+
return (a / gcd(a, b)) * b;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 单个商品的行数 = max(1, 面料数) 与 max(1, 工序数) 的最小公倍数。
|
|
108
|
+
* 面料或工序为空时按 1 行占位(该段输出空值)。
|
|
109
|
+
*/
|
|
110
|
+
function lcmLayout(materialCount, processCount) {
|
|
111
|
+
const m = Math.max(1, materialCount);
|
|
112
|
+
const p = Math.max(1, processCount);
|
|
113
|
+
const rows = lcm(m, p);
|
|
114
|
+
if (rows > MAX_ROWS_PER_GOODS) {
|
|
115
|
+
throw new Error(`单商品面料 ${materialCount} 条、工序 ${processCount} 条,最小公倍数 ${rows} 超出上限`);
|
|
116
|
+
}
|
|
117
|
+
return rows;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 首单面料 11 列(rich-list 列表行内的 firstProduceOrderMaterials) */
|
|
121
|
+
function materialValues(material) {
|
|
122
|
+
return [
|
|
123
|
+
firstValue(material.materialName),
|
|
124
|
+
firstValue(material.materialSn),
|
|
125
|
+
firstValue(material.bomSupplierColor, material.materialColor),
|
|
126
|
+
firstValue(material.propertyText),
|
|
127
|
+
firstValue(material.supplierName),
|
|
128
|
+
firstValue(material.materialQunce),
|
|
129
|
+
firstValue(material.skuPurchasePrice, material.purchasePrice),
|
|
130
|
+
firstValue(material.skuPrice, material.price),
|
|
131
|
+
firstValue(material.materialFabricWidth),
|
|
132
|
+
firstValue(material.materialComposition),
|
|
133
|
+
firstValue(material.remark),
|
|
134
|
+
];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 商品信息 21 列(详情字段优先,列表行字段回退) */
|
|
138
|
+
function goodsValues(summary, detail) {
|
|
139
|
+
const colors = (detail.goodsColors || []).map((c) => text(c.colorName)).filter(Boolean);
|
|
140
|
+
const sizes = (detail.goodsSizes || []).map((s) => text(s.sizeName)).filter(Boolean);
|
|
141
|
+
return [
|
|
142
|
+
firstValue(detail.goodsImgUrl, summary.goodsImgUrl),
|
|
143
|
+
firstValue(detail.goodsSn, summary.goodsSn),
|
|
144
|
+
firstValue(detail.costPrice, summary.costPrice),
|
|
145
|
+
sizes.join(','),
|
|
146
|
+
firstValue(detail.categoryName),
|
|
147
|
+
firstValue(detail.executeStandardName),
|
|
148
|
+
firstValue(detail.securityTypeName),
|
|
149
|
+
firstValue(detail.availableNumber, summary.availableNumber),
|
|
150
|
+
firstValue(detail.produceNumber, summary.produceNumber),
|
|
151
|
+
firstValue(detail.iePrice, summary.iePrice),
|
|
152
|
+
colors.join(','),
|
|
153
|
+
firstValue(detail.name, summary.name),
|
|
154
|
+
firstValue(detail.supplierSn, summary.supplierSn),
|
|
155
|
+
firstValue(detail.designerName),
|
|
156
|
+
firstValue(detail.followerName),
|
|
157
|
+
firstValue(detail.year, summary.year),
|
|
158
|
+
firstValue(detail.month, summary.month),
|
|
159
|
+
firstValue(detail.seasonName),
|
|
160
|
+
firstValue(detail.composition, summary.composition),
|
|
161
|
+
// 性别:DTO 数字枚举转文字
|
|
162
|
+
firstValue(({ 0: '通款', 1: '男款', 2: '女款', 3: '童装' })[detail.sex], ({ 0: '通款', 1: '男款', 2: '女款', 3: '童装' })[summary.sex]),
|
|
163
|
+
// 状态:DTO isOnSale 1/0 转「启用/停用」
|
|
164
|
+
firstValue(detail.isOnSale === 1 ? '启用' : detail.isOnSale === 0 ? '停用' : '', summary.isOnSale === 1 ? '启用' : summary.isOnSale === 0 ? '停用' : ''),
|
|
165
|
+
];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 单个商品的全部报表数据:商品列、首单面料行、首单信息、工序行与主图 */
|
|
169
|
+
async function prepareGoods(summary, detail) {
|
|
170
|
+
const materials = (summary.firstProduceOrderMaterials || []).map(materialValues);
|
|
171
|
+
// 工序按显示顺序升序排列,未设置顺序的排在最后
|
|
172
|
+
const processes = [...(detail.goodsWorkProcessList || [])].sort((a, b) => {
|
|
173
|
+
const an = a.showOrder === null || a.showOrder === undefined;
|
|
174
|
+
const bn = b.showOrder === null || b.showOrder === undefined;
|
|
175
|
+
if (an !== bn) {
|
|
176
|
+
return an ? 1 : -1;
|
|
177
|
+
}
|
|
178
|
+
if (an) {
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
return (a.showOrder || 0) - (b.showOrder || 0);
|
|
182
|
+
}).map((p) => [firstValue(p.workProcessName), firstValue(p.price)]);
|
|
183
|
+
|
|
184
|
+
const order = [
|
|
185
|
+
firstValue(summary.reorderOrderInfo, '新款'),
|
|
186
|
+
formatDateTime(summary.firstOrderTime),
|
|
187
|
+
text(summary.handlerName),
|
|
188
|
+
];
|
|
189
|
+
const goods = goodsValues(summary, detail);
|
|
190
|
+
// 图片列不写文字值,改用嵌图
|
|
191
|
+
goods[0] = '';
|
|
192
|
+
const image = await downloadImage(text(firstValue(detail.goodsImgUrl, summary.goodsImgUrl)));
|
|
193
|
+
return {
|
|
194
|
+
goods,
|
|
195
|
+
image,
|
|
196
|
+
materials,
|
|
197
|
+
order,
|
|
198
|
+
processes,
|
|
199
|
+
hasFirstOrderInfo: summary.firstOrderTime !== null && summary.firstOrderTime !== undefined && summary.firstOrderTime !== '',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 表头样式:加粗、浅蓝底、细边框、居中换行 */
|
|
204
|
+
function styleHeaderCell(cell) {
|
|
205
|
+
cell.border = THIN_BORDER;
|
|
206
|
+
cell.fill = HEADER_FILL;
|
|
207
|
+
cell.font = { bold: true };
|
|
208
|
+
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 写整份 Excel:双行表头 + 每商品一个 lcm 行块。
|
|
213
|
+
* 表头合并:首单面料组第一行横向合并(V1:AF1)显示「首单面料信息」;
|
|
214
|
+
* 商品列、首单信息列、工序列各自纵向合并两行表头。
|
|
215
|
+
*/
|
|
216
|
+
function writeXlsx(filePath, items) {
|
|
217
|
+
const workbook = createWorkbook();
|
|
218
|
+
const sheet = workbook.addWorksheet(SHEET_NAME, { views: [{ state: 'frozen', xSplit: 2, ySplit: 2 }] });
|
|
219
|
+
|
|
220
|
+
const first = HEADERS.map((title, index) => {
|
|
221
|
+
const c = index + 1;
|
|
222
|
+
if (c === 22) {
|
|
223
|
+
return '首单面料信息';
|
|
224
|
+
}
|
|
225
|
+
return c < 22 || c >= 33 ? title : '';
|
|
226
|
+
});
|
|
227
|
+
const second = HEADERS.map((title, index) => {
|
|
228
|
+
const c = index + 1;
|
|
229
|
+
return c >= 22 && c <= 32 ? title : '';
|
|
230
|
+
});
|
|
231
|
+
const row1 = sheet.getRow(1);
|
|
232
|
+
row1.values = first;
|
|
233
|
+
row1.height = 24;
|
|
234
|
+
row1.eachCell({ includeEmpty: true }, (cell) => styleHeaderCell(cell));
|
|
235
|
+
const row2 = sheet.getRow(2);
|
|
236
|
+
row2.values = second;
|
|
237
|
+
row2.height = 36;
|
|
238
|
+
row2.eachCell({ includeEmpty: true }, (cell) => styleHeaderCell(cell));
|
|
239
|
+
|
|
240
|
+
// 全部合并区域先收集、写完行值后统一执行:合并后的从属单元格不能再写值
|
|
241
|
+
const merges = [[1, 22, 1, 32]];
|
|
242
|
+
for (const c of [...GOODS_COLS, ...FIRST_ORDER_COLS, ...PROCESS_COLS]) {
|
|
243
|
+
merges.push([1, c, 2, c]);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let rowNo = 3;
|
|
247
|
+
let totalRows = 0;
|
|
248
|
+
for (const item of items) {
|
|
249
|
+
const n = lcmLayout(item.materials.length, item.processes.length);
|
|
250
|
+
totalRows += n;
|
|
251
|
+
if (totalRows > MAX_TOTAL_ROWS) {
|
|
252
|
+
throw new Error(`导出明细行数超过上限 ${MAX_TOTAL_ROWS}`);
|
|
253
|
+
}
|
|
254
|
+
const start = rowNo;
|
|
255
|
+
const end = rowNo + n - 1;
|
|
256
|
+
const materialRepeat = n / Math.max(1, item.materials.length);
|
|
257
|
+
const processRepeat = n / Math.max(1, item.processes.length);
|
|
258
|
+
// 商品列与首单信息列只写块首行的值,其余行留空后纵向合并
|
|
259
|
+
if (start !== end) {
|
|
260
|
+
for (const c of [...GOODS_COLS, ...FIRST_ORDER_COLS]) {
|
|
261
|
+
merges.push([start, c, end, c]);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// 每条面料占 materialRepeat 行、每条工序占 processRepeat 行,占多行时纵向合并
|
|
265
|
+
item.materials.forEach((_, index) => {
|
|
266
|
+
const a = start + index * materialRepeat;
|
|
267
|
+
const b = start + (index + 1) * materialRepeat - 1;
|
|
268
|
+
if (a !== b) {
|
|
269
|
+
for (const c of MATERIAL_COLS) {
|
|
270
|
+
merges.push([a, c, b, c]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
item.processes.forEach((_, index) => {
|
|
275
|
+
const a = start + index * processRepeat;
|
|
276
|
+
const b = start + (index + 1) * processRepeat - 1;
|
|
277
|
+
if (a !== b) {
|
|
278
|
+
for (const c of PROCESS_COLS) {
|
|
279
|
+
merges.push([a, c, b, c]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
for (let offset = 0; offset < n; offset += 1) {
|
|
284
|
+
const values = new Array(HEADERS.length).fill('');
|
|
285
|
+
if (offset === 0) {
|
|
286
|
+
item.goods.forEach((v, i) => {
|
|
287
|
+
values[i] = v;
|
|
288
|
+
});
|
|
289
|
+
values[32] = item.order[0];
|
|
290
|
+
values[33] = item.order[1];
|
|
291
|
+
values[34] = item.order[2];
|
|
292
|
+
}
|
|
293
|
+
// 值只写在各合并区间的第一行,保证合并后不丢数据
|
|
294
|
+
if (offset % materialRepeat === 0) {
|
|
295
|
+
const material = item.materials[offset / materialRepeat];
|
|
296
|
+
if (material) {
|
|
297
|
+
material.forEach((v, i) => {
|
|
298
|
+
values[21 + i] = v;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (offset % processRepeat === 0) {
|
|
303
|
+
const process = item.processes[offset / processRepeat];
|
|
304
|
+
if (process) {
|
|
305
|
+
values[35] = process[0];
|
|
306
|
+
values[36] = process[1];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const row = sheet.getRow(rowNo);
|
|
310
|
+
row.values = values;
|
|
311
|
+
row.height = 30;
|
|
312
|
+
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
|
|
313
|
+
cell.border = THIN_BORDER;
|
|
314
|
+
cell.alignment = { vertical: 'middle', horizontal: LEFT_COLS.has(colNumber) ? 'left' : 'center', wrapText: true };
|
|
315
|
+
});
|
|
316
|
+
rowNo += 1;
|
|
317
|
+
}
|
|
318
|
+
// 商品主图嵌在 A 列,纵向占满该商品的整块行(约 40 像素/行)
|
|
319
|
+
if (item.image) {
|
|
320
|
+
embedImage(workbook, sheet, item.image, start, 1, { width: 150, height: Math.max(1, 40 * n - 8) });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
for (const [r1, c1, r2, c2] of merges) {
|
|
325
|
+
sheet.mergeCells(r1, c1, r2, c2);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
COLUMN_WIDTHS.forEach((w, i) => {
|
|
329
|
+
sheet.getColumn(i + 1).width = w;
|
|
330
|
+
});
|
|
331
|
+
// 打印方向横向并按宽度缩放到一页(与原脚本 pageSetup 一致)
|
|
332
|
+
sheet.pageSetup = { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0 };
|
|
333
|
+
return saveWorkbook(workbook, filePath).then(() => totalRows);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 报表入口:分页取商品列表(可选按款号过滤),逐商品取详情后写入单个 Excel。
|
|
338
|
+
* argv:{ sn 款号(可选), limit 最多导出商品数(默认 100), out 输出目录 }。
|
|
339
|
+
*/
|
|
340
|
+
async function generate(argv = {}) {
|
|
341
|
+
const limit = Number(argv.limit) > 0 ? Number(argv.limit) : 100;
|
|
342
|
+
const outDir = argv.out ? path.resolve(String(argv.out)) : process.cwd();
|
|
343
|
+
const sn = text(argv.sn);
|
|
344
|
+
const throttle = createThrottle(REQUEST_INTERVAL_MS);
|
|
345
|
+
|
|
346
|
+
const { list } = await fetchAll(
|
|
347
|
+
(page) => throttle(() => call('GET', '/api/agent/v1/goods/rich-list', {
|
|
348
|
+
sn: sn || undefined,
|
|
349
|
+
// 原报表导出全部商品(含停用),isOnSale 传 -1 让服务端不过滤上下架状态
|
|
350
|
+
isOnSale: -1,
|
|
351
|
+
skuIsDisable: 0,
|
|
352
|
+
page,
|
|
353
|
+
limit: PAGE_LIMIT,
|
|
354
|
+
})),
|
|
355
|
+
{ limit: PAGE_LIMIT, maxPages: Math.max(1, Math.ceil(limit / PAGE_LIMIT)) }
|
|
356
|
+
);
|
|
357
|
+
const selected = list.slice(0, limit);
|
|
358
|
+
if (!selected.length) {
|
|
359
|
+
throw new Error('没有符合条件的商品');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const issues = {};
|
|
363
|
+
const issue = (key) => {
|
|
364
|
+
issues[key] = (issues[key] || 0) + 1;
|
|
365
|
+
};
|
|
366
|
+
const prepared = [];
|
|
367
|
+
for (let i = 0; i < selected.length; i += 1) {
|
|
368
|
+
const summary = selected[i];
|
|
369
|
+
try {
|
|
370
|
+
const detail = await throttle(() => call('GET', '/api/agent/v1/goods/full-detail', { id: summary.id }));
|
|
371
|
+
const item = await prepareGoods(summary, detail);
|
|
372
|
+
prepared.push(item);
|
|
373
|
+
if (!item.hasFirstOrderInfo) {
|
|
374
|
+
issue('无首单日期');
|
|
375
|
+
}
|
|
376
|
+
if (!item.materials.length) {
|
|
377
|
+
issue('无首单面料');
|
|
378
|
+
}
|
|
379
|
+
if (!item.processes.length) {
|
|
380
|
+
issue('无商品工序');
|
|
381
|
+
}
|
|
382
|
+
} catch (e) {
|
|
383
|
+
issue('跳过商品');
|
|
384
|
+
process.stdout.write(`跳过商品 ${text(summary.goodsSn) || summary.id}:${e.message}\n`);
|
|
385
|
+
}
|
|
386
|
+
if ((i + 1) % 10 === 0) {
|
|
387
|
+
process.stdout.write(`已处理 ${i + 1}/${selected.length} 个商品\n`);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (!prepared.length) {
|
|
391
|
+
throw new Error('没有成功处理的商品,未生成文件');
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 文件名「基础名-时间戳.xlsx」;已存在时追加序号,不覆盖既有文件
|
|
395
|
+
let filePath = path.join(outDir, `商品首单面料工序单价-${timestamp()}.xlsx`);
|
|
396
|
+
let seq = 2;
|
|
397
|
+
while (fs.existsSync(filePath)) {
|
|
398
|
+
filePath = path.join(outDir, `商品首单面料工序单价-${timestamp()}-${seq}.xlsx`);
|
|
399
|
+
seq += 1;
|
|
400
|
+
}
|
|
401
|
+
const detailRows = await writeXlsx(filePath, prepared);
|
|
402
|
+
|
|
403
|
+
const issueText = Object.entries(issues).map(([k, v]) => `${k} ${v}`).join('、');
|
|
404
|
+
const summaryText = `商品首单面料工序单价导出完成:商品 ${prepared.length}/${selected.length} 个,明细行 ${detailRows} 行` +
|
|
405
|
+
(issueText ? `;${issueText}` : '') + `;文件:${filePath}`;
|
|
406
|
+
return { filePaths: [filePath], summary: summaryText };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
module.exports = { generate };
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// 服装成品盘存表:全量拉取商品单品库存,按「款号 → 颜色」分组、尺码摊开为动态列。
|
|
2
|
+
// 列布局、合并单元格、文件名与原 Python 报表脚本保持一致。
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { call } = require('../cli/api');
|
|
6
|
+
const { dateStr, fetchAll } = require('./util');
|
|
7
|
+
const { createWorkbook, saveWorkbook } = require('./excel');
|
|
8
|
+
|
|
9
|
+
// 常见服装尺码的固定展示顺序;不在其中的尺码按名称排序放在这些之后
|
|
10
|
+
const COMMON_SIZES = ['XXS', 'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL', '4XL', '5XL'];
|
|
11
|
+
|
|
12
|
+
// 单元格边框:黑色细线,与原脚本的盘存表边框一致
|
|
13
|
+
const BORDER = {
|
|
14
|
+
top: { style: 'thin', color: { argb: 'FF000000' } },
|
|
15
|
+
left: { style: 'thin', color: { argb: 'FF000000' } },
|
|
16
|
+
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
|
17
|
+
right: { style: 'thin', color: { argb: 'FF000000' } },
|
|
18
|
+
};
|
|
19
|
+
// 表头底色:浅蓝,与原脚本一致
|
|
20
|
+
const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9EAF7' } };
|
|
21
|
+
|
|
22
|
+
/** 空字符串归一化:空值换成指定兜底文案 */
|
|
23
|
+
function normalizedText(value, fallback) {
|
|
24
|
+
const text = String(value == null ? '' : value).trim();
|
|
25
|
+
return text || fallback;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 尺码排序键:常见尺码按固定顺序,其余按名称升序排在后面 */
|
|
29
|
+
function sizeOrder(value) {
|
|
30
|
+
const text = normalizedText(value, '未设尺码');
|
|
31
|
+
const index = COMMON_SIZES.indexOf(text);
|
|
32
|
+
return index >= 0 ? { common: 0, index } : { common: 1, name: text };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 把单品列表汇总为「款号 → 颜色 → 尺码 → 库存」三层结构。
|
|
37
|
+
* 每个款号保留一个商品名称;同一款号同一颜色的相同尺码库存累加。
|
|
38
|
+
*/
|
|
39
|
+
function buildRows(skus) {
|
|
40
|
+
const grouped = new Map();
|
|
41
|
+
const names = new Map();
|
|
42
|
+
for (const sku of skus) {
|
|
43
|
+
const goodsName = normalizedText(sku.goodsName, '');
|
|
44
|
+
const goodsSn = normalizedText(sku.goodsSn, goodsName || '未设置款号');
|
|
45
|
+
const color = normalizedText(sku.colorName, '未设颜色');
|
|
46
|
+
const size = normalizedText(sku.sizeName, '未设尺码');
|
|
47
|
+
const stock = sku.storedNumber == null ? 0 : Number(sku.storedNumber);
|
|
48
|
+
if (!grouped.has(goodsSn)) {
|
|
49
|
+
grouped.set(goodsSn, new Map());
|
|
50
|
+
}
|
|
51
|
+
const colors = grouped.get(goodsSn);
|
|
52
|
+
if (!colors.has(color)) {
|
|
53
|
+
colors.set(color, new Map());
|
|
54
|
+
}
|
|
55
|
+
const stocks = colors.get(color);
|
|
56
|
+
stocks.set(size, (stocks.get(size) || 0) + stock);
|
|
57
|
+
names.set(goodsSn, goodsName);
|
|
58
|
+
}
|
|
59
|
+
// 收集全部出现过的尺码,按尺码排序键排列成动态列
|
|
60
|
+
const sizes = new Set();
|
|
61
|
+
for (const colors of grouped.values()) {
|
|
62
|
+
for (const stocks of colors.values()) {
|
|
63
|
+
for (const size of stocks.keys()) {
|
|
64
|
+
sizes.add(size);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const sizeList = [...sizes].sort((a, b) => {
|
|
69
|
+
const ka = sizeOrder(a);
|
|
70
|
+
const kb = sizeOrder(b);
|
|
71
|
+
if (ka.common !== kb.common) {
|
|
72
|
+
return ka.common - kb.common;
|
|
73
|
+
}
|
|
74
|
+
return ka.common === 0 ? ka.index - kb.index : ka.name.localeCompare(kb.name);
|
|
75
|
+
});
|
|
76
|
+
// 行顺序:款号升序,同款号内颜色名称升序
|
|
77
|
+
const rows = [];
|
|
78
|
+
for (const goodsSn of [...grouped.keys()].sort()) {
|
|
79
|
+
for (const color of [...grouped.get(goodsSn).keys()].sort()) {
|
|
80
|
+
const stocks = grouped.get(goodsSn).get(color);
|
|
81
|
+
let rowTotal = 0;
|
|
82
|
+
for (const value of stocks.values()) {
|
|
83
|
+
rowTotal += value;
|
|
84
|
+
}
|
|
85
|
+
rows.push({ goodsSn, goodsName: names.get(goodsSn), color, stocks, rowTotal });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { sizes: sizeList, rows };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 给一个单元格设置居中、换行与细边框 */
|
|
92
|
+
function styleDataCell(cell) {
|
|
93
|
+
cell.border = BORDER;
|
|
94
|
+
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 生成服装成品盘存表 Excel。
|
|
99
|
+
* argv.storehouse:仓库 id,不传则全部仓库;argv.out:输出目录,默认当前目录。
|
|
100
|
+
*/
|
|
101
|
+
async function generate(argv = {}) {
|
|
102
|
+
const outDir = argv.out || process.cwd();
|
|
103
|
+
const filePath = path.resolve(outDir, `服装成品盘存表-${dateStr()}.xlsx`);
|
|
104
|
+
if (fs.existsSync(filePath)) {
|
|
105
|
+
throw new Error(`输出文件已存在:${filePath};如需替换请先删除旧文件或更换输出目录`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 分页拉取全部启用单品;skuIsDisable 由服务端固定为 0(只查启用单品)
|
|
109
|
+
const { list: skus } = await fetchAll(
|
|
110
|
+
(page, limit) => call('GET', '/api/agent/v1/inventory/sku-list', {
|
|
111
|
+
storehouseId: argv.storehouse,
|
|
112
|
+
page,
|
|
113
|
+
limit,
|
|
114
|
+
})
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const { sizes, rows } = buildRows(skus);
|
|
118
|
+
|
|
119
|
+
// 总列数 = 序号、款号、颜色、商品名称 4 列 + 尺码动态列 + 合计、备注 2 列
|
|
120
|
+
const lastCol = 4 + sizes.length + 2;
|
|
121
|
+
const workbook = createWorkbook();
|
|
122
|
+
const sheet = workbook.addWorksheet('成品盘存表', {
|
|
123
|
+
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0 },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// 第 1 行:表标题,整行合并
|
|
127
|
+
const titleRow = sheet.addRow(['服装成品盘存表']);
|
|
128
|
+
titleRow.height = 38;
|
|
129
|
+
const titleCell = titleRow.getCell(1);
|
|
130
|
+
titleCell.font = { bold: true, size: 18 };
|
|
131
|
+
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
|
|
132
|
+
|
|
133
|
+
// 第 2 行:盘存日期与部门、仓库说明,分三段合并
|
|
134
|
+
const metaRow = sheet.addRow(['盘存日期:', null, dateStr(), null, null, `部门:${argv.department || ''} 仓库:${argv.storehouse ? `指定仓库 ${argv.storehouse}` : '全部仓库'}`]);
|
|
135
|
+
metaRow.height = 20;
|
|
136
|
+
for (let i = 1; i <= lastCol; i += 1) {
|
|
137
|
+
styleDataCell(metaRow.getCell(i));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 第 3 行:表头,尺码列按数据动态生成
|
|
141
|
+
const headers = ['序号', '款号', '颜色', '商品名称', ...sizes, '合计', '备注'];
|
|
142
|
+
const headerRow = sheet.addRow(headers);
|
|
143
|
+
headerRow.height = 28;
|
|
144
|
+
headerRow.eachCell((cell) => {
|
|
145
|
+
cell.font = { bold: true };
|
|
146
|
+
cell.fill = HEADER_FILL;
|
|
147
|
+
styleDataCell(cell);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// 数据行:同一款号的多行合并序号与款号两列,序号只在每组第一行写
|
|
151
|
+
let rowNumber = 4;
|
|
152
|
+
let seq = 0;
|
|
153
|
+
let totalStock = 0;
|
|
154
|
+
let index = 0;
|
|
155
|
+
while (index < rows.length) {
|
|
156
|
+
const goodsSn = rows[index].goodsSn;
|
|
157
|
+
let end = index;
|
|
158
|
+
while (end + 1 < rows.length && rows[end + 1].goodsSn === goodsSn) {
|
|
159
|
+
end += 1;
|
|
160
|
+
}
|
|
161
|
+
const firstRow = rowNumber;
|
|
162
|
+
const lastRow = rowNumber + (end - index);
|
|
163
|
+
seq += 1;
|
|
164
|
+
for (let i = index; i <= end; i += 1) {
|
|
165
|
+
const item = rows[i];
|
|
166
|
+
const values = [
|
|
167
|
+
i === index ? seq : '',
|
|
168
|
+
i === index ? item.goodsSn : '',
|
|
169
|
+
item.color,
|
|
170
|
+
item.goodsName,
|
|
171
|
+
...sizes.map((size) => (item.stocks.has(size) ? item.stocks.get(size) : '')),
|
|
172
|
+
item.rowTotal,
|
|
173
|
+
'',
|
|
174
|
+
];
|
|
175
|
+
const added = sheet.addRow(values);
|
|
176
|
+
added.height = 22;
|
|
177
|
+
added.eachCell(styleDataCell);
|
|
178
|
+
totalStock += item.rowTotal;
|
|
179
|
+
rowNumber += 1;
|
|
180
|
+
}
|
|
181
|
+
if (lastRow > firstRow) {
|
|
182
|
+
sheet.mergeCells(firstRow, 1, lastRow, 1);
|
|
183
|
+
sheet.mergeCells(firstRow, 2, lastRow, 2);
|
|
184
|
+
}
|
|
185
|
+
index = end + 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 末行:总计行,从序号列合并到倒数第三列,合计列写全部库存总和
|
|
189
|
+
const totalRow = sheet.addRow(['总计']);
|
|
190
|
+
totalRow.height = 24;
|
|
191
|
+
for (let i = 1; i <= lastCol; i += 1) {
|
|
192
|
+
styleDataCell(totalRow.getCell(i));
|
|
193
|
+
totalRow.getCell(i).font = { bold: true };
|
|
194
|
+
}
|
|
195
|
+
totalRow.getCell(lastCol - 1).value = totalStock;
|
|
196
|
+
sheet.mergeCells(rowNumber, 1, rowNumber, lastCol - 2);
|
|
197
|
+
|
|
198
|
+
// 列宽:固定四列 + 尺码列 + 合计、备注
|
|
199
|
+
const widths = [8, 18, 14, 32, ...sizes.map(() => 10), 12, 20];
|
|
200
|
+
widths.forEach((width, i) => {
|
|
201
|
+
sheet.getColumn(i + 1).width = width;
|
|
202
|
+
});
|
|
203
|
+
// 标题行与说明行的合并范围;仓库说明段只有一列时无需合并
|
|
204
|
+
sheet.mergeCells(1, 1, 1, lastCol);
|
|
205
|
+
sheet.mergeCells(2, 1, 2, 2);
|
|
206
|
+
sheet.mergeCells(2, 3, 2, 5);
|
|
207
|
+
if (lastCol > 6) {
|
|
208
|
+
sheet.mergeCells(2, 6, 2, lastCol);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await saveWorkbook(workbook, filePath);
|
|
212
|
+
|
|
213
|
+
const goodsCount = new Set(rows.map((row) => row.goodsSn)).size;
|
|
214
|
+
return {
|
|
215
|
+
filePaths: [filePath],
|
|
216
|
+
summary: `服装成品盘存表已生成:获取 ${skus.length} 个单品,${goodsCount} 个款号、${rows.length} 个颜色行,当前库存总计 ${totalStock}`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { generate };
|