@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,312 @@
|
|
|
1
|
+
// 生产单工序跟进表:导出待生产与生产中(状态 4、2)生产单的工序汇报情况,
|
|
2
|
+
// 每个工序取最晚一条汇报记录,最后汇报日期不在允许天数内的判定为「需跟进」,
|
|
3
|
+
// 在需跟进工序列标红。原脚本的判定口径是「最后汇报不是今天就需跟进」,
|
|
4
|
+
// 本模块保持该默认(days=0),并允许用 days 参数放宽到 N 天。
|
|
5
|
+
// 数据流:GET /api/agent/v1/produce/list 按状态 4、2 分别分页取全量后合并
|
|
6
|
+
// (agent 接口的 state 参数只接受单个状态值),
|
|
7
|
+
// 逐单 GET /api/agent/v1/produce/work-processes 取工序节点,
|
|
8
|
+
// 再逐工序 GET /api/agent/v1/produce/work-process-detail 取汇报记录。
|
|
9
|
+
// 这是请求数最多的报表:每张生产单 1 次工序列表 + 每个工序 1 次明细,
|
|
10
|
+
// 100 张生产单按平均 6 个工序估算约 700 次请求,配合 2.1 秒节流约需 25 分钟。
|
|
11
|
+
// 生成逻辑从原 Python 报表脚本 export_production_process_followup.py 移植,
|
|
12
|
+
// 表结构、列名、文件名与原脚本一致;与原脚本的差异:
|
|
13
|
+
// 1. 原脚本按 businessTime 近 90 天取单;agent 接口用 from/to 日期区间表达,
|
|
14
|
+
// 默认近 90 天(含今天,可用 from/to 覆盖)。
|
|
15
|
+
// 2. 单张生产单的工序列表或单个工序明细查询失败时跳过该部分继续生成
|
|
16
|
+
// (原脚本直接报错终止),失败数量在汇总中说明。
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { call } = require('../cli/api');
|
|
20
|
+
const { dateStr, shiftDate, createThrottle, fetchAll } = require('./util');
|
|
21
|
+
const { createWorkbook, saveWorkbook, markCellColor } = require('./excel');
|
|
22
|
+
|
|
23
|
+
/** 服务器对同一 AI Key 每分钟限 30 次请求,两次请求间隔至少 2100 毫秒 */
|
|
24
|
+
const REQUEST_INTERVAL_MS = 2100;
|
|
25
|
+
/** 列表分页大小(服务端单页上限 100) */
|
|
26
|
+
const PAGE_LIMIT = 100;
|
|
27
|
+
/** 取单的业务日期窗口天数(原脚本 businessTime 近 90 天) */
|
|
28
|
+
const DEFAULT_DAYS = 90;
|
|
29
|
+
/** 生产单状态:4 待生产、2 生产中 */
|
|
30
|
+
const STATES = [4, 2];
|
|
31
|
+
/** 需跟进判定天数:最后汇报日期距今天超过该天数即需跟进(原脚本默认 0,即不是今天) */
|
|
32
|
+
const DEFAULT_FOLLOWUP_DAYS = 0;
|
|
33
|
+
/** 平均每张生产单的工序数,用于生成前的耗时估算提示 */
|
|
34
|
+
const ESTIMATED_PROCESSES_PER_ORDER = 6;
|
|
35
|
+
|
|
36
|
+
/** 表头底色(浅蓝,与原脚本一致) */
|
|
37
|
+
const HEADER_FILL = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFD9EAF7' } };
|
|
38
|
+
const BORDER = {
|
|
39
|
+
top: { style: 'thin', color: { argb: 'FF000000' } },
|
|
40
|
+
left: { style: 'thin', color: { argb: 'FF000000' } },
|
|
41
|
+
bottom: { style: 'thin', color: { argb: 'FF000000' } },
|
|
42
|
+
right: { style: 'thin', color: { argb: 'FF000000' } },
|
|
43
|
+
};
|
|
44
|
+
const CENTER = { vertical: 'middle', horizontal: 'center', wrapText: true };
|
|
45
|
+
|
|
46
|
+
/** 空值转空字符串,其余转去空白字符串 */
|
|
47
|
+
function text(value) {
|
|
48
|
+
return value === null || value === undefined ? '' : String(value).trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** yyyy-MM-dd 转 UTC 毫秒,用于日期差计算 */
|
|
52
|
+
function toUtc(dayStr) {
|
|
53
|
+
const [y, m, d] = dayStr.split('-').map(Number);
|
|
54
|
+
return Date.UTC(y, m - 1, d);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 两个 yyyy-MM-dd 之间相差的天数(to - from) */
|
|
58
|
+
function dayDiff(fromStr, toStr) {
|
|
59
|
+
return Math.round((toUtc(toStr) - toUtc(fromStr)) / 86400000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 取一个工序全部汇报记录中最晚的汇报时间。
|
|
64
|
+
* 汇报时间优先取业务时间,为空时取创建时间;接口已格式化为
|
|
65
|
+
* yyyy-MM-dd HH:mm:ss,字符串按字典序比较即得最晚值。
|
|
66
|
+
*/
|
|
67
|
+
function latestReportTime(records) {
|
|
68
|
+
let latest = '';
|
|
69
|
+
for (const record of records || []) {
|
|
70
|
+
const value = text(record.businessTime) || text(record.createTime);
|
|
71
|
+
if (value && value > latest) {
|
|
72
|
+
latest = value;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return latest;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 是否需要跟进:没有汇报记录,或最后汇报日期距今超过 followupDays 天 */
|
|
79
|
+
function needsFollowup(latest, todayStr, followupDays) {
|
|
80
|
+
if (!latest) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
const day = latest.slice(0, 10);
|
|
84
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return dayDiff(day, todayStr) > followupDays;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 商品列显示:商品名(货号),缺一个时只显示另一个 */
|
|
91
|
+
function goodsText(order) {
|
|
92
|
+
const name = text(order.goodsName);
|
|
93
|
+
const sn = text(order.goodsSn);
|
|
94
|
+
if (name && sn) {
|
|
95
|
+
return `${name}(${sn})`;
|
|
96
|
+
}
|
|
97
|
+
return name || sn;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 按状态 4、2 分别取全量生产单后合并。
|
|
102
|
+
* 两个状态的结果理论上不重叠,合并时仍按生产单号去重,防止服务端重复返回。
|
|
103
|
+
*/
|
|
104
|
+
async function fetchOrders(throttle, from, to) {
|
|
105
|
+
const merged = [];
|
|
106
|
+
const seen = new Set();
|
|
107
|
+
for (const state of STATES) {
|
|
108
|
+
const { list } = await fetchAll(
|
|
109
|
+
(page, limit) => throttle(() => call('GET', '/api/agent/v1/produce/list', {
|
|
110
|
+
state,
|
|
111
|
+
from,
|
|
112
|
+
to,
|
|
113
|
+
page,
|
|
114
|
+
limit,
|
|
115
|
+
})),
|
|
116
|
+
{ limit: PAGE_LIMIT },
|
|
117
|
+
);
|
|
118
|
+
for (const row of list) {
|
|
119
|
+
const key = text(row.ordersSn);
|
|
120
|
+
if (key && seen.has(key)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (key) {
|
|
124
|
+
seen.add(key);
|
|
125
|
+
}
|
|
126
|
+
merged.push(row);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return merged;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 查询一张生产单的全部工序跟进情况。
|
|
134
|
+
* 工序列表失败或工序明细失败时计数并继续(明细失败的工序按无汇报处理)。
|
|
135
|
+
*/
|
|
136
|
+
async function followupOrder(throttle, order, todayStr, followupDays, progress) {
|
|
137
|
+
const sn = text(order.ordersSn);
|
|
138
|
+
if (!sn) {
|
|
139
|
+
return { skipped: true };
|
|
140
|
+
}
|
|
141
|
+
let processes = [];
|
|
142
|
+
try {
|
|
143
|
+
processes = await throttle(() => call('GET', '/api/agent/v1/produce/work-processes', { sn }));
|
|
144
|
+
} catch (e) {
|
|
145
|
+
return { sn, error: e };
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(processes)) {
|
|
148
|
+
processes = [];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const processValues = [];
|
|
152
|
+
const followups = [];
|
|
153
|
+
let failedDetails = 0;
|
|
154
|
+
for (let index = 0; index < processes.length; index += 1) {
|
|
155
|
+
const process = processes[index];
|
|
156
|
+
const workProcessId = process.id ?? process.workProcessId;
|
|
157
|
+
if (workProcessId === null || workProcessId === undefined) {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const name = text(process.workProcessName) || text(process.name) || `未命名工序${index + 1}`;
|
|
161
|
+
let latest = '';
|
|
162
|
+
try {
|
|
163
|
+
const detail = await throttle(() => call('GET', '/api/agent/v1/produce/work-process-detail', {
|
|
164
|
+
workProcessId,
|
|
165
|
+
}));
|
|
166
|
+
latest = latestReportTime(detail.reportRecords);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
failedDetails += 1;
|
|
169
|
+
}
|
|
170
|
+
const follow = needsFollowup(latest, todayStr, followupDays);
|
|
171
|
+
processValues.push({ name, latest, follow });
|
|
172
|
+
if (follow) {
|
|
173
|
+
followups.push(name);
|
|
174
|
+
}
|
|
175
|
+
progress.processed += 1;
|
|
176
|
+
if (progress.processed % 10 === 0) {
|
|
177
|
+
const elapsed = Math.round((Date.now() - progress.startedAt) / 1000);
|
|
178
|
+
const minutes = Math.floor(elapsed / 60);
|
|
179
|
+
const seconds = elapsed % 60;
|
|
180
|
+
process.stdout.write(
|
|
181
|
+
`工序跟进进度:已查询 ${progress.processed} 个工序,生产单 ${progress.orderIndex}/${progress.orderTotal},`
|
|
182
|
+
+ `已耗时 ${minutes}分${String(seconds).padStart(2, '0')}秒\n`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { sn, goods: goodsText(order), processValues, followups, failedDetails };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** 写 Excel:标题行 + 表头行 + 数据行,需跟进工序列标红 */
|
|
190
|
+
async function writeWorkbook(filePath, records) {
|
|
191
|
+
const workbook = createWorkbook();
|
|
192
|
+
const sheet = workbook.addWorksheet('生产工序跟进表', { views: [{ state: 'frozen', ySplit: 2 }] });
|
|
193
|
+
|
|
194
|
+
// 第 1 行:表标题,整行合并
|
|
195
|
+
const titleRow = sheet.getRow(1);
|
|
196
|
+
titleRow.getCell(1).value = '生产单工序汇报跟进表';
|
|
197
|
+
titleRow.height = 32;
|
|
198
|
+
titleRow.eachCell({ includeEmpty: true }, (cell) => {
|
|
199
|
+
cell.font = { bold: true, size: 16 };
|
|
200
|
+
cell.border = BORDER;
|
|
201
|
+
cell.alignment = { vertical: 'middle', horizontal: 'center' };
|
|
202
|
+
});
|
|
203
|
+
sheet.mergeCells(1, 1, 1, 3);
|
|
204
|
+
|
|
205
|
+
// 第 2 行:表头
|
|
206
|
+
const headerRow = sheet.getRow(2);
|
|
207
|
+
['生产单号', '商品', '需跟进工序'].forEach((header, i) => {
|
|
208
|
+
headerRow.getCell(i + 1).value = header;
|
|
209
|
+
});
|
|
210
|
+
headerRow.height = 25;
|
|
211
|
+
headerRow.eachCell({ includeEmpty: true }, (cell) => {
|
|
212
|
+
cell.font = { bold: true };
|
|
213
|
+
cell.fill = HEADER_FILL;
|
|
214
|
+
cell.border = BORDER;
|
|
215
|
+
cell.alignment = CENTER;
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// 数据行:无表框(与原脚本一致),需跟进工序列标红
|
|
219
|
+
records.forEach((record, index) => {
|
|
220
|
+
const rowNumber = index + 3;
|
|
221
|
+
const row = sheet.getRow(rowNumber);
|
|
222
|
+
row.getCell(1).value = record.sn;
|
|
223
|
+
row.getCell(2).value = record.goods;
|
|
224
|
+
row.getCell(3).value = record.followups.join('、');
|
|
225
|
+
row.height = 24;
|
|
226
|
+
if (record.followups.length) {
|
|
227
|
+
markCellColor(sheet, rowNumber, 3, 'FFFF0000');
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
sheet.getColumn(1).width = 23;
|
|
232
|
+
sheet.getColumn(2).width = 30;
|
|
233
|
+
sheet.getColumn(3).width = 28;
|
|
234
|
+
if (records.length) {
|
|
235
|
+
sheet.autoFilter = `A2:C${records.length + 2}`;
|
|
236
|
+
}
|
|
237
|
+
await saveWorkbook(workbook, filePath);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 生成生产单工序跟进表。
|
|
242
|
+
* argv:{ from/to 业务日期区间(可选,默认近 90 天含今天),
|
|
243
|
+
* days 需跟进判定天数(可选,默认 0,即最后汇报不是今天就需跟进),
|
|
244
|
+
* out 输出目录 }。
|
|
245
|
+
* 输出文件名 生产单工序跟进表-YYYY-MM-DD.xlsx,已存在时报错,不覆盖。
|
|
246
|
+
*/
|
|
247
|
+
async function generate(argv = {}) {
|
|
248
|
+
const throttle = createThrottle(REQUEST_INTERVAL_MS);
|
|
249
|
+
// 后端对业务日期结束时间用严格小于比较,因此默认结束日期传明天以包含今天;
|
|
250
|
+
// 显式传入 from/to 时按传入值使用
|
|
251
|
+
const from = argv.from ? String(argv.from) : shiftDate(-(DEFAULT_DAYS - 1));
|
|
252
|
+
const to = argv.to ? String(argv.to) : shiftDate(1);
|
|
253
|
+
const followupDays = Number.isFinite(Number(argv.days)) && Number(argv.days) > 0
|
|
254
|
+
? Number(argv.days)
|
|
255
|
+
: DEFAULT_FOLLOWUP_DAYS;
|
|
256
|
+
const outDir = argv.out ? path.resolve(String(argv.out)) : process.cwd();
|
|
257
|
+
const todayStr = dateStr();
|
|
258
|
+
|
|
259
|
+
const orders = await fetchOrders(throttle, from, to);
|
|
260
|
+
if (!orders.length) {
|
|
261
|
+
throw new Error('没有符合条件的生产单');
|
|
262
|
+
}
|
|
263
|
+
const estimatedRequests = orders.length + orders.length * ESTIMATED_PROCESSES_PER_ORDER;
|
|
264
|
+
process.stdout.write(
|
|
265
|
+
`将检查 ${orders.length} 张生产单的工序汇报:按平均每单 ${ESTIMATED_PROCESSES_PER_ORDER} 个工序估算,`
|
|
266
|
+
+ `约 ${estimatedRequests} 次请求,每次间隔 ${REQUEST_INTERVAL_MS / 1000} 秒,`
|
|
267
|
+
+ `预计需要 ${Math.round((estimatedRequests * REQUEST_INTERVAL_MS) / 60000)} 分钟以上,请耐心等待\n`,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const progress = { processed: 0, orderIndex: 0, orderTotal: orders.length, startedAt: Date.now() };
|
|
271
|
+
const records = [];
|
|
272
|
+
let noSnCount = 0;
|
|
273
|
+
let orderFailedCount = 0;
|
|
274
|
+
let detailFailedCount = 0;
|
|
275
|
+
let processCount = 0;
|
|
276
|
+
for (const order of orders) {
|
|
277
|
+
progress.orderIndex += 1;
|
|
278
|
+
const result = await followupOrder(throttle, order, todayStr, followupDays, progress);
|
|
279
|
+
if (result.skipped) {
|
|
280
|
+
noSnCount += 1;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (result.error) {
|
|
284
|
+
orderFailedCount += 1;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
detailFailedCount += result.failedDetails;
|
|
288
|
+
if (!result.processValues.length) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
processCount += result.processValues.length;
|
|
292
|
+
records.push(result);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const filePath = path.resolve(outDir, `生产单工序跟进表-${todayStr}.xlsx`);
|
|
296
|
+
if (fs.existsSync(filePath)) {
|
|
297
|
+
throw new Error(`输出文件已存在:${filePath};如需替换请先删除旧文件`);
|
|
298
|
+
}
|
|
299
|
+
await writeWorkbook(filePath, records);
|
|
300
|
+
|
|
301
|
+
const followupTotal = records.reduce((sum, record) => sum + record.followups.length, 0);
|
|
302
|
+
const elapsed = Math.round((Date.now() - progress.startedAt) / 1000);
|
|
303
|
+
let summary = `生产单工序跟进表已生成:检查生产单 ${orders.length} 张,其中 ${records.length} 张有工序、`
|
|
304
|
+
+ `共 ${processCount} 个工序;需跟进工序 ${followupTotal} 个(最后汇报超过 ${followupDays} 天或无汇报)`;
|
|
305
|
+
if (orderFailedCount || detailFailedCount || noSnCount) {
|
|
306
|
+
summary += `;工序列表失败 ${orderFailedCount} 张、工序明细失败 ${detailFailedCount} 个、缺少单号 ${noSnCount} 张已跳过`;
|
|
307
|
+
}
|
|
308
|
+
summary += `;耗时 ${Math.floor(elapsed / 60)}分${String(elapsed % 60).padStart(2, '0')}秒;文件:${filePath}`;
|
|
309
|
+
return { filePaths: [filePath], summary };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = { generate };
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// 生产节点看板 HTML 报表:近 N 天生产单按「生产中/结束/超期」分组,
|
|
2
|
+
// 每组内按未完成工序节点分列,卡片展示款式图与单号。
|
|
3
|
+
// 数据来自 agent 接口 produce/list(列表行带工序进度),从原 Python 看板脚本移植。
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { call } = require('../cli/api');
|
|
7
|
+
const { readConfig, DEFAULT_SERVER } = require('../cli/config');
|
|
8
|
+
const { timestamp, dateStr, shiftDate, createThrottle } = require('./util');
|
|
9
|
+
|
|
10
|
+
/** 服务器对同一 AI Key 每分钟限 30 次请求,两次请求间隔至少 2100 毫秒 */
|
|
11
|
+
const REQUEST_INTERVAL_MS = 2100;
|
|
12
|
+
|
|
13
|
+
/** 服务器单页上限 */
|
|
14
|
+
const PAGE_LIMIT = 100;
|
|
15
|
+
|
|
16
|
+
/** 分页保护上限,防止服务端分页异常导致无限请求 */
|
|
17
|
+
const MAX_PAGES = 500;
|
|
18
|
+
|
|
19
|
+
/** 工序节点状态:1 未完成 2 完成(与后端 ProduceOrderConstants 一致) */
|
|
20
|
+
const NODE_STATE_FINISHED = 2;
|
|
21
|
+
|
|
22
|
+
/** 生产单没有任何未完成工序节点时的兜底列名 */
|
|
23
|
+
const UNCONFIGURED_NODE = '未配置节点';
|
|
24
|
+
|
|
25
|
+
/** 生产单状态到名称的映射(与后端 ProduceOrderConstants 一致;接口给不出名称时兜底) */
|
|
26
|
+
const STATE_LABELS = {
|
|
27
|
+
1: '草稿',
|
|
28
|
+
2: '生产中',
|
|
29
|
+
3: '生产完成',
|
|
30
|
+
4: '待生产',
|
|
31
|
+
9: '撤销',
|
|
32
|
+
11: '待接单',
|
|
33
|
+
12: '拒绝接单',
|
|
34
|
+
13: '待审批',
|
|
35
|
+
14: '拒绝',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 三个看板分组:[标题, 生产单状态集合, 是否只保留交货日期早于今天的生产单]。
|
|
40
|
+
* 三个分组一律按工序节点分列,口径保持一致。
|
|
41
|
+
* 与原脚本相同:生产中=待生产+生产中;结束=生产完成;超期=三种状态且交货日期早于今天。
|
|
42
|
+
*/
|
|
43
|
+
const BOARDS = [
|
|
44
|
+
{ title: '生产中', states: [2, 4], overdueOnly: false },
|
|
45
|
+
{ title: '结束', states: [3], overdueOnly: false },
|
|
46
|
+
{ title: '超期', states: [2, 3, 4], overdueOnly: true },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
/** 款式图 CDN 压缩别名:SCM CDN 用 query 形式,qinsilk 图片用 @! 形式 */
|
|
50
|
+
const SCM_IMAGE_ALIAS = '?x-image-process=style/pic200wh';
|
|
51
|
+
const CDN_IMAGE_ALIAS = '@!pic200wh';
|
|
52
|
+
/** 前端同款占位图路径(商品无款式图时使用) */
|
|
53
|
+
const NO_IMAGE_PATH = '/scm-front/assets/no-content-img-BQFT23u5.png';
|
|
54
|
+
|
|
55
|
+
/** HTML 转义,等价于 Python html.escape(quote=True) */
|
|
56
|
+
function escapeHtml(value) {
|
|
57
|
+
return String(value)
|
|
58
|
+
.replace(/&/g, '&')
|
|
59
|
+
.replace(/</g, '<')
|
|
60
|
+
.replace(/>/g, '>')
|
|
61
|
+
.replace(/"/g, '"')
|
|
62
|
+
.replace(/'/g, ''');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 取第一个非空值(null、undefined、空字符串视为空),转为去空白字符串 */
|
|
66
|
+
function text(raw) {
|
|
67
|
+
return raw === null || raw === undefined || raw === '' ? '' : String(raw).trim();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 拉取一组状态的生产单并分页取全量。
|
|
72
|
+
* 时间范围:业务日期近 days 天(含今天)。后端 SQL 对业务日期结束时间用严格小于比较,
|
|
73
|
+
* 因此结束日期传明天(today+1)才能包含今天的生产单,等价于原脚本 timeRules 的 lastNdays 规则。
|
|
74
|
+
* agent 接口的 state 参数只接受单个状态,因此按状态分别分页取全量再合并;
|
|
75
|
+
* 分页间按生产单号去重(服务端跨页可能重复返回),每个状态的去重计数与接口声明的总条数不一致时报错。
|
|
76
|
+
*/
|
|
77
|
+
async function fetchBoardOrders(states, days, overdueOnly, throttle) {
|
|
78
|
+
const from = shiftDate(-(days - 1));
|
|
79
|
+
const to = shiftDate(1);
|
|
80
|
+
const today = dateStr();
|
|
81
|
+
const orders = [];
|
|
82
|
+
const seen = new Set();
|
|
83
|
+
for (const state of states) {
|
|
84
|
+
let declared = null;
|
|
85
|
+
let uniqueCount = 0;
|
|
86
|
+
let fetched = 0;
|
|
87
|
+
let page = 1;
|
|
88
|
+
do {
|
|
89
|
+
const data = await throttle(() => call('GET', '/api/agent/v1/produce/list', { state, from, to, page, limit: PAGE_LIMIT }));
|
|
90
|
+
if (declared === null && typeof data.total === 'number') {
|
|
91
|
+
declared = data.total;
|
|
92
|
+
}
|
|
93
|
+
for (const row of data.list || []) {
|
|
94
|
+
const key = text(row.ordersSn);
|
|
95
|
+
// 生产单号为空时不参与去重,避免多张无单号的单被错误合并
|
|
96
|
+
if (key && seen.has(key)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (key) {
|
|
100
|
+
seen.add(key);
|
|
101
|
+
}
|
|
102
|
+
orders.push(row);
|
|
103
|
+
uniqueCount += 1;
|
|
104
|
+
}
|
|
105
|
+
fetched = (data.list || []).length;
|
|
106
|
+
page += 1;
|
|
107
|
+
} while (fetched === PAGE_LIMIT && page <= MAX_PAGES);
|
|
108
|
+
if (declared !== null && uniqueCount !== declared) {
|
|
109
|
+
throw new Error(`生产单数据不完整:状态 ${state} 接口声明 ${declared} 条,实际获取 ${uniqueCount} 条`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!overdueOnly) {
|
|
113
|
+
return orders;
|
|
114
|
+
}
|
|
115
|
+
// 超期分组:只保留计划交货日期早于今天的生产单;未设置交货日期的不算超期(与原脚本 before 规则一致)
|
|
116
|
+
return orders.filter((row) => {
|
|
117
|
+
const delivery = text(row.deliveryTime);
|
|
118
|
+
return delivery && delivery < today;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 工序节点显示名:节点名为空时归入「未配置节点」列 */
|
|
123
|
+
function nodeKey(node) {
|
|
124
|
+
return text(node.workProcessName) || UNCONFIGURED_NODE;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 工序显示顺序:showOrder 缺失或非法时用节点在列表中的位置兜底 */
|
|
128
|
+
function nodeOrder(node, fallback) {
|
|
129
|
+
const value = Number(node.showOrder);
|
|
130
|
+
return Number.isInteger(value) ? value : fallback;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 按 SCM 前端 dealWithPic 的规则给款式图加压缩别名 */
|
|
134
|
+
function aliasImage(url) {
|
|
135
|
+
if (!url || url.includes('?') || url.includes('@!')) {
|
|
136
|
+
return url;
|
|
137
|
+
}
|
|
138
|
+
if (url.includes('is-scm.cdn.qinsilk.com') || url.includes('is-scm-dev.cdn.qinsilk.com')) {
|
|
139
|
+
return url + SCM_IMAGE_ALIAS;
|
|
140
|
+
}
|
|
141
|
+
if (url.includes('qinsilk.com')) {
|
|
142
|
+
return url + CDN_IMAGE_ALIAS;
|
|
143
|
+
}
|
|
144
|
+
return url;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 从接口行构造看板卡片 */
|
|
148
|
+
function buildCard(order) {
|
|
149
|
+
const ordersSn = text(order.ordersSn);
|
|
150
|
+
const batchSn = text(order.produceBatchSn);
|
|
151
|
+
const state = order.state;
|
|
152
|
+
return {
|
|
153
|
+
ordersSn,
|
|
154
|
+
displaySn: batchSn || ordersSn || '未填写单号',
|
|
155
|
+
goodsSn: text(order.goodsSn),
|
|
156
|
+
goodsImg: text(order.goodsImgUrl),
|
|
157
|
+
state,
|
|
158
|
+
stateName: STATE_LABELS[state] || text(order.stateName) || '未知状态',
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 把每张生产单挂到它全部未完成工序节点列下(扇出)。
|
|
164
|
+
* 同一张单会出现在多个节点列,卡片数因此大于生产单数——这反映「这些节点都还没做完」。
|
|
165
|
+
* 去重按(生产单, 节点列名)生效:同一张单在每个节点列内只出现一次,同名节点也只挂一张卡。
|
|
166
|
+
* 没有任何未完成节点的单(未配置工序,或工序已全部完成)只进「未配置节点」列一次。
|
|
167
|
+
* 列顺序取该列所有节点 showOrder 的最小值,再按列名字典序。
|
|
168
|
+
*/
|
|
169
|
+
function splitNodes(orderRows) {
|
|
170
|
+
const columns = new Map();
|
|
171
|
+
const unconfigured = [];
|
|
172
|
+
for (const order of orderRows) {
|
|
173
|
+
const card = buildCard(order);
|
|
174
|
+
const nodes = (order.workProcesses || []).filter((node) => node && typeof node === 'object');
|
|
175
|
+
const pending = nodes.map((node, index) => ({ node, index })).filter(({ node }) => node.state !== NODE_STATE_FINISHED);
|
|
176
|
+
if (!pending.length) {
|
|
177
|
+
unconfigured.push(card);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const attached = new Set();
|
|
181
|
+
for (const { node, index } of pending) {
|
|
182
|
+
const name = nodeKey(node);
|
|
183
|
+
if (attached.has(name)) {
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
attached.add(name);
|
|
187
|
+
let entry = columns.get(name);
|
|
188
|
+
if (!entry) {
|
|
189
|
+
entry = { name, cards: [], order: nodeOrder(node, index) };
|
|
190
|
+
columns.set(name, entry);
|
|
191
|
+
}
|
|
192
|
+
entry.order = Math.min(entry.order, nodeOrder(node, index));
|
|
193
|
+
entry.cards.push(card);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const ordered = [...columns.values()].sort((a, b) => (a.order - b.order) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
197
|
+
return { columns: ordered, unconfigured };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 分组统计:orders 按生产单号去重计数;cards 是节点列里实际渲染的卡片总数(扇出后大于单数);
|
|
202
|
+
* columns 是工序节点列数;largest 是最大单列卡片数。
|
|
203
|
+
*/
|
|
204
|
+
function boardSummary(columns, unconfigured) {
|
|
205
|
+
const orders = new Set();
|
|
206
|
+
for (const column of columns) {
|
|
207
|
+
for (const card of column.cards) {
|
|
208
|
+
orders.add(card.ordersSn);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
for (const card of unconfigured) {
|
|
212
|
+
orders.add(card.ordersSn);
|
|
213
|
+
}
|
|
214
|
+
const cards = columns.reduce((sum, column) => sum + column.cards.length, 0) + unconfigured.length;
|
|
215
|
+
const largest = columns.reduce((max, column) => Math.max(max, column.cards.length), 0);
|
|
216
|
+
return { orders: orders.size, cards, columns: columns.length, largest };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 渲染一张卡片:款式图 + 单号 + 款号,点击跳转生产单详情页。
|
|
221
|
+
* 无图片时用前端同款占位图;图片加载失败时 onerror 也切到占位图,避免浏览器裂图。
|
|
222
|
+
*/
|
|
223
|
+
function cardHtml(card, server) {
|
|
224
|
+
const target = `${server}/scm-front/#/production/production-order-details?ordersSn=${encodeURIComponent(card.ordersSn)}`;
|
|
225
|
+
const title = `${card.displaySn} ${card.stateName}` + (card.goodsSn ? ` ${card.goodsSn}` : '');
|
|
226
|
+
const placeholder = escapeHtml(`${server}${NO_IMAGE_PATH}`);
|
|
227
|
+
const source = card.goodsImg ? escapeHtml(aliasImage(card.goodsImg)) : placeholder;
|
|
228
|
+
const image = `<img src="${source}" alt="${escapeHtml(card.goodsSn || card.displaySn)}" loading="lazy" referrerpolicy="no-referrer" onerror="this.onerror=null;this.src='${placeholder}'">`;
|
|
229
|
+
const goods = card.goodsSn ? `<span class="card-goods">${escapeHtml(card.goodsSn)}</span>` : '';
|
|
230
|
+
const inner = `${image}<span class="card-sn">${escapeHtml(card.displaySn)}</span>${goods}`;
|
|
231
|
+
if (!card.ordersSn) {
|
|
232
|
+
return `<div class="card" title="${escapeHtml(title)}">${inner}</div>`;
|
|
233
|
+
}
|
|
234
|
+
return `<a class="card" title="${escapeHtml(title)}" href="${escapeHtml(target)}" target="_blank" rel="noopener noreferrer">${inner}</a>`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 渲染一个卡片容器;无卡片时显示占位说明 */
|
|
238
|
+
function renderCards(cards, server, emptyText, containerClass) {
|
|
239
|
+
if (!cards.length) {
|
|
240
|
+
return `<p class="empty">${escapeHtml(emptyText)}</p>`;
|
|
241
|
+
}
|
|
242
|
+
const attr = containerClass ? ` class="${containerClass}"` : '';
|
|
243
|
+
return `<div${attr}>${cards.map((card) => cardHtml(card, server)).join('')}</div>`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** 渲染一个节点列:列头显示节点名与卡片数,列内为卡片 */
|
|
247
|
+
function nodeColumnHtml(name, cards, server, emptyHeader = false) {
|
|
248
|
+
const headClass = emptyHeader ? 'node-head node-head-empty' : 'node-head';
|
|
249
|
+
return (
|
|
250
|
+
'<div class="node-column">'
|
|
251
|
+
+ `<div class="${headClass}">${escapeHtml(name)}<em>${cards.length}</em></div>`
|
|
252
|
+
+ renderCards(cards, server, '暂无生产单', 'node-cells')
|
|
253
|
+
+ '</div>'
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 渲染一个分组的节点表:未配置节点列固定放第一列,避免随节点增多被排到最右侧 */
|
|
258
|
+
function renderNodeTable(columns, unconfigured, server) {
|
|
259
|
+
const output = unconfigured.length ? [nodeColumnHtml(UNCONFIGURED_NODE, unconfigured, server, true)] : [];
|
|
260
|
+
output.push(...columns.map((column) => nodeColumnHtml(column.name, column.cards, server)));
|
|
261
|
+
if (!output.length) {
|
|
262
|
+
return '<p class="empty">暂无生产单</p>';
|
|
263
|
+
}
|
|
264
|
+
return `<div class="node-scroll">${output.join('')}</div>`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** 渲染一个分组区块:标题 + 单数/卡片数/节点数统计 + 节点表 */
|
|
268
|
+
function renderBoard(board, server) {
|
|
269
|
+
const summary = boardSummary(board.columns, board.unconfigured);
|
|
270
|
+
const detail = `${summary.orders} 张生产单 ${summary.cards} 张卡片 ${summary.columns} 个工序节点`;
|
|
271
|
+
return (
|
|
272
|
+
`<section><h2>${escapeHtml(board.title)}<span class="count">${escapeHtml(detail)}</span></h2>`
|
|
273
|
+
+ renderNodeTable(board.columns, board.unconfigured, server)
|
|
274
|
+
+ '</section>'
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** 组装完整 HTML 文档(样式与说明文案与原脚本一致) */
|
|
279
|
+
function render(boards, day, server, days) {
|
|
280
|
+
const title = `生产节点看板 - ${day}`;
|
|
281
|
+
const sections = boards.map((board) => renderBoard(board, server)).join('');
|
|
282
|
+
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)}</title><style>
|
|
283
|
+
*{box-sizing:border-box}body{font-family:"Microsoft YaHei",Arial,sans-serif;color:#1f2937;margin:24px;background:#f1f5f9}.report{max-width:100%}h1{font-size:24px;margin:0 0 6px}h2{font-size:20px;margin:0 0 14px;display:flex;align-items:baseline;gap:10px}.count{font-size:13px;font-weight:400;color:#64748b}p{color:#64748b;margin:0 0 16px}section{background:#fff;border:1px solid #dbe3ee;border-radius:8px;padding:18px}section+section{margin-top:26px}
|
|
284
|
+
/* 每个节点固定等宽,横向滚动,列宽不再参差 */
|
|
285
|
+
.node-scroll{display:flex;gap:12px;overflow-x:auto;padding-bottom:6px;align-items:flex-start}
|
|
286
|
+
.node-column{flex:0 0 208px;width:208px;display:flex;flex-direction:column;gap:8px}
|
|
287
|
+
.node-head{border:1px solid #cbd5e1;background:#e8f1fb;color:#1e3a5f;font-size:14px;font-weight:600;padding:8px 10px;border-radius:6px;overflow-wrap:break-word;word-break:break-word}
|
|
288
|
+
.node-head em{font-style:normal;font-weight:400;color:#64748b;margin-left:6px}.node-head-empty{background:#fef3c7;color:#92400e}
|
|
289
|
+
.node-cells{display:flex;flex-direction:column;gap:8px;min-height:60px;padding:8px;border:1px solid #e5eaf2;border-radius:8px;background:#f8fafc}
|
|
290
|
+
.card{display:flex;flex-direction:column;align-items:center;gap:4px;background:#fff;border:1px solid #dbe3ee;border-radius:6px;padding:6px;text-decoration:none;color:#1f2937}.card:hover{border-color:#2563eb;box-shadow:0 1px 6px rgba(37,99,235,.18)}
|
|
291
|
+
.card img{width:100%;height:108px;object-fit:contain;background:#fff;border-radius:4px;display:block}
|
|
292
|
+
.card-sn{font-size:13px;font-weight:600;word-break:break-all;text-align:center}.card-goods{font-size:12px;color:#64748b;word-break:break-all;text-align:center}.hint{font-size:13px;color:#94a3b8;margin:-8px 0 18px}.empty{text-align:center;color:#94a3b8;font-size:13px;padding:18px 0;margin:0}
|
|
293
|
+
</style></head><body><div class="report"><h1>${escapeHtml(title)}</h1><p>订单范围:业务日期近 ${escapeHtml(days)} 天 | 生产中=待生产+生产中 结束=生产完成 超期=待生产+生产中+生产完成且计划交货日期早于今天 | 一张生产单出现在它的全部未完成工序节点下,卡片数大于生产单数属正常</p><p class="hint">Shift + 鼠标滚轮可以横向移动看板</p>${sections}</div></body></html>`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** 校验生成结果:文档结构完整、分组数量正确 */
|
|
297
|
+
function validate(document, expectedSections) {
|
|
298
|
+
if (!document.startsWith('<!doctype html>') || !document.endsWith('</html>')) {
|
|
299
|
+
throw new Error('生成的 HTML 结构不完整');
|
|
300
|
+
}
|
|
301
|
+
const sectionCount = document.split('<section>').length - 1;
|
|
302
|
+
if (sectionCount !== expectedSections) {
|
|
303
|
+
throw new Error('生成的 HTML 分组数量不正确');
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* 生成生产节点看板 HTML。
|
|
309
|
+
* 数据流:每个分组按状态分别分页取全量(合并去重并与声明总数比对)→
|
|
310
|
+
* 按未完成工序节点扇出分列 → 渲染三组看板。
|
|
311
|
+
* 输出文件已存在时报错,不覆盖。
|
|
312
|
+
*/
|
|
313
|
+
async function generate(argv = {}) {
|
|
314
|
+
const days = argv.days === undefined ? 7 : Number(argv.days);
|
|
315
|
+
if (!Number.isFinite(days) || days < 1) {
|
|
316
|
+
throw new Error('--days 必须大于 0');
|
|
317
|
+
}
|
|
318
|
+
if (days > 365) {
|
|
319
|
+
throw new Error('--days 不能超过 365');
|
|
320
|
+
}
|
|
321
|
+
const throttle = createThrottle(REQUEST_INTERVAL_MS);
|
|
322
|
+
const config = readConfig() || {};
|
|
323
|
+
const server = config.server || DEFAULT_SERVER;
|
|
324
|
+
|
|
325
|
+
const boards = [];
|
|
326
|
+
for (const board of BOARDS) {
|
|
327
|
+
const rows = await fetchBoardOrders(board.states, days, board.overdueOnly, throttle);
|
|
328
|
+
const { columns, unconfigured } = splitNodes(rows);
|
|
329
|
+
boards.push({ title: board.title, columns, unconfigured });
|
|
330
|
+
const summary = boardSummary(columns, unconfigured);
|
|
331
|
+
console.log(`${board.title}:${summary.orders} 张生产单,${summary.cards} 张卡片,${summary.columns} 个工序节点,单节点最多 ${summary.largest} 张`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const document = render(boards, dateStr(), server, days);
|
|
335
|
+
validate(document, BOARDS.length);
|
|
336
|
+
const outDir = argv.out || process.cwd();
|
|
337
|
+
const filePath = path.resolve(outDir, `生产节点看板${timestamp()}.html`);
|
|
338
|
+
if (fs.existsSync(filePath)) {
|
|
339
|
+
throw new Error(`输出文件已存在:${filePath}`);
|
|
340
|
+
}
|
|
341
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
342
|
+
fs.writeFileSync(filePath, document, 'utf8');
|
|
343
|
+
const counts = boards.map((board) => {
|
|
344
|
+
const summary = boardSummary(board.columns, board.unconfigured);
|
|
345
|
+
return `${board.title} ${summary.orders} 张`;
|
|
346
|
+
});
|
|
347
|
+
return {
|
|
348
|
+
filePaths: [filePath],
|
|
349
|
+
summary: `生产节点看板已生成:${counts.join('、')}(近 ${days} 天)`,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
module.exports = { generate };
|