@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,622 @@
|
|
|
1
|
+
// 生产成本报表明细:按生产成本报表的每一行生成一个 Excel 文件。
|
|
2
|
+
// 每个文件一个工作表:上半部分是外层报表行(款式/生产单/实际成本/预估成本,两级表头),
|
|
3
|
+
// 下半部分依次排布五类成本明细分区(物料、委外加工、工序外发、工人计件、其它费用),
|
|
4
|
+
// 每个分区有自己的两级表头,底部是绿底合计行;物料分区额外嵌物料图片。
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { call } = require('../cli/api');
|
|
8
|
+
const { timestamp, fetchAll } = require('./util');
|
|
9
|
+
const { createWorkbook, saveWorkbook, embedImage } = require('./excel');
|
|
10
|
+
const { downloadImages } = require('./image');
|
|
11
|
+
|
|
12
|
+
const SHEET_NAME = '生产成本报表明细';
|
|
13
|
+
// 计算规则固定为接口默认的 1(按默认逻辑计算);原脚本本地 config.json 的默认值不移植
|
|
14
|
+
const CAL_WAY = 1;
|
|
15
|
+
const CAL_WAY_NAME = '按默认逻辑计算';
|
|
16
|
+
// 状态文案(与页面一致)
|
|
17
|
+
const STATE_NAMES = {
|
|
18
|
+
1: '草稿', 2: '生产中', 3: '生产完成', 4: '待生产', 9: '撤销',
|
|
19
|
+
11: '待接单', 12: '拒绝接单', 13: '待审批', 14: '审批拒绝',
|
|
20
|
+
};
|
|
21
|
+
const TYPE_NAMES = { 1: '自主生产', 2: '单工序外发', 3: '委外加工(全工序外发)', 4: '成品采购' };
|
|
22
|
+
// 单个明细分区的行数上限(与原脚本一致,防止异常数据撑爆文件)
|
|
23
|
+
const MAX_DETAIL_ROWS = 10000;
|
|
24
|
+
// 一次导出最多生成的文件数(与原脚本一致)
|
|
25
|
+
const MAX_FILES = 100;
|
|
26
|
+
// 金额格式:明细金额固定两位小数(与原脚本一致,不读企业配置)
|
|
27
|
+
const MONEY_FORMAT = '¥#,##0.00;-¥#,##0.00';
|
|
28
|
+
|
|
29
|
+
// 外层报表行:与生产成本报表页面默认可见列一致(含状态列,不含差异列)
|
|
30
|
+
const OUTER_GROUPS = [
|
|
31
|
+
{
|
|
32
|
+
name: '款式信息',
|
|
33
|
+
columns: [
|
|
34
|
+
{ header: '商品', field: 'goodsName', kind: 'text' },
|
|
35
|
+
{ header: '款号', field: 'designSn', kind: 'text' },
|
|
36
|
+
{ header: '图片', field: 'imgUrl', kind: 'image' },
|
|
37
|
+
{ header: '大货号', field: 'goodsSn', kind: 'text' },
|
|
38
|
+
{ header: '波段', field: 'rangesName', kind: 'text' },
|
|
39
|
+
{ header: '设计师', field: 'designerName', kind: 'text' },
|
|
40
|
+
{ header: '打版师', field: 'patternMakerName', kind: 'text' },
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: '生产单信息',
|
|
45
|
+
columns: [
|
|
46
|
+
{ header: '状态', field: 'state', kind: 'state' },
|
|
47
|
+
{ header: '单号', field: 'ordersSn', kind: 'text' },
|
|
48
|
+
{ header: '订单类型', field: 'orderTypeName', kind: 'text' },
|
|
49
|
+
{ header: '标签', field: 'tags', kind: 'tags' },
|
|
50
|
+
{ header: '生产类型', field: 'type', kind: 'type' },
|
|
51
|
+
{ header: '预计交货日期', field: 'deliveryTime', kind: 'date' },
|
|
52
|
+
{ header: '跟单员', field: 'followerName', kind: 'text' },
|
|
53
|
+
{ header: '下单数', field: 'sumNumber', kind: 'number' },
|
|
54
|
+
{ header: '裁床数', field: 'sumCutBedNumber', kind: 'number' },
|
|
55
|
+
{ header: '已入库数', field: 'sumStoreInNumber', kind: 'number' },
|
|
56
|
+
{ header: '未完成', field: 'sumNoFinishNumber', kind: 'number' },
|
|
57
|
+
{ header: '未出数', field: 'sumNoOutNumber', kind: 'number' },
|
|
58
|
+
{ header: '未入库数', field: 'sumNoStoreInNumber', kind: 'number' },
|
|
59
|
+
{ header: '业务日期', field: 'businessTime', kind: 'date' },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: '实际成本',
|
|
64
|
+
columns: [
|
|
65
|
+
{ header: '物料', field: 'actualMaterialCost', kind: 'money' },
|
|
66
|
+
{ header: '委外加工', field: 'actualProduceOrderProcessCost', kind: 'money' },
|
|
67
|
+
{ header: '工序外发', field: 'actualOutWardOrderCost', kind: 'money' },
|
|
68
|
+
{ header: '工人计件', field: 'actualWorkManPieceworkCost', kind: 'money' },
|
|
69
|
+
{ header: '其它费用', field: 'actualOtherCost', kind: 'money' },
|
|
70
|
+
{ header: '单件成本', field: 'actualSumSingleGoodsCost', kind: 'money' },
|
|
71
|
+
{ header: '件数', field: 'actualNumber', kind: 'number' },
|
|
72
|
+
{ header: '总成本', field: 'actualSumCost', kind: 'money' },
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: '预估成本',
|
|
77
|
+
columns: [
|
|
78
|
+
{ header: '物料', field: 'estimatedMaterialCost', kind: 'money' },
|
|
79
|
+
{ header: '委外加工', field: 'estimatedProduceOrderProcessCost', kind: 'money' },
|
|
80
|
+
{ header: '工序外发', field: 'estimatedOutWardOrderCost', kind: 'money' },
|
|
81
|
+
{ header: '工人计件', field: 'estimatedWorkManPieceworkCost', kind: 'money' },
|
|
82
|
+
{ header: '其它费用', field: 'estimatedOtherCost', kind: 'money' },
|
|
83
|
+
{ header: '单件成本', field: 'estimatedSumSingleGoodsCost', kind: 'money' },
|
|
84
|
+
{ header: '件数', field: 'estimatedNumber', kind: 'number' },
|
|
85
|
+
{ header: '总成本', field: 'estimatedSumCost', kind: 'money' },
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
const OUTER_COLUMNS = OUTER_GROUPS.flatMap((group) => group.columns);
|
|
90
|
+
|
|
91
|
+
// 五类成本明细分区。items/totals 是接口 costItem 里的字段名;
|
|
92
|
+
// summary 描述合计行每个列字段取自 totals 的哪个字段,「合计」表示固定文字。
|
|
93
|
+
const DETAIL_SECTIONS = [
|
|
94
|
+
{
|
|
95
|
+
key: 'materialItems',
|
|
96
|
+
totalsKey: 'materialTotals',
|
|
97
|
+
title: '物料',
|
|
98
|
+
hasImage: true,
|
|
99
|
+
groups: [
|
|
100
|
+
{ name: '', columns: [
|
|
101
|
+
{ header: '图片', field: 'materialImgUrl', kind: 'image' },
|
|
102
|
+
{ header: '物料', field: 'materialDisplay', kind: 'text' },
|
|
103
|
+
{ header: '单位', field: 'materialUnit', kind: 'text' },
|
|
104
|
+
] },
|
|
105
|
+
{ name: '采购成本', columns: [
|
|
106
|
+
{ header: '采购单成本', field: 'purchaseAmount', kind: 'money' },
|
|
107
|
+
{ header: '采购已入库数', field: 'purchaseNumber', kind: 'number' },
|
|
108
|
+
{ header: '采购单价', field: 'purchaseSinglePrice', kind: 'money' },
|
|
109
|
+
{ header: '单件采购成本', field: 'purchaseSingleGoodsCost', kind: 'money' },
|
|
110
|
+
{ header: '单件采购用量', field: 'purchaseSingleGoodsNumber', kind: 'number' },
|
|
111
|
+
{ header: '合计成本', field: 'sumPurchaseCost', kind: 'money' },
|
|
112
|
+
] },
|
|
113
|
+
{ name: '实际成本', columns: [
|
|
114
|
+
{ header: '总用量', field: 'sumNumber', kind: 'number' },
|
|
115
|
+
{ header: '单件用量', field: 'singleGoodsNumber', kind: 'number' },
|
|
116
|
+
{ header: '库存单价', field: 'singleStoredPrice', kind: 'money' },
|
|
117
|
+
{ header: '单件成本', field: 'singleGoodsCost', kind: 'money' },
|
|
118
|
+
{ header: '合计成本', field: 'sumGoodsCost', kind: 'money' },
|
|
119
|
+
] },
|
|
120
|
+
{ name: '预估成本', columns: [
|
|
121
|
+
{ header: '单件成本', field: 'singleGoodsCostEstimate', kind: 'money' },
|
|
122
|
+
] },
|
|
123
|
+
],
|
|
124
|
+
summary: [
|
|
125
|
+
['materialDisplay', '合计'],
|
|
126
|
+
['purchaseSingleGoodsCost', 'sumSinglePurchaseGoodsCost'],
|
|
127
|
+
['sumPurchaseCost', 'sumProducePurchaseCost'],
|
|
128
|
+
['singleGoodsCost', 'sumSingleGoodsCost'],
|
|
129
|
+
['sumGoodsCost', 'sumProduceCost'],
|
|
130
|
+
['singleGoodsCostEstimate', 'sumSingleGoodsCostEstimate'],
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
key: 'processItems',
|
|
135
|
+
totalsKey: 'processTotals',
|
|
136
|
+
title: '委外加工',
|
|
137
|
+
hasImage: false,
|
|
138
|
+
groups: [
|
|
139
|
+
{ name: '', columns: [
|
|
140
|
+
{ header: '费用名称', field: 'name', kind: 'text' },
|
|
141
|
+
{ header: '入库数', field: 'number', kind: 'number' },
|
|
142
|
+
] },
|
|
143
|
+
{ name: '实际成本', columns: [
|
|
144
|
+
{ header: '单件成本', field: 'singleGoodsCost', kind: 'money' },
|
|
145
|
+
{ header: '合计成本', field: 'sumGoodsCost', kind: 'money' },
|
|
146
|
+
] },
|
|
147
|
+
{ name: '预估成本', columns: [
|
|
148
|
+
{ header: '单件成本', field: 'singleGoodsCostEstimate', kind: 'money' },
|
|
149
|
+
] },
|
|
150
|
+
],
|
|
151
|
+
summary: [
|
|
152
|
+
['name', '合计'],
|
|
153
|
+
['singleGoodsCost', 'sumSingleGoodsCost'],
|
|
154
|
+
['sumGoodsCost', 'sumProduceCost'],
|
|
155
|
+
['singleGoodsCostEstimate', 'sumSingleGoodsCostEstimate'],
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
key: 'outwardItems',
|
|
160
|
+
totalsKey: 'outwardTotals',
|
|
161
|
+
title: '工序外发',
|
|
162
|
+
hasImage: false,
|
|
163
|
+
groups: [
|
|
164
|
+
{ name: '', columns: [
|
|
165
|
+
{ header: '工序名称', field: 'name', kind: 'text' },
|
|
166
|
+
{ header: '净回收数', field: 'number', kind: 'number' },
|
|
167
|
+
] },
|
|
168
|
+
{ name: '实际成本', columns: [
|
|
169
|
+
{ header: '单件成本', field: 'singleGoodsCost', kind: 'money' },
|
|
170
|
+
{ header: '合计成本', field: 'sumGoodsCost', kind: 'money' },
|
|
171
|
+
] },
|
|
172
|
+
],
|
|
173
|
+
summary: [
|
|
174
|
+
['name', '合计'],
|
|
175
|
+
['sumGoodsCost', 'sumProduceCost'],
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
key: 'pieceworkItems',
|
|
180
|
+
totalsKey: 'pieceworkTotals',
|
|
181
|
+
title: '工人计件',
|
|
182
|
+
hasImage: false,
|
|
183
|
+
groups: [
|
|
184
|
+
{ name: '', columns: [
|
|
185
|
+
{ header: '工序名称', field: 'name', kind: 'text' },
|
|
186
|
+
{ header: '汇报数', field: 'number', kind: 'number' },
|
|
187
|
+
] },
|
|
188
|
+
{ name: '实际成本', columns: [
|
|
189
|
+
{ header: '单件成本', field: 'singleGoodsCost', kind: 'money' },
|
|
190
|
+
{ header: '合计成本', field: 'sumGoodsCost', kind: 'money' },
|
|
191
|
+
] },
|
|
192
|
+
{ name: '预估成本', columns: [
|
|
193
|
+
{ header: '单件成本', field: 'singleGoodsCostEstimate', kind: 'money' },
|
|
194
|
+
] },
|
|
195
|
+
],
|
|
196
|
+
summary: [
|
|
197
|
+
['name', '合计'],
|
|
198
|
+
['singleGoodsCost', 'sumSingleGoodsCost'],
|
|
199
|
+
['sumGoodsCost', 'sumProduceCost'],
|
|
200
|
+
['singleGoodsCostEstimate', 'sumSingleGoodsCostEstimate'],
|
|
201
|
+
],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
key: 'otherItems',
|
|
205
|
+
totalsKey: 'otherTotals',
|
|
206
|
+
title: '其它费用',
|
|
207
|
+
hasImage: false,
|
|
208
|
+
groups: [
|
|
209
|
+
{ name: '', columns: [
|
|
210
|
+
{ header: '费用类型', field: 'name', kind: 'text' },
|
|
211
|
+
] },
|
|
212
|
+
{ name: '实际成本', columns: [
|
|
213
|
+
{ header: '合计成本', field: 'sumGoodsCost', kind: 'money' },
|
|
214
|
+
{ header: '单件成本', field: 'singleGoodsCost', kind: 'money' },
|
|
215
|
+
] },
|
|
216
|
+
{ name: '预估成本', columns: [
|
|
217
|
+
{ header: '单件成本', field: 'singleGoodsCostEstimate', kind: 'money' },
|
|
218
|
+
] },
|
|
219
|
+
],
|
|
220
|
+
summary: [
|
|
221
|
+
['name', '合计'],
|
|
222
|
+
['sumGoodsCost', 'sumProduceCost'],
|
|
223
|
+
['singleGoodsCost', 'sumSingleGoodsCost'],
|
|
224
|
+
['singleGoodsCostEstimate', 'sumSingleGoodsCostEstimate'],
|
|
225
|
+
],
|
|
226
|
+
},
|
|
227
|
+
];
|
|
228
|
+
|
|
229
|
+
const BORDER = {
|
|
230
|
+
top: { style: 'thin', color: { argb: 'FFCCCCCC' } },
|
|
231
|
+
left: { style: 'thin', color: { argb: 'FFCCCCCC' } },
|
|
232
|
+
bottom: { style: 'thin', color: { argb: 'FFCCCCCC' } },
|
|
233
|
+
right: { style: 'thin', color: { argb: 'FFCCCCCC' } },
|
|
234
|
+
};
|
|
235
|
+
// 表头与标题底色:灰 / 浅蓝 / 浅绿(与原脚本一致)
|
|
236
|
+
const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFC0C0C0' } };
|
|
237
|
+
const TITLE_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9EAF7' } };
|
|
238
|
+
const SUMMARY_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2F0D9' } };
|
|
239
|
+
|
|
240
|
+
/** 文本归一化:空值返回空字符串 */
|
|
241
|
+
function text(value) {
|
|
242
|
+
return value == null ? '' : String(value).trim();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** 数值列取值:无法转数字时按原文返回,整数去掉小数尾巴 */
|
|
246
|
+
function numeric(value) {
|
|
247
|
+
if (value === null || value === undefined || value === '') {
|
|
248
|
+
return '';
|
|
249
|
+
}
|
|
250
|
+
const parsed = Number(value);
|
|
251
|
+
if (!Number.isFinite(parsed)) {
|
|
252
|
+
return text(value);
|
|
253
|
+
}
|
|
254
|
+
return parsed;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 物料列显示:名称(货号),只有一边时只显示一边 */
|
|
258
|
+
function materialDisplay(detail) {
|
|
259
|
+
const name = text(detail.materialName);
|
|
260
|
+
const sn = text(detail.materialSn);
|
|
261
|
+
if (name && sn) {
|
|
262
|
+
return `${name}(${sn})`;
|
|
263
|
+
}
|
|
264
|
+
return name || sn;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 外层报表行的单元格值 */
|
|
268
|
+
function outerValue(row, column) {
|
|
269
|
+
const value = row[column.field];
|
|
270
|
+
switch (column.kind) {
|
|
271
|
+
case 'state':
|
|
272
|
+
return STATE_NAMES[value] || text(value);
|
|
273
|
+
case 'type':
|
|
274
|
+
return TYPE_NAMES[value] || text(value);
|
|
275
|
+
case 'tags':
|
|
276
|
+
return Array.isArray(value) ? value.map(text).filter(Boolean).join('、') : '';
|
|
277
|
+
case 'date':
|
|
278
|
+
return dateText(value);
|
|
279
|
+
case 'number':
|
|
280
|
+
case 'money':
|
|
281
|
+
return numeric(value);
|
|
282
|
+
case 'image':
|
|
283
|
+
return '';
|
|
284
|
+
default:
|
|
285
|
+
return text(value);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** 日期列容错:取 yyyy-MM-dd 前 10 位 */
|
|
290
|
+
function dateText(value) {
|
|
291
|
+
const raw = text(value);
|
|
292
|
+
if (!raw) {
|
|
293
|
+
return '';
|
|
294
|
+
}
|
|
295
|
+
const match = /^(\d{4}-\d{2}-\d{2})/.exec(raw);
|
|
296
|
+
return match ? match[1] : raw;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 把一行值按列类型写入指定行并设置样式,列数补齐到 totalCols(分区宽度之外的
|
|
301
|
+
* 空白列也要有边框,与原脚本整行样式的写法一致);返回该行对象。
|
|
302
|
+
*/
|
|
303
|
+
function writeStyledRow(sheet, rowNumber, columns, values, { height = 24, summary = false, totalCols } = {}) {
|
|
304
|
+
const width = totalCols || columns.length;
|
|
305
|
+
const padded = [...values];
|
|
306
|
+
while (padded.length < width) {
|
|
307
|
+
padded.push('');
|
|
308
|
+
}
|
|
309
|
+
const row = sheet.getRow(rowNumber);
|
|
310
|
+
row.values = [];
|
|
311
|
+
for (let index = 1; index <= width; index += 1) {
|
|
312
|
+
const column = columns[index - 1];
|
|
313
|
+
const cell = row.getCell(index);
|
|
314
|
+
const value = padded[index - 1];
|
|
315
|
+
cell.value = column && column.kind === 'image' ? '' : value;
|
|
316
|
+
cell.border = BORDER;
|
|
317
|
+
if (column && column.kind === 'money') {
|
|
318
|
+
cell.numFmt = MONEY_FORMAT;
|
|
319
|
+
}
|
|
320
|
+
const isText = !column || column.kind === 'text' || column.kind === 'state' || column.kind === 'type' || column.kind === 'tags' || column.kind === 'date';
|
|
321
|
+
cell.alignment = {
|
|
322
|
+
vertical: 'middle',
|
|
323
|
+
horizontal: isText ? 'left' : 'center',
|
|
324
|
+
wrapText: !column || (column.kind !== 'number' && column.kind !== 'money'),
|
|
325
|
+
};
|
|
326
|
+
if (summary) {
|
|
327
|
+
cell.fill = SUMMARY_FILL;
|
|
328
|
+
cell.font = { bold: true };
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
row.height = height;
|
|
332
|
+
return row;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 写一个成本明细分区:标题行、两级表头、明细行、合计行。
|
|
337
|
+
* 返回写完后的下一行行号。
|
|
338
|
+
*/
|
|
339
|
+
function writeSection(sheet, startRow, section, costItem, images, maxCols, workbook) {
|
|
340
|
+
const columns = section.groups.flatMap((group) => group.columns);
|
|
341
|
+
const width = columns.length;
|
|
342
|
+
let rowNumber = startRow;
|
|
343
|
+
|
|
344
|
+
// 分区标题行:合并整行
|
|
345
|
+
const titleCell = sheet.getCell(rowNumber, 1);
|
|
346
|
+
titleCell.value = section.title;
|
|
347
|
+
titleCell.font = { bold: true, size: 12 };
|
|
348
|
+
titleCell.fill = TITLE_FILL;
|
|
349
|
+
titleCell.alignment = { vertical: 'middle', horizontal: 'left' };
|
|
350
|
+
for (let col = 2; col <= maxCols; col += 1) {
|
|
351
|
+
const cell = sheet.getCell(rowNumber, col);
|
|
352
|
+
cell.fill = TITLE_FILL;
|
|
353
|
+
}
|
|
354
|
+
sheet.mergeCells(rowNumber, 1, rowNumber, maxCols);
|
|
355
|
+
sheet.getRow(rowNumber).height = 26;
|
|
356
|
+
rowNumber += 1;
|
|
357
|
+
|
|
358
|
+
// 两级表头:第一行组名(无组名的列纵向合并两行,合并后显示为空白,与原脚本一致),
|
|
359
|
+
// 第二行列标题。整行样式覆盖到 maxCols,保证空白列也有边框和底色。
|
|
360
|
+
const groupRow = sheet.getRow(rowNumber);
|
|
361
|
+
const titleRow = sheet.getRow(rowNumber + 1);
|
|
362
|
+
let colIndex = 1;
|
|
363
|
+
for (const group of section.groups) {
|
|
364
|
+
group.columns.forEach((column, offset) => {
|
|
365
|
+
groupRow.getCell(colIndex).value = offset === 0 ? group.name : '';
|
|
366
|
+
titleRow.getCell(colIndex).value = column.header;
|
|
367
|
+
colIndex += 1;
|
|
368
|
+
});
|
|
369
|
+
if (group.name && group.columns.length > 1) {
|
|
370
|
+
sheet.mergeCells(rowNumber, colIndex - group.columns.length, rowNumber, colIndex - 1);
|
|
371
|
+
} else if (!group.name) {
|
|
372
|
+
for (let col = colIndex - group.columns.length; col < colIndex; col += 1) {
|
|
373
|
+
sheet.mergeCells(rowNumber, col, rowNumber + 1, col);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const row of [groupRow, titleRow]) {
|
|
378
|
+
row.height = row === groupRow ? 24 : 30;
|
|
379
|
+
for (let col = 1; col <= maxCols; col += 1) {
|
|
380
|
+
const cell = row.getCell(col);
|
|
381
|
+
cell.font = { bold: true };
|
|
382
|
+
cell.fill = HEADER_FILL;
|
|
383
|
+
cell.border = BORDER;
|
|
384
|
+
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
rowNumber += 2;
|
|
388
|
+
|
|
389
|
+
// 明细行与合计行
|
|
390
|
+
const items = Array.isArray(costItem && costItem[section.key]) ? costItem[section.key] : [];
|
|
391
|
+
const totals = (costItem && costItem[section.totalsKey]) || {};
|
|
392
|
+
if (items.length > MAX_DETAIL_ROWS) {
|
|
393
|
+
throw new Error(`${section.title}明细超过 ${MAX_DETAIL_ROWS} 行`);
|
|
394
|
+
}
|
|
395
|
+
if (!items.length) {
|
|
396
|
+
writeStyledRow(sheet, rowNumber, columns, ['暂无数据'], { totalCols: maxCols });
|
|
397
|
+
sheet.mergeCells(rowNumber, 1, rowNumber, width);
|
|
398
|
+
rowNumber += 1;
|
|
399
|
+
return rowNumber + 1;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
items.forEach((item) => {
|
|
403
|
+
const source = section.key === 'materialItems' ? { ...item, materialDisplay: materialDisplay(item) } : item;
|
|
404
|
+
const values = columns.map((column) => (
|
|
405
|
+
column.kind === 'number' || column.kind === 'money' ? numeric(source[column.field]) : text(source[column.field])
|
|
406
|
+
));
|
|
407
|
+
writeStyledRow(sheet, rowNumber, columns, values, {
|
|
408
|
+
height: section.hasImage ? 46 : 24,
|
|
409
|
+
totalCols: maxCols,
|
|
410
|
+
});
|
|
411
|
+
// 物料图片列嵌图;图片列单元格本身不写 URL 文本
|
|
412
|
+
if (section.hasImage) {
|
|
413
|
+
const imageColIndex = columns.findIndex((column) => column.kind === 'image') + 1;
|
|
414
|
+
const url = text(item.materialImgUrl);
|
|
415
|
+
if (url && images.has(url)) {
|
|
416
|
+
embedImage(workbook, sheet, images.get(url), rowNumber, imageColIndex, { width: 60, height: 60 });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
rowNumber += 1;
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// 合计行:summary 里声明的列取 totals 字段,其余列空
|
|
423
|
+
const summaryValues = columns.map((column) => {
|
|
424
|
+
const pair = section.summary.find(([field]) => field === column.field);
|
|
425
|
+
if (!pair) {
|
|
426
|
+
return '';
|
|
427
|
+
}
|
|
428
|
+
if (pair[1] === '合计') {
|
|
429
|
+
return '合计';
|
|
430
|
+
}
|
|
431
|
+
const value = totals[pair[1]];
|
|
432
|
+
return column.kind === 'money' || column.kind === 'number' ? numeric(value == null ? 0 : value) : text(value);
|
|
433
|
+
});
|
|
434
|
+
writeStyledRow(sheet, rowNumber, columns, summaryValues, { summary: true, totalCols: maxCols });
|
|
435
|
+
rowNumber += 1;
|
|
436
|
+
return rowNumber + 1;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 生成一个生产单的成本明细 Excel。
|
|
441
|
+
* 返回该文件的明细行数与嵌入图片数。
|
|
442
|
+
*/
|
|
443
|
+
async function writeOneFile(filePath, row, images) {
|
|
444
|
+
const workbook = createWorkbook();
|
|
445
|
+
const sheet = workbook.addWorksheet(SHEET_NAME, {
|
|
446
|
+
views: [{ state: 'frozen', ySplit: 2 }],
|
|
447
|
+
pageSetup: { orientation: 'landscape', fitToPage: true, fitToWidth: 1, fitToHeight: 0 },
|
|
448
|
+
});
|
|
449
|
+
const maxCols = Math.max(OUTER_COLUMNS.length, ...DETAIL_SECTIONS.map((section) => section.groups.reduce((sum, group) => sum + group.columns.length, 0)));
|
|
450
|
+
|
|
451
|
+
// 外层两级表头
|
|
452
|
+
const groupRow = sheet.getRow(1);
|
|
453
|
+
const titleRow = sheet.getRow(2);
|
|
454
|
+
let colIndex = 1;
|
|
455
|
+
for (const group of OUTER_GROUPS) {
|
|
456
|
+
group.columns.forEach((column, offset) => {
|
|
457
|
+
groupRow.getCell(colIndex).value = offset === 0 ? group.name : '';
|
|
458
|
+
titleRow.getCell(colIndex).value = column.header;
|
|
459
|
+
colIndex += 1;
|
|
460
|
+
});
|
|
461
|
+
sheet.mergeCells(1, colIndex - group.columns.length, 1, colIndex - 1);
|
|
462
|
+
}
|
|
463
|
+
groupRow.height = 26;
|
|
464
|
+
titleRow.height = 30;
|
|
465
|
+
for (const headerLine of [groupRow, titleRow]) {
|
|
466
|
+
for (let col = 1; col <= maxCols; col += 1) {
|
|
467
|
+
const cell = headerLine.getCell(col);
|
|
468
|
+
cell.font = { bold: true };
|
|
469
|
+
cell.fill = HEADER_FILL;
|
|
470
|
+
cell.border = BORDER;
|
|
471
|
+
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// 外层报表数据行(嵌款式图)
|
|
476
|
+
writeStyledRow(sheet, 3, OUTER_COLUMNS, OUTER_COLUMNS.map((column) => outerValue(row, column)), { height: 52, totalCols: maxCols });
|
|
477
|
+
const outerImageCol = OUTER_COLUMNS.findIndex((column) => column.kind === 'image') + 1;
|
|
478
|
+
const outerUrl = text(row.imgUrl);
|
|
479
|
+
if (outerUrl && images.has(outerUrl)) {
|
|
480
|
+
embedImage(workbook, sheet, images.get(outerUrl), 3, outerImageCol, { width: 60, height: 60 });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// 成本明细标题行
|
|
484
|
+
const detailTitle = sheet.getCell(5, 1);
|
|
485
|
+
detailTitle.value = `成本明细(计算规则:${CAL_WAY_NAME})`;
|
|
486
|
+
detailTitle.font = { bold: true, size: 12 };
|
|
487
|
+
detailTitle.fill = TITLE_FILL;
|
|
488
|
+
detailTitle.alignment = { vertical: 'middle', horizontal: 'left' };
|
|
489
|
+
for (let col = 2; col <= maxCols; col += 1) {
|
|
490
|
+
sheet.getCell(5, col).fill = TITLE_FILL;
|
|
491
|
+
}
|
|
492
|
+
sheet.mergeCells(5, 1, 5, maxCols);
|
|
493
|
+
sheet.getRow(5).height = 28;
|
|
494
|
+
|
|
495
|
+
// 五类成本明细分区
|
|
496
|
+
const costItem = row.costItem || {};
|
|
497
|
+
let rowNumber = 6;
|
|
498
|
+
let detailCount = 0;
|
|
499
|
+
for (const section of DETAIL_SECTIONS) {
|
|
500
|
+
const items = Array.isArray(costItem[section.key]) ? costItem[section.key] : [];
|
|
501
|
+
detailCount += items.length;
|
|
502
|
+
rowNumber = writeSection(sheet, rowNumber, section, costItem, images, maxCols, workbook);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// 列宽:外层列按标题与类型,其余列 18
|
|
506
|
+
OUTER_COLUMNS.forEach((column, index) => {
|
|
507
|
+
const width = column.kind === 'number' || column.kind === 'money'
|
|
508
|
+
? 13
|
|
509
|
+
: column.kind === 'image'
|
|
510
|
+
? 10
|
|
511
|
+
: Math.max(12, Math.min(22, column.header.length * 3 + 4));
|
|
512
|
+
sheet.getColumn(index + 1).width = width;
|
|
513
|
+
});
|
|
514
|
+
for (let col = OUTER_COLUMNS.length + 1; col <= maxCols; col += 1) {
|
|
515
|
+
sheet.getColumn(col).width = 18;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
await saveWorkbook(workbook, filePath);
|
|
519
|
+
return { detailCount };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** 文件名清洗:替换 Windows 非法字符,去掉首尾空格与点,最长 80 字符 */
|
|
523
|
+
function safeFilename(value) {
|
|
524
|
+
const cleaned = text(value).replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').replace(/^[. ]+|[. ]+$/g, '');
|
|
525
|
+
return cleaned.slice(0, 80) || '未命名';
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* 生成生产成本报表明细:每个生产单一个 Excel,输出到一个带时间戳的目录。
|
|
530
|
+
* argv.from/to:业务日期区间(默认不限日期,与原脚本不同处:原脚本默认近 30 天,
|
|
531
|
+
* 接口版默认全量由调用方控制);argv.sn/ordersSn、argv.searchKey、argv.state 透传接口;
|
|
532
|
+
* argv.out:输出目录。
|
|
533
|
+
*/
|
|
534
|
+
async function generate(argv = {}) {
|
|
535
|
+
const outDir = argv.out || process.cwd();
|
|
536
|
+
const dirPath = path.resolve(outDir, `生产成本报表明细${timestamp()}`);
|
|
537
|
+
if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length) {
|
|
538
|
+
throw new Error(`输出目录已存在且非空:${dirPath};如需写入请先清空目录或更换输出目录`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const states = String(argv.state || '4,2,3')
|
|
542
|
+
.split(',')
|
|
543
|
+
.map((item) => item.trim())
|
|
544
|
+
.filter(Boolean);
|
|
545
|
+
const searchSn = argv.sn || argv.ordersSn;
|
|
546
|
+
const { list: rawRows, total } = await fetchAll(
|
|
547
|
+
(page, limit) => call('GET', '/api/agent/v1/report/produce-cost', {
|
|
548
|
+
from: argv.from,
|
|
549
|
+
to: argv.to,
|
|
550
|
+
state: states.join(','),
|
|
551
|
+
calWay: CAL_WAY,
|
|
552
|
+
searchKey: argv.searchKey,
|
|
553
|
+
ordersSn: searchSn,
|
|
554
|
+
page,
|
|
555
|
+
limit,
|
|
556
|
+
})
|
|
557
|
+
);
|
|
558
|
+
if (total > MAX_FILES) {
|
|
559
|
+
throw new Error(`符合条件的数据有 ${total} 行,超过安全上限 ${MAX_FILES};请缩小筛选范围`);
|
|
560
|
+
}
|
|
561
|
+
// 接口的单号查询是模糊匹配,这里再按单号/批次号精确过滤一次(与原脚本一致)
|
|
562
|
+
const rows = searchSn
|
|
563
|
+
? rawRows.filter((row) => text(row.ordersSn) === searchSn || text(row.produceBatchSn) === searchSn)
|
|
564
|
+
: rawRows;
|
|
565
|
+
if (!rows.length) {
|
|
566
|
+
throw new Error('没有符合条件的生产成本报表数据');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 预下载全部图片(款式图 + 物料图),失败地址在 Map 外,对应单元格留空
|
|
570
|
+
const urls = new Set();
|
|
571
|
+
for (const row of rows) {
|
|
572
|
+
if (text(row.imgUrl)) {
|
|
573
|
+
urls.add(text(row.imgUrl));
|
|
574
|
+
}
|
|
575
|
+
const item = row.costItem || {};
|
|
576
|
+
for (const material of item.materialItems || []) {
|
|
577
|
+
if (text(material.materialImgUrl)) {
|
|
578
|
+
urls.add(text(material.materialImgUrl));
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const images = await downloadImages([...urls]);
|
|
583
|
+
|
|
584
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
585
|
+
const usedNames = new Set();
|
|
586
|
+
const filePaths = [];
|
|
587
|
+
const failures = [];
|
|
588
|
+
let totalDetails = 0;
|
|
589
|
+
for (const row of rows) {
|
|
590
|
+
const orderSn = row.ordersSn || row.id;
|
|
591
|
+
const goodsName = text(row.goodsName) || '未命名商品';
|
|
592
|
+
const goodsSn = text(row.goodsSn) || text(row.designSn) || '无大货号';
|
|
593
|
+
let base = safeFilename(`${orderSn}_${goodsName}(${goodsSn})`);
|
|
594
|
+
let candidate = `${base}.xlsx`;
|
|
595
|
+
let suffix = 2;
|
|
596
|
+
while (usedNames.has(candidate.toLowerCase())) {
|
|
597
|
+
candidate = `${base}_${suffix}.xlsx`;
|
|
598
|
+
suffix += 1;
|
|
599
|
+
}
|
|
600
|
+
usedNames.add(candidate.toLowerCase());
|
|
601
|
+
const filePath = path.join(dirPath, candidate);
|
|
602
|
+
try {
|
|
603
|
+
const { detailCount } = await writeOneFile(filePath, row, images);
|
|
604
|
+
filePaths.push(filePath);
|
|
605
|
+
totalDetails += detailCount;
|
|
606
|
+
} catch (e) {
|
|
607
|
+
failures.push(String(orderSn));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
if (!filePaths.length) {
|
|
611
|
+
throw new Error('所有 Excel 均生成失败');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
filePaths,
|
|
616
|
+
summary: `生产成本报表明细已生成:报表行 ${rows.length} 行拆为 ${filePaths.length} 个 Excel(目录 ${path.basename(dirPath)}),` +
|
|
617
|
+
`成本明细 ${totalDetails} 行,计算规则「${CAL_WAY_NAME}」` +
|
|
618
|
+
(failures.length ? `,${failures.length} 个生成失败(${failures.join('、')})` : ''),
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
module.exports = { generate };
|