Compare commits
No commits in common. "main" and "codex/mkh-pack-warehouse-join-0919" have entirely different histories.
main
...
codex/mkh-
5
.gitignore
vendored
5
.gitignore
vendored
@ -62,8 +62,3 @@ tmall-board-speed-check.json
|
||||
# Local verification artifacts
|
||||
/outputs/
|
||||
.git-status.txt
|
||||
|
||||
# 本地一次性探查/调试产物(不进仓库)
|
||||
_probe_*.json
|
||||
_probe_*.js
|
||||
_work-scratch-*/
|
||||
|
||||
@ -18,8 +18,6 @@ npm run remote:dev
|
||||
|
||||
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
|
||||
|
||||
拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。
|
||||
|
||||
## 验证与构建
|
||||
|
||||
```bash
|
||||
|
||||
@ -3,17 +3,12 @@ FROM node:22-bookworm-slim
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
# 每日进销存定时截图需要 Chromium 与中文字体:
|
||||
# · fonts-noto-cjk —— 否则截图里所有中文都会变成方块
|
||||
# · playwright install --with-deps chromium —— 默认镜像不带浏览器
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates fonts-noto-cjk \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund \
|
||||
&& npx playwright install --with-deps chromium \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.npm/_cacache
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@ -10,7 +10,6 @@
|
||||
"test": "node --test test/*.test.js",
|
||||
"check": "npm run syntax && npm test",
|
||||
"alert:check": "node src/services/alertChecker.js",
|
||||
"inventory:import-official": "node scripts/importDailyInventoryOfficial.js",
|
||||
"product-analysis:sync-replica": "node scripts/sync_jian_sun_product_analysis_tables.js"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -1,97 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 导入聚水潭「进销存按仓导出」到每日进销存校正表
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/importDailyInventoryOfficial.js <业务日期> <xlsx 路径>
|
||||
* node scripts/importDailyInventoryOfficial.js 2026-09-26 ./进销存_按仓_20260926.xlsx
|
||||
*
|
||||
* 作用:
|
||||
* 1) 解析导出文件,校验「期初 + Σ流水 = 期末」逐仓闭合
|
||||
* 2) 写入本地表 daily_inventory_official(同一业务日整体覆盖)
|
||||
* 3) 回写本地快照:官方期初 → 上一业务日期末、官方期末 → 该业务日期末
|
||||
*
|
||||
* 页面右上角「导入聚水潭进销存」走的是同一个后端接口(POST /api/daily-inventory/official-import)。
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parseWorkbook, importOfficial, checkBalance } = require('../src/services/dailyInventoryOfficial');
|
||||
const snapshotDb = require('../src/config/dailyInventorySnapshotDb');
|
||||
const { DAILY_INVENTORY_BASELINE } = require('../src/config/dailyInventoryBaseline');
|
||||
|
||||
function prevDay(bizDate) {
|
||||
const d = new Date(`${bizDate}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [bizDate, file] = process.argv.slice(2);
|
||||
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) {
|
||||
console.error('用法: node scripts/importDailyInventoryOfficial.js <业务日期 YYYY-MM-DD> <xlsx 路径>');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!file || !fs.existsSync(file)) {
|
||||
console.error(`文件不存在: ${file}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { rows, warnings, sheetName } = parseWorkbook(fs.readFileSync(file));
|
||||
console.log(`解析 ${path.basename(file)} [${sheetName}]:${rows.length} 行,提示:${warnings.join(';') || '无'}`);
|
||||
if (!rows.length) {
|
||||
console.error('未解析到仓库行,已中止');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mismatches = checkBalance(rows);
|
||||
if (mismatches.length) {
|
||||
console.warn(`⚠ 以下 ${mismatches.length} 个仓「期初 + 流水 ≠ 期末」:`);
|
||||
mismatches.forEach((m) => console.warn(` ${m.warehouse}: 差 ${m.diff}`));
|
||||
} else {
|
||||
console.log('恒等式校验:期初 + 流水 = 期末,逐仓闭合 ✓');
|
||||
}
|
||||
|
||||
const result = await importOfficial(bizDate, rows);
|
||||
console.log(`写入 official:${result.imported} 行(${bizDate})`);
|
||||
if (result.unknownWarehouses.length) {
|
||||
console.warn(`⚠ 未匹配到仓库 ID(仅按仓库名存档):${result.unknownWarehouses.join('、')}`);
|
||||
}
|
||||
if (result.missingWarehouses.length) {
|
||||
console.warn(`⚠ 清单中未出现在导出里的仓库:${result.missingWarehouses.join('、')}`);
|
||||
}
|
||||
|
||||
const byId = new Map(DAILY_INVENTORY_BASELINE.map((b) => [String(b.wmsCoId), b]));
|
||||
const known = rows.filter((r) => r.wmsCoId != null);
|
||||
const prevDate = prevDay(bizDate);
|
||||
const prev = await snapshotDb.fetchSnapshot(prevDate);
|
||||
const prevCount = await snapshotDb.upsertSnapshot(prevDate, known
|
||||
.filter((r) => r.openingQty != null)
|
||||
.map((r) => ({
|
||||
wmsCoId: r.wmsCoId,
|
||||
warehouse: r.warehouse,
|
||||
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
|
||||
closingQty: Number(r.openingQty),
|
||||
skuCount: Number(prev.get(String(r.wmsCoId))?.skuCount || 0),
|
||||
takenAt: null,
|
||||
})));
|
||||
const current = await snapshotDb.fetchSnapshot(bizDate);
|
||||
const currentCount = await snapshotDb.upsertSnapshot(bizDate, known
|
||||
.filter((r) => r.closingQty != null)
|
||||
.map((r) => ({
|
||||
wmsCoId: r.wmsCoId,
|
||||
warehouse: r.warehouse,
|
||||
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
|
||||
closingQty: Number(r.closingQty),
|
||||
skuCount: Number(current.get(String(r.wmsCoId))?.skuCount || 0),
|
||||
takenAt: null,
|
||||
})));
|
||||
console.log(`快照回写:${prevDate}(官方期初 → 期末)${prevCount} 行,${bizDate}(官方期末)${currentCount} 行`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('导入失败:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 手动同步一次每日库存快照
|
||||
*
|
||||
* 用途:补跑 / 排障 / 挂到系统计划任务(Windows 任务计划程序、Linux crontab)。
|
||||
* 业务库没有按天的历史库存结存表,本脚本把 26 仓实时库存落一份到本地快照表,
|
||||
* 使「今日期初 = 上一业务日期末」能够自动滚动。
|
||||
*
|
||||
* 运行:node scripts/sync-daily-inventory-snapshot.cjs
|
||||
* 业务日自动取库存快照库 rpt.pm_stockMonitor 的最新日期(T-1)。
|
||||
*
|
||||
* 生产环境下后端每日 01:30 的定时任务(src/jobs/dailyInventorySnapshotJob.js)会自动执行,
|
||||
* 本脚本仅作为兜底与手动补跑手段。
|
||||
*/
|
||||
process.env.CANGKU_SKIP_BOOTSTRAP = '1';
|
||||
|
||||
const { syncOnce } = require('../src/jobs/dailyInventorySnapshotJob');
|
||||
|
||||
syncOnce('manual')
|
||||
.then((result) => {
|
||||
console.log('[快照同步] 结果:', JSON.stringify(result));
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[快照同步] 失败:', error.message);
|
||||
})
|
||||
.finally(() => process.exit(0));
|
||||
@ -1,153 +0,0 @@
|
||||
'use strict';
|
||||
/**
|
||||
* 用业务库(JSReportReplica)反算流水,校验已导入的聚水潭官方进销存数据。
|
||||
* 用法(先起隧道): cd backend && node scripts/validateDailyInventoryFromBiz.js 2026-10-06
|
||||
*
|
||||
* 注意:业务库 jst_inventoryInfo 是 T-1 同步快照,只能精确校验「昨天」的期末;
|
||||
* 更早的历史日会带自然变动(实测 10-05 差 0.1%~1.2%),属正常。
|
||||
*/
|
||||
const mysql = require('mysql2/promise');
|
||||
const sql = require('mssql');
|
||||
|
||||
const BIZ_DATE = process.argv[2] || '2026-10-06';
|
||||
|
||||
const SYS = {
|
||||
host: '127.0.0.1',
|
||||
port: 13307,
|
||||
user: 'cangku_67c48c6660be787f',
|
||||
password: 'wIJugzi26RQHF4J4BgnwxCxxecp157goR93M_IXEhqc',
|
||||
database: 'shujuku2_system',
|
||||
};
|
||||
|
||||
// 业务库能还原的 4 个流水列(与 dailyInventoryFlows.js 口径一致,这里加单日上界)
|
||||
const FLOWS = [
|
||||
{
|
||||
field: 'purchase_in',
|
||||
label: '采购进仓',
|
||||
sql: `SELECT p.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(pi.qty,0) AS DECIMAL(18,2))) AS qty
|
||||
FROM rpt.jst_purchase p JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id
|
||||
WHERE p.wms_co_id IN (%W%) AND p.po_date >= @from AND p.po_date < @to
|
||||
AND ISNULL(p.status,'') IN ('Confirmed','Finished') GROUP BY p.wms_co_id`,
|
||||
},
|
||||
{
|
||||
field: 'purchase_return',
|
||||
label: '采购退货',
|
||||
sql: `SELECT o.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(oi.qty,0) AS DECIMAL(18,2))) AS qty
|
||||
FROM rpt.jst_purchaseout o JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id
|
||||
WHERE o.wms_co_id IN (%W%) AND o.io_date >= @from AND o.io_date < @to
|
||||
GROUP BY o.wms_co_id`,
|
||||
},
|
||||
{
|
||||
field: 'sales_out',
|
||||
label: '销售出仓',
|
||||
sql: `SELECT o.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(oi.qty,0) AS DECIMAL(18,2))) AS qty
|
||||
FROM rpt.jst_Orders o JOIN rpt.jst_OrderItem oi ON oi.o_id = o.o_id
|
||||
WHERE o.wms_co_id IN (%W%) AND o.send_date >= @from AND o.send_date < @to
|
||||
AND ISNULL(o.status,'') NOT IN ('Delete','Cancelled') GROUP BY o.wms_co_id`,
|
||||
},
|
||||
{
|
||||
field: 'sales_return',
|
||||
label: '销售退货',
|
||||
sql: `SELECT r.wms_co_id AS wmsCoId, SUM(CAST(ISNULL(ri.r_qty,0) AS DECIMAL(18,2))) AS qty
|
||||
FROM rpt.jst_returnAndRefundInfo r JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id
|
||||
WHERE r.wms_co_id IN (%W%) AND r.as_date >= @from AND r.as_date < @to
|
||||
AND ISNULL(r.type,'') IN (N'普通退货',N'拒收退货',N'换货',N'维修') GROUP BY r.wms_co_id`,
|
||||
},
|
||||
];
|
||||
|
||||
(async () => {
|
||||
const sys = await mysql.createConnection(SYS);
|
||||
const [official] = await sys.query(
|
||||
'SELECT wms_co_id, warehouse, opening_qty, closing_qty, purchase_in, purchase_return, sales_out, sales_return FROM daily_inventory_official WHERE DATE(biz_date)=? ORDER BY closing_qty DESC',
|
||||
[BIZ_DATE]
|
||||
);
|
||||
if (!official.length) {
|
||||
console.log('官方表无 ' + BIZ_DATE + ' 数据');
|
||||
await sys.end();
|
||||
return;
|
||||
}
|
||||
const ids = official.map((r) => String(r.wms_co_id)).filter(Boolean);
|
||||
const clause = ids.map((_, i) => '@w' + i).join(',');
|
||||
const wparams = {};
|
||||
ids.forEach((v, i) => (wparams['w' + i] = v));
|
||||
|
||||
// 业务库凭据从系统库 database_configs 现取(不落盘)
|
||||
const [cfgRows] = await sys.query(
|
||||
"SELECT host, port, database_name, username, password FROM database_configs WHERE name='cloud_database_public' LIMIT 1"
|
||||
);
|
||||
const biz = cfgRows[0];
|
||||
await sql.connect({
|
||||
server: biz.host,
|
||||
port: Number(biz.port),
|
||||
database: biz.database_name,
|
||||
user: biz.username,
|
||||
password: biz.password,
|
||||
options: { encrypt: false, trustServerCertificate: true },
|
||||
connectionTimeout: 20000,
|
||||
requestTimeout: 180000,
|
||||
});
|
||||
|
||||
const from = BIZ_DATE + ' 00:00:00';
|
||||
const d = new Date(BIZ_DATE + 'T00:00:00Z');
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
const to = d.toISOString().slice(0, 10) + ' 00:00:00';
|
||||
|
||||
const computed = {};
|
||||
for (const f of FLOWS) {
|
||||
const req = new sql.Request();
|
||||
req.input('from', sql.DateTime, new Date(from));
|
||||
req.input('to', sql.DateTime, new Date(to));
|
||||
ids.forEach((v, i) => req.input('w' + i, sql.NVarChar, v));
|
||||
const r = await req.query(f.sql.replace('%W%', clause));
|
||||
r.recordset.forEach((row) => {
|
||||
computed[String(row.wmsCoId)] = computed[String(row.wmsCoId)] || {};
|
||||
computed[String(row.wmsCoId)][f.field] = Number(row.qty) || 0;
|
||||
});
|
||||
}
|
||||
|
||||
console.log('业务库口径反算 vs 聚水潭官方导出 —— ' + BIZ_DATE);
|
||||
console.log('(业务库仅能还原 4/12 列;采购退货/销售出仓为流出,官方值取绝对值比较)\n');
|
||||
const pad = (s, n) => String(s).padEnd(n);
|
||||
console.log(
|
||||
pad('仓库', 26) + pad('采购进仓 官方/业务库', 26) + pad('采购退货 官方/业务库', 26) + pad('销售出仓 官方/业务库', 26) + '销售退货 官方/业务库'
|
||||
);
|
||||
let diffs = { purchase_in: 0, sales_out: 0, purchase_return: 0, sales_return: 0 };
|
||||
for (const o of official) {
|
||||
const c = computed[String(o.wms_co_id)] || {};
|
||||
const cells = ['purchase_in', 'purchase_return', 'sales_out', 'sales_return'].map((k) => {
|
||||
const ov = Math.abs(Number(o[k]) || 0);
|
||||
const cv = Math.abs(Number(c[k]) || 0);
|
||||
if (Math.abs(ov - cv) > 0.005) diffs[k]++;
|
||||
return pad(ov + ' / ' + cv, 26);
|
||||
});
|
||||
console.log(pad(o.warehouse, 26) + cells.join(''));
|
||||
}
|
||||
console.log('\n不一致仓数:' + JSON.stringify(diffs) + ' / 共 ' + official.length + ' 仓');
|
||||
|
||||
// 期末 vs 业务库实时库存(仅作量级参考,业务库只有实时库存)
|
||||
const req2 = new sql.Request();
|
||||
ids.forEach((v, i) => req2.input('w' + i, sql.NVarChar, v));
|
||||
const inv = await req2.query(
|
||||
`SELECT wms_co_id AS wmsCoId, SUM(CAST(ISNULL(qty,0) AS DECIMAL(18,2))) AS qty
|
||||
FROM rpt.jst_inventoryInfo WHERE wms_co_id IN (${clause}) GROUP BY wms_co_id`
|
||||
);
|
||||
const invMap = {};
|
||||
inv.recordset.forEach((r) => (invMap[String(r.wmsCoId)] = Number(r.qty) || 0));
|
||||
console.log('\n【参考】官方' + BIZ_DATE + '期末 vs 业务库实时库存(业务库无历史快照,仅看量级)');
|
||||
official
|
||||
.slice()
|
||||
.sort((a, b) => Number(b.closing_qty) - Number(a.closing_qty))
|
||||
.slice(0, 8)
|
||||
.forEach((o) => {
|
||||
const live = invMap[String(o.wms_co_id)] || 0;
|
||||
const off = Number(o.closing_qty) || 0;
|
||||
const pct = off ? (((live - off) / off) * 100).toFixed(1) : '-';
|
||||
console.log(pad(o.warehouse, 26) + '期末 ' + off + ' | 实时 ' + live + ' | 差 ' + pct + '%');
|
||||
});
|
||||
|
||||
await sql.close();
|
||||
await sys.end();
|
||||
})().catch((e) => {
|
||||
console.log('FAIL', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,73 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getReplenishExportRows,
|
||||
getReplenishList,
|
||||
} = require('../services/binReplenishService');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const EXPORT_COLUMNS = [
|
||||
{ key: 'skuId', title: '商品编码' },
|
||||
{ key: 'productName', title: '品名' },
|
||||
{ key: 'supplier', title: '供应商' },
|
||||
{ key: 'statusLabel', title: '状态' },
|
||||
{ key: 'pickQty', title: '拣货位库存' },
|
||||
{ key: 'storageQty', title: '存储位库存' },
|
||||
{ key: 'totalQty', title: '现有库存合计' },
|
||||
{ key: 'orderQty', title: '当日订单' },
|
||||
{ key: 'excess', title: '超出订单数' },
|
||||
{ key: 'suggestQty', title: '建议补货量' },
|
||||
{ key: 'shortfall', title: '存储缺口' },
|
||||
];
|
||||
|
||||
/** GET /api/bin-replenish/list —— 缺货补货监控列表(订单驱动) */
|
||||
router.get('/list', async (req, res, next) => {
|
||||
try {
|
||||
const payload = await getReplenishList({
|
||||
warehouse: req.query.warehouse,
|
||||
keyword: req.query.keyword,
|
||||
status: req.query.status,
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
refresh: req.query.refresh,
|
||||
});
|
||||
res.json({ success: true, data: payload });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/bin-replenish/export —— 服务端全量导出 */
|
||||
router.get('/export', async (req, res, next) => {
|
||||
try {
|
||||
const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const payload = await getReplenishExportRows({
|
||||
warehouse: req.query.warehouse,
|
||||
keyword: req.query.keyword,
|
||||
status: req.query.status,
|
||||
});
|
||||
const fileName = `${payload.warehouse.shortName}缺货补货监控_${payload.orderDate || ''}`;
|
||||
|
||||
sendWorkbookDownload(res, {
|
||||
fileName,
|
||||
format,
|
||||
sheets: [{
|
||||
name: '缺货补货监控',
|
||||
header: EXPORT_COLUMNS.map((column) => column.title),
|
||||
rows: payload.rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
|
||||
}],
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/bin-replenish/ping —— 路由存活探针(401 也代表已注册) */
|
||||
router.get('/ping', (req, res) => {
|
||||
res.json({ success: true, data: { ok: true } });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,563 +0,0 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
const snapshotDb = require('../config/dailyInventorySnapshotDb');
|
||||
const officialDb = require('../config/dailyInventoryOfficialDb');
|
||||
const {
|
||||
DAILY_INVENTORY_BASELINE,
|
||||
FLOW_COLUMNS,
|
||||
} = require('../config/dailyInventoryBaseline');
|
||||
const { fetchFlows, AVAILABLE_FLOW_FIELDS } = require('../services/dailyInventoryFlows');
|
||||
const { deriveAndStoreOpeningSnapshot } = require('../services/dailyInventoryOpening');
|
||||
const {
|
||||
parseWorkbook,
|
||||
normalizeRecords,
|
||||
checkBalance,
|
||||
importOfficial,
|
||||
} = require('../services/dailyInventoryOfficial');
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 20 * 1024 * 1024 } });
|
||||
|
||||
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */
|
||||
const AS_OF_TIME = '01:00';
|
||||
|
||||
/**
|
||||
* 「月度」发货窗口天数:滚动 30 天。
|
||||
* 库龄阈值:3 个月 = 90 天、12 个月 = 365 天(按日历天折算)。
|
||||
*/
|
||||
const SHIP_WINDOW_DAYS = 30;
|
||||
const AGE_3M_DAYS = 90;
|
||||
const AGE_12M_DAYS = 365;
|
||||
|
||||
/** 结果缓存:单次查询要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
|
||||
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
let cachedResult = null;
|
||||
let cachedResultAt = 0;
|
||||
|
||||
const toNumber = (v) => {
|
||||
if (v == null) return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/** 快照日 + 次日 01:00 作为统一的库龄/分级基准 */
|
||||
function nextDayAsOf(bizDate) {
|
||||
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
|
||||
const d = new Date(`${bizDate}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
|
||||
}
|
||||
|
||||
/** 发货窗口:[asOf - 30 天, asOf),滚动一个月 */
|
||||
function shipWindow(asOf) {
|
||||
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
|
||||
const from = new Date(to.getTime() - SHIP_WINDOW_DAYS * 86400000);
|
||||
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
|
||||
return { shipFrom: fmt(from), shipTo: fmt(to) };
|
||||
}
|
||||
|
||||
async function fetchLatestBizDate() {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
|
||||
);
|
||||
return rows[0]?.d || null;
|
||||
}
|
||||
|
||||
/** 上一自然日(日历天,用于取「期初 = 昨日结存」) */
|
||||
function prevDay(bizDate) {
|
||||
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
|
||||
const d = new Date(`${bizDate}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() - 1);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
|
||||
function buildInClause(values, prefix) {
|
||||
const params = {};
|
||||
const clause = values
|
||||
.map((v, i) => {
|
||||
const key = `${prefix}${i}`;
|
||||
params[key] = v;
|
||||
return `@${key}`;
|
||||
})
|
||||
.join(',');
|
||||
return { clause, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分级规则(库龄 + 周转率双维度),口径与业务给定一致:
|
||||
* 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警
|
||||
* 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流
|
||||
* 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流
|
||||
* 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞
|
||||
* 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞
|
||||
* 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸
|
||||
* 边界:库龄正好 90 天不计入「慢流」(业务描述为 > 3 个月),正好 365 天仍属「3~12 个月」。
|
||||
*/
|
||||
function buildLevelExpr() {
|
||||
const t = 'b.turnoverPct';
|
||||
return `
|
||||
CASE
|
||||
WHEN b.stockQty <= 0 OR ${t} IS NULL OR b.ageDays IS NULL THEN NULL
|
||||
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 200 THEN 'warn'
|
||||
WHEN b.ageDays < ${AGE_3M_DAYS} AND ${t} >= 200 THEN 'slow'
|
||||
WHEN b.ageDays > ${AGE_3M_DAYS} AND ${t} >= 30 AND ${t} < 100 THEN 'slow'
|
||||
WHEN b.ageDays >= ${AGE_3M_DAYS} AND b.ageDays <= ${AGE_12M_DAYS} AND ${t} >= 100 THEN 'stale'
|
||||
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 100 AND ${t} < 200 THEN 'stale'
|
||||
WHEN b.ageDays > ${AGE_12M_DAYS} AND ${t} >= 200 THEN 'zombie'
|
||||
ELSE NULL
|
||||
END`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个仓库的周转率与四级库存量(一次查询覆盖全部仓库)。
|
||||
*
|
||||
* 口径:
|
||||
* 库存周转天数(周转率)= 月度发货总件数 / 月度日均库存件数 × 100%
|
||||
* · 月度发货总件数 = 该仓近 30 天已发货订单件数(jst_OrderItem × jst_Orders,按 send_date,排除 Delete/Cancelled)
|
||||
* · 月度日均库存件数 = 该仓聚水潭库存 jst_inventoryInfo(无历史结存表,以当日库存近似月度日均)
|
||||
* 库龄(天) = DATEDIFF(基准时刻, SKU 建档时间 created)
|
||||
*
|
||||
* SKU 关联链路:jst_inventoryInfo.sku_id = jst_OrderItem.sku_id = pm_stockMonitor.sku_id(实测崔桥 1852/1852 命中)
|
||||
*/
|
||||
async function fetchGradeMetrics(wmsCoIds, bizDate, asOf) {
|
||||
if (!wmsCoIds.length || !bizDate || !asOf) return new Map();
|
||||
const invIn = buildInClause(wmsCoIds, 'w');
|
||||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||||
const { shipFrom, shipTo } = shipWindow(asOf);
|
||||
const level = buildLevelExpr();
|
||||
|
||||
const sqlText = `
|
||||
WITH inv AS (
|
||||
SELECT i.wms_co_id AS wmsCoId,
|
||||
i.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
|
||||
FROM rpt.jst_inventoryInfo i
|
||||
WHERE i.wms_co_id IN (${invIn.clause})
|
||||
GROUP BY i.wms_co_id, i.sku_id
|
||||
),
|
||||
created AS (
|
||||
SELECT m.sku_id AS skuId, MIN(m.created) AS created
|
||||
FROM rpt.pm_stockMonitor m
|
||||
WHERE CONVERT(VARCHAR(10), m.date, 23) = @bizDate
|
||||
GROUP BY m.sku_id
|
||||
),
|
||||
ship AS (
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
oi.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS shipQty
|
||||
FROM rpt.jst_OrderItem oi
|
||||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||||
AND o.send_date >= @shipFrom
|
||||
AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
GROUP BY o.wms_co_id, oi.sku_id
|
||||
),
|
||||
base AS (
|
||||
SELECT v.wmsCoId,
|
||||
v.skuId,
|
||||
v.stockQty,
|
||||
ISNULL(s.shipQty, 0) AS shipQty,
|
||||
DATEDIFF(DAY, c.created, @asOf) AS ageDays,
|
||||
CASE WHEN v.stockQty <= 0 THEN NULL
|
||||
ELSE ISNULL(s.shipQty, 0) * 100.0 / v.stockQty END AS turnoverPct
|
||||
FROM inv v
|
||||
LEFT JOIN ship s ON s.wmsCoId = v.wmsCoId AND s.skuId = v.skuId
|
||||
LEFT JOIN created c ON c.skuId = v.skuId
|
||||
)
|
||||
SELECT b.wmsCoId,
|
||||
SUM(CASE WHEN ABS(b.stockQty) > 0.0001 THEN 1 ELSE 0 END) AS skuCount,
|
||||
SUM(b.stockQty) AS totalStock,
|
||||
SUM(b.shipQty) AS totalShip,
|
||||
SUM(CASE WHEN b.ageDays IS NULL OR b.turnoverPct IS NULL OR b.stockQty <= 0 THEN 1 ELSE 0 END) AS ungradedSku,
|
||||
CASE WHEN SUM(b.stockQty) > 0
|
||||
THEN CAST(SUM(b.shipQty) * 100.0 / SUM(b.stockQty) AS DECIMAL(18, 2))
|
||||
ELSE NULL END AS turnoverPct,
|
||||
SUM(CASE WHEN ${level} = 'warn' THEN b.stockQty ELSE 0 END) AS warnLevel,
|
||||
SUM(CASE WHEN ${level} = 'slow' THEN b.stockQty ELSE 0 END) AS slowLevel,
|
||||
SUM(CASE WHEN ${level} = 'stale' THEN b.stockQty ELSE 0 END) AS staleLevel,
|
||||
SUM(CASE WHEN ${level} = 'zombie' THEN b.stockQty ELSE 0 END) AS zombieLevel
|
||||
FROM base b
|
||||
GROUP BY b.wmsCoId`;
|
||||
|
||||
const rows = await cloudDb.query(sqlText, {
|
||||
...invIn.params,
|
||||
...shipIn.params,
|
||||
bizDate,
|
||||
asOf,
|
||||
shipFrom,
|
||||
shipTo,
|
||||
}, { requestTimeout: 120000 });
|
||||
|
||||
const map = new Map();
|
||||
rows.forEach((r) => {
|
||||
map.set(String(r.wmsCoId), {
|
||||
skuCount: toNumber(r.skuCount) || 0,
|
||||
gradeStock: toNumber(r.totalStock) || 0,
|
||||
shipQty: toNumber(r.totalShip) || 0,
|
||||
turnoverDays: toNumber(r.turnoverPct),
|
||||
warnLevel: toNumber(r.warnLevel) || 0,
|
||||
slowLevel: toNumber(r.slowLevel) || 0,
|
||||
staleLevel: toNumber(r.staleLevel) || 0,
|
||||
zombieLevel: toNumber(r.zombieLevel) || 0,
|
||||
ungradedSku: toNumber(r.ungradedSku) || 0,
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 实时库存表的数据时效(一次取全局即可) */
|
||||
async function fetchInventoryTakenAt() {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
|
||||
);
|
||||
return rows[0]?.takenAt || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/daily-inventory
|
||||
* 每日进销存:26 个仓库 ×(期初 / 期末 / 周转率 / 四级库存量)
|
||||
*
|
||||
* 数据源分工(全部来自数据库,无硬编码业务数值):
|
||||
* - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存;
|
||||
* 快照缺失时按 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写
|
||||
* - 期末数量:jst_inventoryInfo 实时库存(按 wms_co_id 单仓)
|
||||
* - 库存周转天数 / 四级库存量:近 30 天发货 × 实时库存 × SKU 建档日期
|
||||
* - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货,
|
||||
* 见 services/dailyInventoryFlows.js),其余 8 列业务库无单据表,返回 null(前端显示「—」)
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const force = String(req.query?.refresh || '') === '1';
|
||||
const requestedDate = typeof req.query?.date === 'string'
|
||||
&& /^\d{4}-\d{2}-\d{2}$/.test(req.query.date) ? req.query.date : null;
|
||||
const systemBizDate = await fetchLatestBizDate().catch(() => null);
|
||||
const latestBizDate = requestedDate || systemBizDate;
|
||||
// 查看历史业务日时,业务库只有「当前」的实时库存与流水,不能当成该日的期末/发生额
|
||||
const staleMetrics = Boolean(requestedDate && systemBizDate && requestedDate !== systemBizDate);
|
||||
const cacheKey = `${latestBizDate}|${staleMetrics ? 'hist' : 'live'}`;
|
||||
if (!force && cachedResult && cachedResult.latestBizDate === cacheKey
|
||||
&& Date.now() - cachedResultAt < RESULT_CACHE_TTL_MS) {
|
||||
return res.json({ success: true, data: cachedResult, cached: true });
|
||||
}
|
||||
|
||||
const asOf = nextDayAsOf(latestBizDate);
|
||||
const openingDate = prevDay(latestBizDate);
|
||||
const wmsCoIds = DAILY_INVENTORY_BASELINE
|
||||
.map((b) => b.wmsCoId)
|
||||
.filter((v) => v != null);
|
||||
|
||||
const [gradeMap, takenAt, officialMap, officialDates] = await Promise.all([
|
||||
fetchGradeMetrics(wmsCoIds, latestBizDate, asOf).catch((e) => {
|
||||
console.error('[daily-inventory] 分级计算失败:', e.message);
|
||||
return new Map();
|
||||
}),
|
||||
fetchInventoryTakenAt().catch(() => null),
|
||||
officialDb.fetchOfficial(latestBizDate).catch(() => new Map()),
|
||||
officialDb.listOfficialDates(30).catch(() => []),
|
||||
]);
|
||||
|
||||
// 期初 = 上一业务日的期末快照;若定时任务漏跑,顺手把当前业务日的快照补齐
|
||||
let openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map());
|
||||
const todayMap = await snapshotDb.fetchSnapshot(latestBizDate).catch(() => new Map());
|
||||
const missing = DAILY_INVENTORY_BASELINE
|
||||
.filter((b) => b.wmsCoId != null && !todayMap.has(String(b.wmsCoId)))
|
||||
.map((b) => {
|
||||
const g = gradeMap.get(String(b.wmsCoId));
|
||||
return {
|
||||
wmsCoId: b.wmsCoId,
|
||||
warehouse: b.warehouse,
|
||||
dbWarehouse: b.dbWarehouse,
|
||||
closingQty: Number(g?.gradeStock ?? 0),
|
||||
skuCount: Number(g?.skuCount ?? 0),
|
||||
takenAt: takenAt ? new Date(takenAt) : null,
|
||||
};
|
||||
});
|
||||
if (missing.length) {
|
||||
await snapshotDb.upsertSnapshot(latestBizDate, missing).catch((e) => {
|
||||
console.error('[daily-inventory] 快照补写失败:', e.message);
|
||||
});
|
||||
}
|
||||
|
||||
// 期初快照也缺失时,用 pm_stockMonitor(精确)/ 实时库存扣流水(近似)推导并补写
|
||||
if (openingDate && !openingMap.size) {
|
||||
await deriveAndStoreOpeningSnapshot({
|
||||
cloudDb,
|
||||
snapshotDb,
|
||||
baseline: DAILY_INVENTORY_BASELINE,
|
||||
openingDate,
|
||||
latestBizDate,
|
||||
takenAt,
|
||||
}).catch((e) => {
|
||||
console.error('[daily-inventory] 期初快照推导失败:', e.message);
|
||||
});
|
||||
openingMap = await snapshotDb.fetchSnapshot(openingDate).catch(() => new Map());
|
||||
}
|
||||
|
||||
// 流水列:自期初时刻(最新业务日 01:00)以来的单据合计(带符号,流出为负)
|
||||
let flowMap = new Map();
|
||||
if (openingDate) {
|
||||
flowMap = await fetchFlows(cloudDb, wmsCoIds, `${latestBizDate} 01:00:00`).catch((e) => {
|
||||
console.error('[daily-inventory] 流水取数失败:', e.message);
|
||||
return new Map();
|
||||
});
|
||||
}
|
||||
|
||||
const items = DAILY_INVENTORY_BASELINE.map((base) => {
|
||||
const official = officialMap.get(base.warehouse) || null;
|
||||
const grade = base.wmsCoId != null ? gradeMap.get(String(base.wmsCoId)) : null;
|
||||
const opening = base.wmsCoId != null ? openingMap.get(String(base.wmsCoId)) : null;
|
||||
const itemFlows = base.wmsCoId != null ? flowMap.get(String(base.wmsCoId)) : null;
|
||||
|
||||
// 12 个流水列:聚水潭官方导出优先,缺失时回退业务库推算(历史业务日不做回退)
|
||||
const flows = {};
|
||||
const flowSources = {};
|
||||
for (const col of FLOW_COLUMNS) {
|
||||
const fromOfficial = official?.flows?.[col.field];
|
||||
if (fromOfficial != null) {
|
||||
flows[col.field] = Number(fromOfficial);
|
||||
flowSources[col.field] = 'official';
|
||||
continue;
|
||||
}
|
||||
const derived = itemFlows ? itemFlows[col.field] : null;
|
||||
if (derived != null && !staleMetrics) {
|
||||
flows[col.field] = Number(derived.toFixed(2));
|
||||
flowSources[col.field] = 'derived';
|
||||
continue;
|
||||
}
|
||||
flows[col.field] = null;
|
||||
flowSources[col.field] = 'none';
|
||||
}
|
||||
|
||||
// 期末:官方期末优先;无官方时,历史业务日不给实时库存(口径不对)
|
||||
const gradeStock = grade?.gradeStock ?? null;
|
||||
let closingQty = gradeStock;
|
||||
let closingSource = 'derived';
|
||||
if (official?.closingQty != null) {
|
||||
closingQty = Number(official.closingQty);
|
||||
closingSource = 'official';
|
||||
} else if (staleMetrics) {
|
||||
closingQty = null;
|
||||
closingSource = 'none';
|
||||
}
|
||||
|
||||
// 期初:官方期初优先,其次本地快照
|
||||
let openingQty = opening ? opening.closingQty : null;
|
||||
let openingSource = opening ? 'snapshot' : 'none';
|
||||
if (official?.openingQty != null) {
|
||||
openingQty = Number(official.openingQty);
|
||||
openingSource = 'official';
|
||||
} else if (staleMetrics) {
|
||||
openingQty = null;
|
||||
openingSource = 'none';
|
||||
}
|
||||
|
||||
return {
|
||||
warehouse: base.warehouse,
|
||||
dbWarehouse: base.dbWarehouse,
|
||||
openingQty,
|
||||
openingQtyExtra: null,
|
||||
...flows,
|
||||
closingQty,
|
||||
closingSkuCount: grade?.skuCount ?? null,
|
||||
closingTakenAt: takenAt,
|
||||
shipQty30d: grade?.shipQty ?? null,
|
||||
turnoverDays: grade?.turnoverDays ?? null,
|
||||
gradeStock,
|
||||
warnLevel: grade?.warnLevel ?? null,
|
||||
slowLevel: grade?.slowLevel ?? null,
|
||||
staleLevel: grade?.staleLevel ?? null,
|
||||
zombieLevel: grade?.zombieLevel ?? null,
|
||||
ungradedSku: grade?.ungradedSku ?? null,
|
||||
hasGrade: Boolean(grade),
|
||||
source: {
|
||||
opening: openingSource,
|
||||
closing: closingSource,
|
||||
flows: flowSources,
|
||||
hasOfficial: Boolean(official),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 合计行:便于与表格总额核对
|
||||
const sum = (k) => items.reduce((acc, it) => acc + (Number(it[k]) || 0), 0);
|
||||
const totals = { warehouse: '合计', dbWarehouse: '', isTotal: true };
|
||||
for (const k of ['openingQty', 'openingQtyExtra', ...FLOW_COLUMNS.map((c) => c.field), 'closingQty', 'warnLevel', 'slowLevel', 'staleLevel', 'zombieLevel']) {
|
||||
// 若所有仓库该列均无数据(null),合计同样置空,避免误读为 0
|
||||
totals[k] = items.every((it) => it[k] == null) ? null : Number(sum(k).toFixed(2));
|
||||
}
|
||||
// 合计行周转率按「总发货 / 总库存」重算,避免简单累加失真
|
||||
totals.shipQty30d = Number(sum('shipQty30d').toFixed(2));
|
||||
const totalStock = sum('closingQty');
|
||||
totals.turnoverDays = totalStock > 0
|
||||
? Number((sum('shipQty30d') * 100 / totalStock).toFixed(2))
|
||||
: null;
|
||||
|
||||
const noOpeningCount = items.filter((it) => it.openingQty == null).length;
|
||||
const balanceMismatch = checkBalance([...officialMap.values()]);
|
||||
const officialFlowFields = FLOW_COLUMNS
|
||||
.filter((c) => items.some((it) => it.source.flows[c.field] === 'official'))
|
||||
.map((c) => c.field);
|
||||
const derivedFlowFields = FLOW_COLUMNS
|
||||
.filter((c) => items.some((it) => it.source.flows[c.field] === 'derived'))
|
||||
.map((c) => c.field);
|
||||
|
||||
const payload = {
|
||||
openingDate,
|
||||
latestBizDate,
|
||||
systemBizDate,
|
||||
staleMetrics,
|
||||
asOf,
|
||||
shipWindow: asOf ? { ...shipWindow(asOf), days: SHIP_WINDOW_DAYS } : null,
|
||||
flowColumns: FLOW_COLUMNS,
|
||||
flowColumnsAvailable: AVAILABLE_FLOW_FIELDS,
|
||||
flowColumnsOfficial: officialFlowFields,
|
||||
flowColumnsDerived: derivedFlowFields,
|
||||
noOpeningCount,
|
||||
official: {
|
||||
hasData: officialMap.size > 0,
|
||||
warehouseCount: officialMap.size,
|
||||
availableDates: officialDates,
|
||||
balanceMismatch,
|
||||
},
|
||||
items,
|
||||
totals,
|
||||
};
|
||||
cachedResult = payload;
|
||||
cachedResultAt = Date.now();
|
||||
return res.json({ success: true, data: payload });
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory] 查询失败:', error.message);
|
||||
return res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 官方期初 = 上一业务日期末、官方期末 = 当日期末。
|
||||
* 导入后回写本地快照,让「今日期初 = 昨日结存」的自动推算也对齐聚水潭口径。
|
||||
*/
|
||||
async function syncSnapshotFromOfficial(bizDate, rows) {
|
||||
const byId = new Map(DAILY_INVENTORY_BASELINE.map((b) => [String(b.wmsCoId), b]));
|
||||
const known = rows.filter((r) => r.wmsCoId != null);
|
||||
if (!known.length) return { prev: 0, current: 0 };
|
||||
const prevDate = prevDay(bizDate);
|
||||
|
||||
let prevCount = 0;
|
||||
if (prevDate) {
|
||||
const prev = await snapshotDb.fetchSnapshot(prevDate).catch(() => new Map());
|
||||
prevCount = await snapshotDb.upsertSnapshot(prevDate, known
|
||||
.filter((r) => r.openingQty != null)
|
||||
.map((r) => ({
|
||||
wmsCoId: r.wmsCoId,
|
||||
warehouse: r.warehouse,
|
||||
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
|
||||
closingQty: Number(r.openingQty),
|
||||
skuCount: Number(prev.get(String(r.wmsCoId))?.skuCount || 0),
|
||||
takenAt: null,
|
||||
}))).catch((e) => {
|
||||
console.error('[daily-inventory] 期初快照回写失败:', e.message);
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
const current = await snapshotDb.fetchSnapshot(bizDate).catch(() => new Map());
|
||||
const currentCount = await snapshotDb.upsertSnapshot(bizDate, known
|
||||
.filter((r) => r.closingQty != null)
|
||||
.map((r) => ({
|
||||
wmsCoId: r.wmsCoId,
|
||||
warehouse: r.warehouse,
|
||||
dbWarehouse: byId.get(String(r.wmsCoId))?.dbWarehouse || null,
|
||||
closingQty: Number(r.closingQty),
|
||||
skuCount: Number(current.get(String(r.wmsCoId))?.skuCount || 0),
|
||||
takenAt: null,
|
||||
}))).catch((e) => {
|
||||
console.error('[daily-inventory] 期末快照回写失败:', e.message);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return { prev: prevCount, current: currentCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/daily-inventory/official
|
||||
* 查询某业务日已导入的聚水潭官方进销存数据
|
||||
*/
|
||||
router.get('/official', async (req, res) => {
|
||||
try {
|
||||
const date = String(req.query?.date || '').trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return res.status(400).json({ success: false, error: '请提供业务日期 date=YYYY-MM-DD' });
|
||||
}
|
||||
const map = await officialDb.fetchOfficial(date);
|
||||
const dates = await officialDb.listOfficialDates(30);
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { date, rows: [...map.values()], availableDates: dates },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory] 官方数据查询失败:', error.message);
|
||||
return res.status(500).json({ success: false, error: '官方数据查询失败', detail: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/daily-inventory/official-import
|
||||
* 导入聚水潭进销存按仓导出(multipart: file=xlsx, date;或 JSON: {date, rows})
|
||||
*/
|
||||
router.post('/official-import', upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
const date = String((req.body && req.body.date) || req.query?.date || '').trim();
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
return res.status(400).json({ success: false, error: '请提供业务日期 date(YYYY-MM-DD)' });
|
||||
}
|
||||
|
||||
let rows = [];
|
||||
let warnings = [];
|
||||
let sheetName = null;
|
||||
if (req.file && req.file.buffer) {
|
||||
const parsed = parseWorkbook(req.file.buffer);
|
||||
rows = parsed.rows;
|
||||
warnings = parsed.warnings;
|
||||
sheetName = parsed.sheetName;
|
||||
if (!rows.length) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '未能从文件解析出仓库行',
|
||||
detail: warnings.join(';') || '未找到含「仓库」列的工作表',
|
||||
});
|
||||
}
|
||||
} else if (req.body && Array.isArray(req.body.rows)) {
|
||||
const parsed = normalizeRecords(req.body.rows);
|
||||
rows = parsed.rows;
|
||||
warnings = parsed.warnings;
|
||||
if (!rows.length) {
|
||||
return res.status(400).json({ success: false, error: 'rows 中未解析到仓库行', detail: warnings.join(';') });
|
||||
}
|
||||
} else {
|
||||
return res.status(400).json({ success: false, error: '请上传 xlsx 文件(字段名 file)或提供 rows 数组' });
|
||||
}
|
||||
|
||||
const result = await importOfficial(date, rows, {
|
||||
source: String((req.body && req.body.source) || 'jst-export'),
|
||||
});
|
||||
const synced = await syncSnapshotFromOfficial(date, rows);
|
||||
cachedResult = null; // 导入后立即使查询缓存失效
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...result,
|
||||
sheetName,
|
||||
warnings,
|
||||
snapshotSynced: synced,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory] 官方数据导入失败:', error.message);
|
||||
return res.status(500).json({ success: false, error: '官方数据导入失败', detail: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,81 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 每日进销存 · 定时截图通知(管理员专用)
|
||||
//
|
||||
// 安全约定:
|
||||
// · 全部接口要求登录且 role === 'admin'
|
||||
// · 响应里只有「是否已配置」与脱敏预览,绝不返回通知中心密钥 / 对象存储密钥
|
||||
// · 收件人名单由后端校验与规范化,前端只能提交姓名文本
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getDailyInventoryNotificationAdminConfig,
|
||||
listRunRecords,
|
||||
saveDailyInventoryNotificationConfig,
|
||||
} = require('../services/dailyInventoryNotificationSettings');
|
||||
const {
|
||||
runDailyInventoryNotificationJob,
|
||||
} = require('../jobs/dailyInventoryNotificationJob');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback) {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[DailyInventoryNotification]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
/** 仅管理员可查看与修改通知设置、执行立即测试 */
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req?.user?.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: '仅管理员可操作通知设置' });
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
router.use(requireAdmin);
|
||||
|
||||
// 读取通知设置(含通知中心 / 对象存储的配置状态与最近执行记录)
|
||||
router.get('/config', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getDailyInventoryNotificationAdminConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载每日进销存通知设置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存通知设置
|
||||
router.put('/config', async (req, res) => {
|
||||
try {
|
||||
const saved = await saveDailyInventoryNotificationConfig(req.body || {});
|
||||
res.json({ success: true, data: { ...saved, admin: await getDailyInventoryNotificationAdminConfig() } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存每日进销存通知设置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 立即测试:真实走一次截图 + 上传 + 通知,但不写入正式幂等记录
|
||||
router.post('/test', async (_req, res) => {
|
||||
try {
|
||||
const result = await runDailyInventoryNotificationJob({ mode: 'test' });
|
||||
res.json({ success: result?.success === true, data: result });
|
||||
} catch (error) {
|
||||
sendError(res, error, '执行每日进销存通知测试失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 最近执行记录(管理员排障用)
|
||||
router.get('/runs', async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(Math.max(Number(req.query?.limit) || 10, 1), 50);
|
||||
res.json({ success: true, data: await listRunRecords(limit) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载每日进销存通知执行记录失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -29,14 +29,9 @@ function sendError(res, error, fallback) {
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
function buildSyncQuery(query = {}) {
|
||||
return { ...query, live: '1', fresh: '1' };
|
||||
}
|
||||
|
||||
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({ success: true, data: await getMkhBomList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载 MKH-BOM 资料列表失败');
|
||||
@ -47,7 +42,6 @@ router.get('/list', async (req, res) => {
|
||||
router.get('/detail', async (req, res) => {
|
||||
try {
|
||||
const skuId = req.query?.skuId || req.query?.comboSkuId;
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({ success: true, data: await getMkhBomDetail(skuId) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载 MKH-BOM 明细失败');
|
||||
@ -108,20 +102,14 @@ router.put('/notification-center', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 手动同步:先清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
|
||||
// 再在同一次请求中直连副本库读取新快照。这样不会被同步前尚未完成的旧列表请求覆盖。
|
||||
router.post('/sync', async (req, res) => {
|
||||
// 手动同步:清空本页服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
|
||||
// 前端「同步」按钮调用后再拉一次列表即可直连副本库取最新快照
|
||||
router.post('/sync', async (_req, res) => {
|
||||
try {
|
||||
const cache = clearMkhBomCache();
|
||||
// fresh=1 是真正重读库存 / 加工单 / 待发货数据的协议。
|
||||
// 只清缓存再传 live=1 会落到旧快照,导致按钮显示“同步成功”却数值不变。
|
||||
const snapshot = await getMkhBomList(buildSyncQuery(req.body?.query));
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.json({ success: true, data: { cache, snapshot } });
|
||||
res.json({ success: true, data: clearMkhBomCache() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '同步 MKH-BOM 数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports._private = { buildSyncQuery };
|
||||
|
||||
@ -1,282 +0,0 @@
|
||||
const express = require('express');
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
|
||||
const {
|
||||
PICKING_BIN_GRADE_RULES,
|
||||
PICKING_BIN_WINDOW_OPTIONS,
|
||||
PICKING_BIN_DEFAULT_DAYS,
|
||||
PICKING_BIN_TOTAL_LABEL,
|
||||
PICKING_BIN_BIN_TYPES,
|
||||
} = require('../config/pickingBinSalesAnalysis');
|
||||
const { fetchPickingBinSkuRows } = require('../services/pickingBinSalesAnalysis');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */
|
||||
const AS_OF_TIME = '01:00';
|
||||
|
||||
/** 结果缓存:一次要扫订单明细 + 仓位明细两张大表,避免连续刷新重复打库 */
|
||||
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const cacheByKey = new Map();
|
||||
|
||||
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
|
||||
const round4 = (v) => Math.round((Number(v) || 0) * 10000) / 10000;
|
||||
|
||||
/** 快照日 + 次日 01:00 作为统一的统计基准时刻 */
|
||||
function nextDayAsOf(bizDate) {
|
||||
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
|
||||
const d = new Date(`${bizDate}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
|
||||
}
|
||||
|
||||
/** 发货窗口:[asOf - N 天, asOf) */
|
||||
function buildShipWindow(asOf, days) {
|
||||
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
|
||||
const from = new Date(to.getTime() - days * 86400000);
|
||||
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
|
||||
return { shipFrom: fmt(from), shipTo: fmt(to) };
|
||||
}
|
||||
|
||||
async function fetchLatestBizDate() {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
|
||||
);
|
||||
return rows[0]?.d || null;
|
||||
}
|
||||
|
||||
async function fetchBinTakenAt() {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT MAX(updateTime) AS takenAt FROM rpt.jst_packInventoryInfo`
|
||||
);
|
||||
return rows[0]?.takenAt || null;
|
||||
}
|
||||
|
||||
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
|
||||
function resolveDays(raw) {
|
||||
const n = Number.parseInt(String(raw ?? ''), 10);
|
||||
return PICKING_BIN_WINDOW_OPTIONS.includes(n) ? n : PICKING_BIN_DEFAULT_DAYS;
|
||||
}
|
||||
|
||||
/** 解析仓库过滤,只允许出现在仓库清单里的 wmsCoId,防止任意拼参 */
|
||||
function resolveWarehouses(raw) {
|
||||
const known = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
|
||||
const requested = String(raw || '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter(Boolean);
|
||||
const picked = requested.length > 0
|
||||
? known.filter((b) => requested.includes(String(b.wmsCoId)))
|
||||
: known;
|
||||
return picked;
|
||||
}
|
||||
|
||||
const GRADE_MAP = new Map(PICKING_BIN_GRADE_RULES.map((g) => [g.key, g]));
|
||||
|
||||
/** 明细行按「仓库 × 商品编码」去重后的商品个数 */
|
||||
function dedupeCount(list) {
|
||||
return new Set(list.map((it) => `${it.wmsCoId}::${it.skuId}`)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/picking-bin-sales-analysis
|
||||
* 拣选库位动销分析:仓库 × 仓位 × 商品编码 → 库存数量 / 商品销量 / 商品日销 / 动销等级
|
||||
* 动销等级按「商品日销」判定(商品整档销量 ÷ 天数),不做仓位分摊。
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const force = String(req.query?.refresh || '') === '1';
|
||||
const days = resolveDays(req.query?.days);
|
||||
const warehouses = resolveWarehouses(req.query?.wmsCoId);
|
||||
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
|
||||
|
||||
const cacheKey = `${days}::${wmsCoIds.join('|')}`;
|
||||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||||
const cached = cacheByKey.get(cacheKey);
|
||||
if (!force && cached && cached.latestBizDate === latestBizDate
|
||||
&& Date.now() - cached.at < RESULT_CACHE_TTL_MS) {
|
||||
return res.json({ success: true, data: cached.payload, cached: true });
|
||||
}
|
||||
|
||||
const asOf = nextDayAsOf(latestBizDate);
|
||||
if (!asOf) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: '业务库暂无库存监控数据,无法定位统计基准日',
|
||||
});
|
||||
}
|
||||
|
||||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||||
const [rows, takenAt] = await Promise.all([
|
||||
fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
|
||||
fetchBinTakenAt().catch(() => null),
|
||||
]);
|
||||
|
||||
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
|
||||
const items = rows.map((r) => ({
|
||||
wmsCoId: r.wmsCoId,
|
||||
binName: r.binName,
|
||||
binType: r.binType,
|
||||
skuId: r.skuId,
|
||||
warehouse: whMap.get(r.wmsCoId) || r.wmsCoId,
|
||||
stockQty: r.stockQty,
|
||||
skuCount: r.skuCount,
|
||||
salesQty: round2(r.salesQty),
|
||||
dailySales: round4(r.dailySales),
|
||||
gradeKey: r.gradeKey,
|
||||
gradeLabel: GRADE_MAP.get(r.gradeKey)?.label || '',
|
||||
}));
|
||||
|
||||
// 每个「仓库 × 商品」只计一次销量,避免同一商品跨多个仓位被重复累加
|
||||
const skuSalesMap = new Map();
|
||||
rows.forEach((r) => {
|
||||
const key = `${r.wmsCoId}::${r.skuId}`;
|
||||
if (!skuSalesMap.has(key)) skuSalesMap.set(key, r.salesQty);
|
||||
});
|
||||
const dedupeSales = (list) => {
|
||||
const seen = new Set();
|
||||
return list.reduce((acc, it) => {
|
||||
const key = `${it.wmsCoId}::${it.skuId}`;
|
||||
if (seen.has(key)) return acc;
|
||||
seen.add(key);
|
||||
return acc + (skuSalesMap.get(key) || 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 各动销等级汇总:商品数按「仓库 × 商品」去重,库存按明细行求和(无重复)
|
||||
const summary = PICKING_BIN_GRADE_RULES.map((grade) => {
|
||||
const hit = items.filter((it) => it.gradeKey === grade.key);
|
||||
const salesQty = round2(dedupeSales(hit));
|
||||
return {
|
||||
key: grade.key,
|
||||
label: grade.label,
|
||||
tip: grade.tip,
|
||||
rowCount: hit.length,
|
||||
skuCount: dedupeCount(hit),
|
||||
stockQty: round2(hit.reduce((acc, it) => acc + it.stockQty, 0)),
|
||||
salesQty,
|
||||
dailySales: round4(salesQty / days),
|
||||
};
|
||||
});
|
||||
|
||||
const dedupeAll = new Set(items.map((it) => `${it.wmsCoId}::${it.skuId}`));
|
||||
const totalSales = round2(dedupeSales(items));
|
||||
const total = {
|
||||
key: '__total__',
|
||||
label: PICKING_BIN_TOTAL_LABEL,
|
||||
rowCount: items.length,
|
||||
skuCount: dedupeAll.size,
|
||||
stockQty: round2(summary.reduce((acc, s) => acc + s.stockQty, 0)),
|
||||
salesQty: totalSales,
|
||||
dailySales: round4(totalSales / days),
|
||||
};
|
||||
|
||||
const payload = {
|
||||
days,
|
||||
windowOptions: PICKING_BIN_WINDOW_OPTIONS,
|
||||
binTypes: [...PICKING_BIN_BIN_TYPES],
|
||||
binTypeOptions: [...PICKING_BIN_BIN_TYPES],
|
||||
latestBizDate,
|
||||
asOf,
|
||||
shipWindow: { shipFrom, shipTo, days },
|
||||
takenAt,
|
||||
grades: PICKING_BIN_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
|
||||
totalLabel: PICKING_BIN_TOTAL_LABEL,
|
||||
warehouses: warehouses.map((b) => ({ wmsCoId: String(b.wmsCoId), warehouse: b.warehouse })),
|
||||
items,
|
||||
summary,
|
||||
total,
|
||||
};
|
||||
|
||||
cacheByKey.set(cacheKey, { latestBizDate, at: Date.now(), payload });
|
||||
return res.json({ success: true, data: payload });
|
||||
} catch (error) {
|
||||
console.error('[picking-bin-sales-analysis] 查询失败:', error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '拣选库位动销分析数据查询失败',
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/picking-bin-sales-analysis/export
|
||||
* 导出库位明细(只含拣选位)。query: days、wmsCoId(逗号分隔)、grade(逗号分隔)、format(xlsx|csv)
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const days = resolveDays(req.query?.days);
|
||||
const warehouses = resolveWarehouses(req.query?.wmsCoId);
|
||||
const wmsCoIds = warehouses.map((b) => String(b.wmsCoId));
|
||||
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
|
||||
const grades = String(req.query?.grade || '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => GRADE_MAP.has(v));
|
||||
|
||||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||||
const asOf = nextDayAsOf(latestBizDate);
|
||||
if (!asOf) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: '业务库暂无库存监控数据,无法定位统计基准日',
|
||||
});
|
||||
}
|
||||
|
||||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||||
// 导出按「仓位 × 商品编码」拆行:一个仓位有 N 个商品就输出 N 行
|
||||
const rows = await fetchPickingBinSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
|
||||
|
||||
const whMap = new Map(warehouses.map((b) => [String(b.wmsCoId), b.warehouse]));
|
||||
const filtered = grades.length > 0
|
||||
? rows.filter((r) => grades.includes(r.gradeKey))
|
||||
: rows;
|
||||
|
||||
const sheetRows = filtered.map((r) => [
|
||||
whMap.get(r.wmsCoId) || r.wmsCoId,
|
||||
r.binName,
|
||||
r.binType,
|
||||
r.skuId,
|
||||
r.stockQty,
|
||||
r.skuCount,
|
||||
round2(r.salesQty),
|
||||
round4(r.allocatedSalesQty),
|
||||
round4(r.dailySales),
|
||||
GRADE_MAP.get(r.gradeKey)?.label || r.gradeKey,
|
||||
days,
|
||||
latestBizDate || '',
|
||||
]);
|
||||
|
||||
const fileName = `拣选库位动销分析-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
|
||||
const { totalRows } = sendWorkbookDownload(res, {
|
||||
fileName,
|
||||
format,
|
||||
sheets: [{
|
||||
name: '库位动销明细',
|
||||
header: [
|
||||
'仓库', '仓位', '类型', '商品编码', '库存数量', '仓位SKU数',
|
||||
`商品销量(近${days}天)`, `分摊销量(近${days}天)`, '商品日销',
|
||||
'动销等级', '统计天数', '统计基准日',
|
||||
],
|
||||
rows: sheetRows,
|
||||
}],
|
||||
});
|
||||
console.log(`[picking-bin-sales-analysis] 导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 仅拣选位, 窗口 ${days} 天`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[picking-bin-sales-analysis] 导出失败:', error.message);
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '拣选库位动销分析导出失败',
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,58 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getReturnInventoryWarningList,
|
||||
buildReturnInventoryWarningExport,
|
||||
} = require('../services/returnInventoryWarningService');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '退货库存预警请求失败') {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ReturnInventoryWarning]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
// 退货库存预警明细列表(固定过滤仓库:销售退仓 横林二仓)
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getReturnInventoryWarningList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载退货库存预警列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 导出退货入库明细(固定过滤仓库:销售退仓 横林二仓)
|
||||
* query: warehouse, date, keyword, format=xlsx|csv
|
||||
* 注意:导出走服务端全量拉取,不受列表分页限制。
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const query = req.query || {};
|
||||
const format = String(query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const result = await buildReturnInventoryWarningExport(query);
|
||||
|
||||
console.log('[ReturnInventoryWarning][export]', {
|
||||
format,
|
||||
warehouse: result.warehouse,
|
||||
date: result.date,
|
||||
rows: result.totalRows,
|
||||
matchedTotal: result.matchedTotal,
|
||||
truncated: result.truncated,
|
||||
});
|
||||
|
||||
sendWorkbookDownload(res, { fileName: result.fileName, format, sheets: result.sheets });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出退货入库明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,161 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getDashboard,
|
||||
runDailyCompare,
|
||||
backfillHistory,
|
||||
getInStockLinesForCode,
|
||||
resolveDiscrepancy,
|
||||
updateInboundStatus,
|
||||
hasComparedToday,
|
||||
todayInShanghai,
|
||||
buildReturnMonitorExport,
|
||||
} = require('../services/returnMonitorService');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '退货入库核销请求失败') {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ReturnMonitor]', {
|
||||
message: error?.message || fallback,
|
||||
status,
|
||||
stack: error?.stack || null,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error?.message || fallback });
|
||||
}
|
||||
|
||||
/**
|
||||
* 看板数据(KPI / 库龄分布 / 超期清单 / 按编码汇总 / 差异清单 / 入库明细)
|
||||
* query: date, overdueDays, status, keyword, page, pageSize, refresh=1
|
||||
*/
|
||||
router.get('/dashboard', async (req, res) => {
|
||||
try {
|
||||
const query = req.query || {};
|
||||
const refresh = String(query.refresh || '') === '1';
|
||||
const bizDate = String(query.date || '').trim() || todayInShanghai();
|
||||
let compare = null;
|
||||
|
||||
// 首次打开或显式刷新时,先跑一轮「同步 + 比对」
|
||||
if (refresh || !(await hasComparedToday(bizDate))) {
|
||||
compare = await runDailyCompare({ date: bizDate, trigger: refresh ? 'manual' : 'api' });
|
||||
}
|
||||
|
||||
const data = await getDashboard(query);
|
||||
res.json({ success: true, data: { ...data, compare } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载退货入库核销看板失败');
|
||||
}
|
||||
});
|
||||
|
||||
/** 手动触发一轮「同步 + 每日比对」;backfillDays 用于补历史(库龄分布/超期清单) */
|
||||
router.post('/sync', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const date = String(body.date || '').trim() || todayInShanghai();
|
||||
const lookbackDays = body.lookbackDays === undefined ? 3 : Number(body.lookbackDays) || 0;
|
||||
|
||||
let backfill = null;
|
||||
if (body.backfillDays) {
|
||||
backfill = await backfillHistory({
|
||||
days: Number(body.backfillDays) || 180,
|
||||
toDate: date,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await runDailyCompare({ date, trigger: 'manual', lookbackDays, bootstrapDays: 0 });
|
||||
res.json({ success: true, data: { ...result, backfill } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '同步退货入库数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 导出看板数据
|
||||
* query: scope=all|detail|overdue|code|discrepancy|age, format=xlsx|csv,
|
||||
* date, overdueDays, keyword, status
|
||||
* xlsx 支持多工作表;csv 只能承载一张表(scope=all 时自动降级为 detail)。
|
||||
*/
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const query = req.query || {};
|
||||
const format = String(query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const requestedScope = String(query.scope || 'all').trim() || 'all';
|
||||
const scope = format === 'csv' && requestedScope === 'all' ? 'detail' : requestedScope;
|
||||
|
||||
const result = await buildReturnMonitorExport({
|
||||
date: query.date,
|
||||
overdueDays: query.overdueDays,
|
||||
keyword: query.keyword,
|
||||
status: query.status,
|
||||
scope,
|
||||
});
|
||||
|
||||
console.log('[ReturnMonitor][export]', {
|
||||
format,
|
||||
scope: result.scope,
|
||||
date: result.date,
|
||||
counts: result.counts,
|
||||
});
|
||||
|
||||
sendWorkbookDownload(res, { fileName: result.fileName, format, sheets: result.sheets });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出退货入库核销数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
/** 某商品编码当前的全部在库入库行(差异清单「手动指定」勾选用) */
|
||||
router.get('/code-lines', async (req, res) => {
|
||||
try {
|
||||
const query = req.query || {};
|
||||
res.json({
|
||||
success: true,
|
||||
data: await getInStockLinesForCode({
|
||||
productCode: query.productCode,
|
||||
page: query.page,
|
||||
pageSize: query.pageSize,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载该商品编码的在库入库行失败');
|
||||
}
|
||||
});
|
||||
|
||||
/** 处理差异清单:mode=fifo 采纳 FIFO 建议;mode=manual 手动指定行 */
|
||||
router.post('/discrepancy/resolve', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = await resolveDiscrepancy({
|
||||
productCode: body.productCode,
|
||||
mode: body.mode === 'manual' ? 'manual' : 'fifo',
|
||||
ids: body.ids,
|
||||
outboundDate: body.outboundDate,
|
||||
operator: req.user?.username || req.user?.name || body.operator,
|
||||
remark: body.remark,
|
||||
});
|
||||
const data = await getDashboard({});
|
||||
res.json({ success: true, data: { result, dashboard: data } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '差异核销失败');
|
||||
}
|
||||
});
|
||||
|
||||
/** 人工标记单行状态(在库 / 已出库 / 已报废) */
|
||||
router.post('/inbound/mark', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const result = await updateInboundStatus({
|
||||
id: body.id,
|
||||
status: body.status,
|
||||
outboundDate: body.outboundDate,
|
||||
operator: req.user?.username || req.user?.name || body.operator,
|
||||
remark: body.remark,
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
sendError(res, error, '更新退货入库状态失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,132 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const {
|
||||
buildExportColumns,
|
||||
getReturnStockWarningExportRows,
|
||||
getReturnStockWarningList,
|
||||
resolveWarehouse,
|
||||
resolveWarehouseKey,
|
||||
} = require('../services/returnStockWarningService');
|
||||
const {
|
||||
clearStockWarningImport,
|
||||
importStockWarning,
|
||||
} = require('../services/returnStockWarningImport');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
const resolveScope = (value) => (['imported', 'replica'].includes(String(value)) ? String(value) : 'all');
|
||||
|
||||
/**
|
||||
* GET /api/return-stock-warning/list —— 退货库存预警列表(客退数据监控,按仓库出数)
|
||||
* warehouse: 仓库标识 cuiqiao / subei / suining / shandong / henan(缺省 = 崔桥工厂仓)
|
||||
*/
|
||||
router.get('/list', async (req, res, next) => {
|
||||
try {
|
||||
const payload = await getReturnStockWarningList({
|
||||
warehouse: req.query.warehouse,
|
||||
page: req.query.page,
|
||||
pageSize: req.query.pageSize,
|
||||
keyword: req.query.keyword,
|
||||
supplier: req.query.supplier,
|
||||
sortBy: req.query.sortBy,
|
||||
sortOrder: req.query.sortOrder,
|
||||
qtyBasis: req.query.qtyBasis,
|
||||
createdAtBasis: req.query.createdAtBasis,
|
||||
scope: resolveScope(req.query.scope),
|
||||
focusDays: req.query.focusDays,
|
||||
alertDays: req.query.alertDays,
|
||||
warningLevel: req.query.warningLevel,
|
||||
priceFocusMin: req.query.priceFocusMin,
|
||||
priceHighMin: req.query.priceHighMin,
|
||||
priceLevel: req.query.priceLevel,
|
||||
returnLimitDays: req.query.returnLimitDays,
|
||||
refresh: req.query.refresh,
|
||||
});
|
||||
res.json({ success: true, data: payload });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/return-stock-warning/export —— 服务端全量导出(xlsx / csv) */
|
||||
router.get('/export', async (req, res, next) => {
|
||||
try {
|
||||
const format = String(req.query.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const keyword = String(req.query.keyword || '').trim();
|
||||
const warehouse = resolveWarehouse(req.query.warehouse);
|
||||
const rows = await getReturnStockWarningExportRows({
|
||||
warehouse: req.query.warehouse,
|
||||
keyword,
|
||||
supplier: req.query.supplier,
|
||||
qtyBasis: req.query.qtyBasis,
|
||||
createdAtBasis: req.query.createdAtBasis,
|
||||
scope: resolveScope(req.query.scope),
|
||||
focusDays: req.query.focusDays,
|
||||
alertDays: req.query.alertDays,
|
||||
warningLevel: req.query.warningLevel,
|
||||
priceFocusMin: req.query.priceFocusMin,
|
||||
priceHighMin: req.query.priceHighMin,
|
||||
priceLevel: req.query.priceLevel,
|
||||
returnLimitDays: req.query.returnLimitDays,
|
||||
refresh: req.query.refresh,
|
||||
});
|
||||
|
||||
const exportColumns = buildExportColumns(warehouse);
|
||||
sendWorkbookDownload(res, {
|
||||
fileName: `${warehouse.shortName}客退数据监控_${keyword || '全部'}`,
|
||||
format,
|
||||
sheets: [{
|
||||
name: `${warehouse.shortName}客退数据监控`,
|
||||
header: exportColumns.map((column) => column.title),
|
||||
rows: rows.map((row) => exportColumns.map((column) => row[column.key])),
|
||||
}],
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/return-stock-warning/import —— 导入聚水潭「箱及仓位库存」导出文件 */
|
||||
router.post('/import', upload.single('file'), async (req, res, next) => {
|
||||
try {
|
||||
if (!req.file || !req.file.buffer) {
|
||||
res.status(400).json({ success: false, message: '请选择要导入的文件(xlsx / xls / csv)' });
|
||||
return;
|
||||
}
|
||||
// multer 按 latin1 解析 multipart 文件名,中文会变成乱码,这里转回 UTF-8
|
||||
const rawName = req.file.originalname || '';
|
||||
const fileName = /[\u00c0-\u00ff]/.test(rawName)
|
||||
? Buffer.from(rawName, 'latin1').toString('utf8')
|
||||
: rawName;
|
||||
|
||||
const result = await importStockWarning({
|
||||
buffer: req.file.buffer,
|
||||
fileName,
|
||||
importedBy: req.user?.username || req.user?.name || null,
|
||||
// 导入落在哪个仓库下:优先取表单字段,其次 query,最后回落默认仓
|
||||
warehouseKey: resolveWarehouseKey(req.body?.warehouse || req.query.warehouse),
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/return-stock-warning/import —— 清除当前仓库的导入,回到系统推算口径 */
|
||||
router.delete('/import', async (req, res, next) => {
|
||||
try {
|
||||
const result = await clearStockWarningImport(resolveWarehouseKey(req.query.warehouse));
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,281 +0,0 @@
|
||||
const express = require('express');
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
|
||||
const {
|
||||
SALES_GRADE_RULES,
|
||||
SALES_TOTAL_LABEL,
|
||||
STOCK_SALES_WINDOW_OPTIONS,
|
||||
STOCK_SALES_DEFAULT_DAYS,
|
||||
} = require('../config/stockSalesAnalysis');
|
||||
const { fetchGradeRows, fetchZeroSaleSkuRows } = require('../services/stockSalesAnalysis');
|
||||
const { sendWorkbookDownload } = require('../services/exportWorkbook');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨跑批,标注 T-1) */
|
||||
const AS_OF_TIME = '01:00';
|
||||
|
||||
/** 结果缓存:一次要扫订单明细 + 库存明细两张大表,避免连续刷新重复打库 */
|
||||
const RESULT_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const cacheByDays = new Map();
|
||||
|
||||
const round2 = (v) => Math.round((Number(v) || 0) * 100) / 100;
|
||||
|
||||
/** 快照日 + 次日 01:00 作为统一的统计基准时刻 */
|
||||
function nextDayAsOf(bizDate) {
|
||||
if (!bizDate || !/^\d{4}-\d{2}-\d{2}$/.test(bizDate)) return null;
|
||||
const d = new Date(`${bizDate}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
return `${d.toISOString().slice(0, 10)} ${AS_OF_TIME}`;
|
||||
}
|
||||
|
||||
/** 发货窗口:[asOf - N 天, asOf) */
|
||||
function buildShipWindow(asOf, days) {
|
||||
const to = new Date(`${asOf.slice(0, 10)}T${AS_OF_TIME}:00Z`);
|
||||
const from = new Date(to.getTime() - days * 86400000);
|
||||
const fmt = (dt) => dt.toISOString().slice(0, 19).replace('T', ' ');
|
||||
return { shipFrom: fmt(from), shipTo: fmt(to) };
|
||||
}
|
||||
|
||||
async function fetchLatestBizDate() {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
|
||||
);
|
||||
return rows[0]?.d || null;
|
||||
}
|
||||
|
||||
async function fetchInventoryTakenAt() {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT MAX(modified) AS takenAt FROM rpt.jst_inventoryInfo`
|
||||
);
|
||||
return rows[0]?.takenAt || null;
|
||||
}
|
||||
|
||||
/** 解析并收敛统计窗口天数,只允许白名单内的取值 */
|
||||
function resolveDays(raw) {
|
||||
const n = Number.parseInt(String(raw ?? ''), 10);
|
||||
return STOCK_SALES_WINDOW_OPTIONS.includes(n) ? n : STOCK_SALES_DEFAULT_DAYS;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/stock-sales-analysis
|
||||
* 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const force = String(req.query?.refresh || '') === '1';
|
||||
const days = resolveDays(req.query?.days);
|
||||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||||
|
||||
const cached = cacheByDays.get(days);
|
||||
if (!force && cached && cached.latestBizDate === latestBizDate
|
||||
&& Date.now() - cached.at < RESULT_CACHE_TTL_MS) {
|
||||
return res.json({ success: true, data: cached.payload, cached: true });
|
||||
}
|
||||
|
||||
const asOf = nextDayAsOf(latestBizDate);
|
||||
if (!asOf) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: '业务库暂无库存监控数据,无法定位统计基准日',
|
||||
});
|
||||
}
|
||||
|
||||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||||
const warehouses = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
|
||||
const wmsCoIds = warehouses.map((b) => b.wmsCoId);
|
||||
|
||||
const [rows, takenAt] = await Promise.all([
|
||||
fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }),
|
||||
fetchInventoryTakenAt().catch(() => null),
|
||||
]);
|
||||
|
||||
// (仓库, 等级) → 指标
|
||||
const matrix = new Map();
|
||||
rows.forEach((r) => {
|
||||
matrix.set(`${r.wmsCoId}::${r.gradeKey}`, r);
|
||||
});
|
||||
|
||||
const buildBucket = (grade, cell) => ({
|
||||
key: grade.key,
|
||||
label: grade.label,
|
||||
tip: grade.tip,
|
||||
skuCount: cell?.skuCount ?? 0,
|
||||
stockQty: round2(cell?.stockQty ?? 0),
|
||||
salesQty: round2(cell?.salesQty ?? 0),
|
||||
dailySales: Number((cell?.dailySales ?? 0).toFixed(2)),
|
||||
});
|
||||
|
||||
const items = warehouses.map((base) => {
|
||||
const id = String(base.wmsCoId);
|
||||
const buckets = SALES_GRADE_RULES.map((grade) => (
|
||||
buildBucket(grade, matrix.get(`${id}::${grade.key}`))
|
||||
));
|
||||
|
||||
const totalSku = buckets.reduce((acc, b) => acc + b.skuCount, 0);
|
||||
const totalStock = buckets.reduce((acc, b) => acc + b.stockQty, 0);
|
||||
const totalSales = round2(buckets.reduce((acc, b) => acc + b.salesQty, 0));
|
||||
|
||||
return {
|
||||
warehouse: base.warehouse,
|
||||
dbWarehouse: base.dbWarehouse,
|
||||
wmsCoId: base.wmsCoId,
|
||||
hasData: totalSku > 0 || totalStock !== 0 || totalSales !== 0,
|
||||
buckets: buckets.map((b) => ({
|
||||
...b,
|
||||
skuRatio: totalSku > 0 ? b.skuCount / totalSku : null,
|
||||
stockRatio: totalStock !== 0 ? b.stockQty / totalStock : null,
|
||||
})),
|
||||
total: {
|
||||
key: '__total__',
|
||||
label: SALES_TOTAL_LABEL,
|
||||
skuCount: totalSku,
|
||||
skuRatio: totalSku > 0 ? 1 : null,
|
||||
stockQty: totalStock,
|
||||
stockRatio: totalStock !== 0 ? 1 : null,
|
||||
salesQty: totalSales,
|
||||
dailySales: Number((totalSales / days).toFixed(2)),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// 全仓合计:各等级跨仓累加 + 汇总
|
||||
const grandBuckets = SALES_GRADE_RULES.map((grade) => {
|
||||
const skuCount = items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.skuCount || 0), 0);
|
||||
const stockQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.stockQty || 0), 0));
|
||||
const salesQty = round2(items.reduce((acc, it) => acc + (it.buckets.find((b) => b.key === grade.key)?.salesQty || 0), 0));
|
||||
return {
|
||||
key: grade.key,
|
||||
label: grade.label,
|
||||
tip: grade.tip,
|
||||
skuCount,
|
||||
stockQty,
|
||||
salesQty,
|
||||
dailySales: Number((salesQty / days).toFixed(2)),
|
||||
};
|
||||
});
|
||||
const grandSku = grandBuckets.reduce((acc, b) => acc + b.skuCount, 0);
|
||||
const grandStock = round2(grandBuckets.reduce((acc, b) => acc + b.stockQty, 0));
|
||||
const grandSales = round2(grandBuckets.reduce((acc, b) => acc + b.salesQty, 0));
|
||||
const grandTotal = {
|
||||
key: '__total__',
|
||||
label: SALES_TOTAL_LABEL,
|
||||
skuCount: grandSku,
|
||||
skuRatio: grandSku > 0 ? 1 : null,
|
||||
stockQty: grandStock,
|
||||
stockRatio: grandStock !== 0 ? 1 : null,
|
||||
salesQty: grandSales,
|
||||
dailySales: Number((grandSales / days).toFixed(2)),
|
||||
};
|
||||
|
||||
const payload = {
|
||||
days,
|
||||
windowOptions: STOCK_SALES_WINDOW_OPTIONS,
|
||||
latestBizDate,
|
||||
asOf,
|
||||
shipWindow: { shipFrom, shipTo, days },
|
||||
takenAt,
|
||||
grades: SALES_GRADE_RULES.map((g) => ({ key: g.key, label: g.label, tip: g.tip })),
|
||||
totalLabel: SALES_TOTAL_LABEL,
|
||||
items,
|
||||
grand: {
|
||||
warehouse: '全部仓库合计',
|
||||
hasData: grandSku > 0,
|
||||
buckets: grandBuckets.map((b) => ({
|
||||
...b,
|
||||
skuRatio: grandSku > 0 ? b.skuCount / grandSku : null,
|
||||
stockRatio: grandStock !== 0 ? b.stockQty / grandStock : null,
|
||||
})),
|
||||
total: grandTotal,
|
||||
},
|
||||
emptyWarehouseCount: items.filter((it) => !it.hasData).length,
|
||||
};
|
||||
|
||||
cacheByDays.set(days, { latestBizDate, at: Date.now(), payload });
|
||||
return res.json({ success: true, data: payload });
|
||||
} catch (error) {
|
||||
console.error('[stock-sales-analysis] 查询失败:', error.message);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '仓库库存动销分析数据查询失败',
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/stock-sales-analysis/zero-sales-export
|
||||
* 导出「销售=0」档的 SKU 级明细。
|
||||
* query: days(统计窗口)、wmsCoId(可选,逗号分隔,不传=全部仓库)、format(xlsx|csv,默认 xlsx)
|
||||
*/
|
||||
router.get('/zero-sales-export', async (req, res) => {
|
||||
try {
|
||||
const days = resolveDays(req.query?.days);
|
||||
const format = String(req.query?.format || 'xlsx').toLowerCase() === 'csv' ? 'csv' : 'xlsx';
|
||||
const latestBizDate = await fetchLatestBizDate().catch(() => null);
|
||||
|
||||
const asOf = nextDayAsOf(latestBizDate);
|
||||
if (!asOf) {
|
||||
return res.status(503).json({
|
||||
success: false,
|
||||
error: '业务库暂无库存监控数据,无法定位统计基准日',
|
||||
});
|
||||
}
|
||||
|
||||
const { shipFrom, shipTo } = buildShipWindow(asOf, days);
|
||||
|
||||
// 仓库过滤:只允许出现在仓库清单里的 wmsCoId,防止任意拼参
|
||||
const known = new Map(
|
||||
DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null)
|
||||
.map((b) => [String(b.wmsCoId), b])
|
||||
);
|
||||
const requested = String(req.query?.wmsCoId || '')
|
||||
.split(',')
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => known.has(v));
|
||||
const wmsCoIds = requested.length > 0 ? requested : [...known.keys()];
|
||||
|
||||
const rows = await fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days });
|
||||
|
||||
const sheetRows = rows.map((r) => {
|
||||
const base = known.get(r.wmsCoId);
|
||||
return [
|
||||
base?.warehouse || r.wmsCoId,
|
||||
r.skuId,
|
||||
r.skuName,
|
||||
r.binName,
|
||||
r.stockQty,
|
||||
r.salesQty,
|
||||
days,
|
||||
latestBizDate || '',
|
||||
];
|
||||
});
|
||||
|
||||
const scopeLabel = requested.length === 1 ? `-${known.get(requested[0]).warehouse}` : '';
|
||||
const fileName = `库存动销分析-销售0明细${scopeLabel}-${(latestBizDate || '').replace(/-/g, '') || '未知日期'}`;
|
||||
const { totalRows } = sendWorkbookDownload(res, {
|
||||
fileName,
|
||||
format,
|
||||
sheets: [{
|
||||
name: '销售=0明细',
|
||||
header: ['仓库', 'SKU编码', 'SKU名称', '系统仓位', '库存数量', `销量(近${days}天)`, '统计天数', '统计基准日'],
|
||||
rows: sheetRows,
|
||||
}],
|
||||
});
|
||||
console.log(`[stock-sales-analysis] 销售=0明细导出: ${totalRows} 行, 仓库 ${wmsCoIds.length} 个, 窗口 ${days} 天`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[stock-sales-analysis] 销售=0明细导出失败:', error.message);
|
||||
// 响应可能已部分写出(sendWorkbookDownload 之后一般不会抛),仅在未发送时回 JSON
|
||||
if (!res.headersSent) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: '销售=0明细导出失败',
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,173 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常 —— HTTP 路由(2026-10-21 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* GET /api/warehouse-logistics-exception/list
|
||||
* query:
|
||||
* date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天)
|
||||
* limit 明细条数上限(默认 500,最大 2000)
|
||||
* force 1 = 绕过 5 分钟结果缓存
|
||||
* GET /api/warehouse-logistics-exception/export
|
||||
* 同 /list 口径的不分页明细(导出用,上限 5000)
|
||||
*
|
||||
* 每天 09:10 推 Excel 到钉钉群(2026-10-22 新增):
|
||||
* GET /api/warehouse-logistics-exception/notify/settings 读配置(AppSecret 只回掩码)
|
||||
* PUT /api/warehouse-logistics-exception/notify/settings 保存配置(AppSecret 留空=不变)
|
||||
* POST /api/warehouse-logistics-exception/notify/run 立即推送一次(force / dryRun)
|
||||
* GET /api/warehouse-logistics-exception/notify/state 上次推送结果
|
||||
*
|
||||
* 挂载见 src/index.js:`/api/warehouse-logistics-exception` + authMiddleware
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getLogisticsExceptions,
|
||||
getLogisticsExceptionExportRows,
|
||||
} = require('../services/warehouseLogisticsExceptionService');
|
||||
const {
|
||||
CENTERS, EXCEPTION_TYPES, LOOKBACK_DAYS, VERIFY_HOUR,
|
||||
} = require('../services/warehouseLogisticsExceptionRegistry');
|
||||
const {
|
||||
getWarehouseLogisticsExceptionNotifyAdminConfig,
|
||||
loadWarehouseLogisticsExceptionNotifyState,
|
||||
missingFields,
|
||||
saveWarehouseLogisticsExceptionNotifyConfig,
|
||||
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
|
||||
} = require('../services/warehouseLogisticsExceptionNotifySettings');
|
||||
const {
|
||||
pushWarehouseLogisticsExceptionNotify,
|
||||
} = require('../jobs/warehouseLogisticsExceptionNotifyJob');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback) {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[WarehouseLogisticsException]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
// 转运异常总览:12 个发货仓异常单量 + 明细
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const data = await getLogisticsExceptions(req.query || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载物流转运异常失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 导出用:与 /list 同一套口径,只是把明细上限放开(不分页)
|
||||
// 页面筛选(仓 / 异常类型)由前端用同一份过滤函数处理,保证导出的就是页面看到的
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
const data = await getLogisticsExceptionExportRows(req.query || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出物流转运异常失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 口径元数据(不查库,秒回):页面文案与图例用它,避免前后端各写一套
|
||||
router.get('/meta', (_req, res) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
verifyHour: VERIFY_HOUR,
|
||||
lookbackDays: LOOKBACK_DAYS,
|
||||
centers: CENTERS.map((c) => ({ key: c.key, name: c.name })),
|
||||
exceptionTypes: EXCEPTION_TYPES,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// ── 每天推送到钉钉群 ────────────────────────────────────────────────────────
|
||||
|
||||
// 读配置:AppSecret 只回掩码 + 是否已配置,明文永不出后端
|
||||
router.get('/notify/settings', async (_req, res) => {
|
||||
try {
|
||||
const [config, state] = await Promise.all([
|
||||
getWarehouseLogisticsExceptionNotifyAdminConfig(),
|
||||
loadWarehouseLogisticsExceptionNotifyState(),
|
||||
]);
|
||||
res.json({ success: true, data: { ...config, lastState: state || {} } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '读取异常通知配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存配置;AppSecret 留空表示「沿用已存的」,不必每次重填
|
||||
router.put('/notify/settings', async (req, res) => {
|
||||
try {
|
||||
const data = await saveWarehouseLogisticsExceptionNotifyConfig(req.body || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) || 400;
|
||||
console.error('[WarehouseLogisticsException] 保存通知配置失败:', error?.message);
|
||||
res.status(status).json({ success: false, error: error.message || '保存异常通知配置失败' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/notify/state', async (_req, res) => {
|
||||
try {
|
||||
const state = await loadWarehouseLogisticsExceptionNotifyState();
|
||||
res.json({ success: true, data: state || {} });
|
||||
} catch (error) {
|
||||
sendError(res, error, '读取推送状态失败');
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 立即推送一次(页面「立即推送」/「预览」/「测试通道」)。
|
||||
* body:
|
||||
* dryRun true = 只组装消息、不发送(返回正文全文,业务先核对口径)
|
||||
* probe true = 只发一句「通道测试」文本,不查业务数据(验 webhook 通不通)
|
||||
* force true = 忽略「今天已经推过」(默认手动触发就是 true,除非显式给 false)
|
||||
* date 指定核验日
|
||||
*/
|
||||
router.post('/notify/run', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
const dryRun = body.dryRun === true || body.dryRun === 'true';
|
||||
const probe = body.probe === true || body.probe === 'true';
|
||||
const force = body.force === undefined ? true : (body.force === true || body.force === 'true');
|
||||
if (!dryRun) {
|
||||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
|
||||
const missing = missingFields(config);
|
||||
if (missing.length) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `请先补全并保存:${missing.join(' / ')}`,
|
||||
data: { missingFields: missing },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
const result = await pushWarehouseLogisticsExceptionNotify({
|
||||
dryRun,
|
||||
probe,
|
||||
force,
|
||||
date: body.date || undefined,
|
||||
});
|
||||
// 查完这一轮记得让页面上的状态同步(lastState 由前端重新拉 settings 获取)
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
const status = Number(error?.status) || 500;
|
||||
console.error('[WarehouseLogisticsException] 推送失败:', {
|
||||
message: error?.message,
|
||||
code: error?.code,
|
||||
dingtalk: error?.dingtalk,
|
||||
});
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: error.message || '推送到钉钉群失败',
|
||||
data: { code: error?.code ?? null },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,62 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效 —— HTTP 路由(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* GET /api/warehouse-ship-timeliness/list
|
||||
* query:
|
||||
* round 'NOON' | 'AFTERNOON' 手动指定核验轮次;不传按当前时刻自动判定
|
||||
* limit 明细条数上限(默认 500,最大 2000)
|
||||
*
|
||||
* GET /api/warehouse-ship-timeliness/rounds
|
||||
* 只返回今日两轮的描述(页面顶部提示用,不打订单表,秒回)
|
||||
*
|
||||
* 挂载见 src/index.js:`/api/warehouse-ship-timeliness` + authMiddleware
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { getShipTimeliness } = require('../services/warehouseShipTimelinessService');
|
||||
const { describeRounds, CENTERS } = require('../services/warehouseShipTimelinessRegistry');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback) {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[WarehouseShipTimeliness]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
});
|
||||
res.status(status).json({ success: false, error: error.message || fallback });
|
||||
}
|
||||
|
||||
// 时效总览:每个主仓「超时未发」数量 + 超时订单明细
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const data = await getShipTimeliness(req.query || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载仓储中心发货时效失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 仅轮次信息(不查订单)
|
||||
router.get('/rounds', async (_req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
rounds: describeRounds(new Date()),
|
||||
centers: CENTERS.map((c) => ({
|
||||
key: c.key,
|
||||
name: c.name,
|
||||
placeholder: Boolean(c.placeholder),
|
||||
})),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载核验轮次失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -19,8 +19,6 @@ const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app';
|
||||
let pool = null;
|
||||
let activeCloudConfig = null;
|
||||
let cachedCloudConfigCandidates = null;
|
||||
// 负缓存(失败态)的过期时间戳。0 表示没有失败态缓存。
|
||||
let cachedCloudConfigExpiresAt = 0;
|
||||
|
||||
function isPoolUsable(activePool) {
|
||||
return Boolean(activePool && activePool.connected && activePool.healthy !== false);
|
||||
@ -32,11 +30,7 @@ const parsePositiveInt = (value, fallback) => {
|
||||
};
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS, 30000);
|
||||
// 原先默认 600000ms(10 分钟),配合 maxAttempts=2 最坏可挂 20 分钟,用户体感就是「卡死」。
|
||||
// 收敛到 60s:本页最重的冷查实测 16~42s,仍有充足余量。
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 60000);
|
||||
// 解析「系统库配置」失败时的负缓存存活时间。到期后自动重试,使隧道恢复后无需重启后端。
|
||||
const NEGATIVE_CONFIG_CACHE_TTL_MS = parsePositiveInt(process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS, 30000);
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 600000);
|
||||
const CLOUD_DB_POOL_MAX = parsePositiveInt(process.env.CLOUD_DB_POOL_MAX, 10);
|
||||
const CLOUD_DB_POOL_IDLE_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_POOL_IDLE_TIMEOUT_MS, 30000);
|
||||
const LOCAL_CONFIG_DB_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.LOCAL_DB_CONNECT_TIMEOUT_MS, 5000);
|
||||
@ -542,10 +536,6 @@ function normalizeMssqlConfig(rawConfig, meta = {}) {
|
||||
trustServerCertificate: true,
|
||||
connectTimeout: DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
requestTimeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
// 业务库的 DATETIME 存的是北京时间(无时区标记)。mssql 驱动默认 useUTC=true 会把它
|
||||
// 当成 UTC 解析,而本项目所有时间格式化都是用本地 getter 还原墙上时间,两者叠加会让
|
||||
// 显示值整体 +8 小时(例如库里 16:56:07 被显示成次日 00:56:07)。按本地时间解析才对得上。
|
||||
useUTC: false,
|
||||
},
|
||||
pool: {
|
||||
max: CLOUD_DB_POOL_MAX,
|
||||
@ -683,15 +673,7 @@ async function loadCandidateConfig(localPool, profile) {
|
||||
}
|
||||
|
||||
async function resolveCloudConfigCandidates() {
|
||||
// 缓存有效判据:**非 null** 且在有效期内。
|
||||
//
|
||||
// 旧实现是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy —— 一旦系统库
|
||||
// 读取失败一次(隧道抖动),就会把空数组当作合法缓存永久返回,之后永不重试,
|
||||
// 直接抛「业务数据库尚未配置」500,直到重启后端。这是典型的「永久负缓存」反模式。
|
||||
//
|
||||
// 现在:成功态 expiresAt = Infinity(靠 resetPool 显式失效);失败态带 30s TTL,
|
||||
// 到期自动重新解析,隧道恢复后无需人工干预即可自愈。
|
||||
if (cachedCloudConfigCandidates !== null && Date.now() < cachedCloudConfigExpiresAt) {
|
||||
if (cachedCloudConfigCandidates) {
|
||||
return cachedCloudConfigCandidates;
|
||||
}
|
||||
|
||||
@ -723,7 +705,6 @@ async function resolveCloudConfigCandidates() {
|
||||
}
|
||||
|
||||
cachedCloudConfigCandidates = candidates;
|
||||
cachedCloudConfigExpiresAt = Number.POSITIVE_INFINITY;
|
||||
return cachedCloudConfigCandidates;
|
||||
} catch (error) {
|
||||
console.warn('[CloudDB] 无法从数据库读取配置:', error.message);
|
||||
@ -735,23 +716,15 @@ async function resolveCloudConfigCandidates() {
|
||||
}
|
||||
}
|
||||
|
||||
// 失败态:按需回退到环境变量配置,并把结果记为「带 TTL 的负缓存」。
|
||||
const fallbacks = [];
|
||||
cachedCloudConfigCandidates = [];
|
||||
if (hasCompleteEnvConfig(process.env, 'default')) {
|
||||
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)');
|
||||
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
|
||||
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'default'));
|
||||
}
|
||||
if (hasCompleteEnvConfig(process.env, 'public')) {
|
||||
console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_PUBLIC_*)');
|
||||
fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
|
||||
cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'public'));
|
||||
}
|
||||
|
||||
cachedCloudConfigCandidates = fallbacks;
|
||||
cachedCloudConfigExpiresAt = Date.now() + NEGATIVE_CONFIG_CACHE_TTL_MS;
|
||||
console.warn(
|
||||
`[CloudDB] 系统库配置暂不可用(${fallbacks.length > 0 ? '暂用环境变量兜底' : '且无环境变量兜底'}),`
|
||||
+ `${NEGATIVE_CONFIG_CACHE_TTL_MS}ms 后自动重试`
|
||||
);
|
||||
return cachedCloudConfigCandidates;
|
||||
}
|
||||
|
||||
@ -764,20 +737,7 @@ async function connectWithCandidate(candidate) {
|
||||
}
|
||||
|
||||
async function queryWithCandidateConnection(candidate, sqlText, params = {}, options = {}) {
|
||||
// request.timeout(请求级)在 mssql 12.x 上实测不生效(始终按池配置的默认值掐断),
|
||||
// 慢查询必须通过「池配置级 requestTimeout」放宽,这里允许调用方覆盖候选连接的池级超时。
|
||||
let mssqlConfig = candidate.mssqlConfig;
|
||||
const poolRequestTimeout = parsePositiveInt(options.connectRequestTimeoutMs, 0);
|
||||
if (poolRequestTimeout > 0) {
|
||||
mssqlConfig = {
|
||||
...candidate.mssqlConfig,
|
||||
options: {
|
||||
...candidate.mssqlConfig.options,
|
||||
requestTimeout: poolRequestTimeout,
|
||||
},
|
||||
};
|
||||
}
|
||||
const nextPool = new sql.ConnectionPool(mssqlConfig);
|
||||
const nextPool = new sql.ConnectionPool(candidate.mssqlConfig);
|
||||
|
||||
try {
|
||||
await nextPool.connect();
|
||||
@ -1001,21 +961,10 @@ const getPool = async () => {
|
||||
throw lastError || new Error(NOT_CONFIGURED_MESSAGE);
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一失效「云配置候选」缓存。
|
||||
*
|
||||
* 抽成单点:数组与负缓存 TTL 时间戳必须同时清,只清数组会让陈旧的 expiresAt 残留,
|
||||
* 下次读取时 `Date.now() < expiresAt` 仍可能成立 → 又退化回「负缓存不失效」。
|
||||
*/
|
||||
function invalidateCloudConfigCache() {
|
||||
cachedCloudConfigCandidates = null;
|
||||
cachedCloudConfigExpiresAt = 0;
|
||||
}
|
||||
|
||||
const resetPool = async () => {
|
||||
if (!pool) {
|
||||
activeCloudConfig = null;
|
||||
invalidateCloudConfigCache();
|
||||
cachedCloudConfigCandidates = null;
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1026,7 +975,7 @@ const resetPool = async () => {
|
||||
} finally {
|
||||
pool = null;
|
||||
activeCloudConfig = null;
|
||||
invalidateCloudConfigCache();
|
||||
cachedCloudConfigCandidates = null;
|
||||
}
|
||||
};
|
||||
|
||||
@ -1077,14 +1026,6 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const candidates = await resolveCloudConfigCandidates();
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
// 候选为空 = 本轮配置解析失败,且没有任何环境变量兜底。
|
||||
//
|
||||
// 这里刻意**不**清缓存:失败态已由 resolveCloudConfigCandidates 写成「带 TTL 的负缓存」。
|
||||
// TTL 内的后续请求快速失败(不必每个请求都去撞系统库的慢超时),到期自动重新解析 ——
|
||||
// 隧道恢复后无需重启后端。旧实现把这次失败永久固化,正是「必须重启后端」的根因。
|
||||
//
|
||||
// 注:getPool() 里的同款分支行为与此保持一致,两条路径统一由 TTL 负责恢复;
|
||||
// 「坏连接池」由 query() 的 catch → resetPool() 负责,各司其职。
|
||||
throw new Error(NOT_CONFIGURED_MESSAGE);
|
||||
}
|
||||
|
||||
@ -1106,36 +1047,11 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => {
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* 慢查询专用入口:走独立临时连接,池级 requestTimeout 放宽到 SLOW_QUERY_POOL_TIMEOUT_MS。
|
||||
* 背景:mssql 12.x 的 request.timeout(请求级)不生效,超过池默认 60s 的查询必须用
|
||||
* 池配置级 requestTimeout 才能放宽。适用场景:动销分析这类多表大窗口聚合。
|
||||
*/
|
||||
const SLOW_QUERY_POOL_TIMEOUT_MS = parsePositiveInt(
|
||||
process.env.CLOUD_DB_SLOW_QUERY_TIMEOUT_MS,
|
||||
300000
|
||||
);
|
||||
|
||||
const querySlow = async (sqlText, params = {}, options = {}) => {
|
||||
const poolRequestTimeout = parsePositiveInt(
|
||||
options.connectRequestTimeoutMs,
|
||||
SLOW_QUERY_POOL_TIMEOUT_MS
|
||||
);
|
||||
return queryWithFreshConnection(sqlText, params, {
|
||||
...options,
|
||||
connectRequestTimeoutMs: poolRequestTimeout,
|
||||
requestTimeout: poolRequestTimeout,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 测试连接
|
||||
*/
|
||||
const test = async () => {
|
||||
try {
|
||||
// 「测试连接」是用户在系统设置里验证配置的入口,必须读最新配置:
|
||||
// 先显式失效,避免 30s 内的失败态负缓存让用户看到「刚改好却连不上」。
|
||||
invalidateCloudConfigCache();
|
||||
const candidates = await resolveCloudConfigCandidates();
|
||||
if (!Array.isArray(candidates) || candidates.length === 0) {
|
||||
return { success: false, message: NOT_CONFIGURED_MESSAGE };
|
||||
@ -1164,7 +1080,7 @@ const close = async () => {
|
||||
}
|
||||
pool = null;
|
||||
activeCloudConfig = null;
|
||||
invalidateCloudConfigCache();
|
||||
cachedCloudConfigCandidates = null;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -1178,7 +1094,6 @@ const reload = async () => {
|
||||
module.exports = {
|
||||
getPool,
|
||||
query,
|
||||
querySlow,
|
||||
queryWithFreshConnection,
|
||||
createPinnedQueryClient,
|
||||
test,
|
||||
|
||||
@ -1,216 +0,0 @@
|
||||
/**
|
||||
* 每日进销存 —— 仓库清单(纯映射,不含业务数值)
|
||||
*
|
||||
* 重要:本文件只描述「要统计哪些仓库」以及它们与聚水潭 wms_co_id 的对应关系,
|
||||
* 不再硬编码任何业务数值。所有数字都从数据库取:
|
||||
* - 期初数量:本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存
|
||||
* - 期末数量:业务库实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓)
|
||||
* - 周转率 / 四级库存量:近 30 天发货 × 实时库存 × SKU 库龄,见 src/api/dailyInventory.js
|
||||
* - 12 个流水列:4 列可从业务库单据表还原(采购进仓/采购退货/销售出仓/销售退货,
|
||||
* 见 src/services/dailyInventoryFlows.js);其余 8 列缺调拨/盘点/加工/其它出入库单据表,
|
||||
* 暂无法显示(前端渲染为「—」)
|
||||
*
|
||||
* 仓库清单随聚水潭仓库档案变化时需同步维护(来源表:rpt.jst_warehouseInfo)。
|
||||
*/
|
||||
|
||||
/** 一级/二级菜单名 */
|
||||
const DAILY_INVENTORY_LEVEL1 = '大货库存监控';
|
||||
const DAILY_INVENTORY_LEVEL2 = '每日进销存';
|
||||
|
||||
/** 12 个流水列的中文表头与字段映射(顺序与聚水潭进销存导出一致) */
|
||||
const FLOW_COLUMNS = [
|
||||
{
|
||||
"label": "加工进仓",
|
||||
"field": "processIn"
|
||||
},
|
||||
{
|
||||
"label": "采购退货",
|
||||
"field": "purchaseReturn"
|
||||
},
|
||||
{
|
||||
"label": "采购进仓",
|
||||
"field": "purchaseIn"
|
||||
},
|
||||
{
|
||||
"label": "其它进仓",
|
||||
"field": "otherIn"
|
||||
},
|
||||
{
|
||||
"label": "其它退货",
|
||||
"field": "otherReturn"
|
||||
},
|
||||
{
|
||||
"label": "销售出仓",
|
||||
"field": "salesOut"
|
||||
},
|
||||
{
|
||||
"label": "盘点",
|
||||
"field": "stocktaking"
|
||||
},
|
||||
{
|
||||
"label": "加工出仓",
|
||||
"field": "processOut"
|
||||
},
|
||||
{
|
||||
"label": "调拨出",
|
||||
"field": "transferOut"
|
||||
},
|
||||
{
|
||||
"label": "销售退货",
|
||||
"field": "salesReturn"
|
||||
},
|
||||
{
|
||||
"label": "调拨入",
|
||||
"field": "transferIn"
|
||||
},
|
||||
{
|
||||
"label": "其它出库",
|
||||
"field": "otherOut"
|
||||
}
|
||||
];
|
||||
|
||||
/** 纳入口径的仓库(warehouse = 聚水潭主仓名,dbWarehouse = 库存监控表仓库名) */
|
||||
const DAILY_INVENTORY_BASELINE = [
|
||||
{
|
||||
"warehouse": "简尚-崔桥工厂半成品包装项目仓主仓",
|
||||
"dbWarehouse": "简尚-崔桥工厂半成品包装项目仓",
|
||||
"wmsCoId": 15718196
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-崔桥工厂仓主仓",
|
||||
"dbWarehouse": "简尚-崔桥工厂仓",
|
||||
"wmsCoId": 15038426
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-山东青岗集仓主仓",
|
||||
"dbWarehouse": "简尚-山东青岗集仓",
|
||||
"wmsCoId": 15137523
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-河南洛宁仓主仓",
|
||||
"dbWarehouse": "简尚-河南洛宁仓",
|
||||
"wmsCoId": 15539084
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-新苏北仓主仓",
|
||||
"dbWarehouse": "简尚-新苏北仓",
|
||||
"wmsCoId": 15038347
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-横林二仓主仓",
|
||||
"dbWarehouse": "简尚-横林二仓",
|
||||
"wmsCoId": 15033302
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-横林三仓主仓",
|
||||
"dbWarehouse": "简尚-横林三仓",
|
||||
"wmsCoId": 15043561
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-山东品牌仓主仓",
|
||||
"dbWarehouse": "简尚-山东品牌仓",
|
||||
"wmsCoId": 15182705
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-睢宁仓主仓",
|
||||
"dbWarehouse": "简尚-睢宁仓",
|
||||
"wmsCoId": 15032603
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-分销部专用仓主仓",
|
||||
"dbWarehouse": "简尚-分销部专用仓",
|
||||
"wmsCoId": 15045426
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-新外贸仓主仓",
|
||||
"dbWarehouse": "简尚-新外贸仓",
|
||||
"wmsCoId": 15045308
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-分销部睢宁专用仓主仓",
|
||||
"dbWarehouse": "简尚-分销部睢宁专用仓",
|
||||
"wmsCoId": 15051960
|
||||
},
|
||||
{
|
||||
"warehouse": "京仓-山东青岗集京东自营仓主仓",
|
||||
"dbWarehouse": "京仓-山东青岗集京东自营仓",
|
||||
"wmsCoId": 15835935
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-崔桥工厂品牌仓主仓",
|
||||
"dbWarehouse": "简尚-崔桥工厂品牌仓",
|
||||
"wmsCoId": 15870495
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-分销部苏北专用仓主仓",
|
||||
"dbWarehouse": "简尚-分销部苏北专用仓",
|
||||
"wmsCoId": 15045325
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-运营C横林二仓专用仓主仓",
|
||||
"dbWarehouse": "简尚-运营C横林二仓专用仓",
|
||||
"wmsCoId": 15301814
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-济南外贸仓主仓",
|
||||
"dbWarehouse": "简尚-济南外贸仓",
|
||||
"wmsCoId": 15051888
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-分销部崔桥专用仓主仓",
|
||||
"dbWarehouse": "简尚-分销部崔桥专用仓",
|
||||
"wmsCoId": 15404402
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-运营C崔桥工厂仓专用仓主仓",
|
||||
"dbWarehouse": "简尚-运营C崔桥工厂仓专用仓",
|
||||
"wmsCoId": 15301815
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-运营D苏北专用仓主仓",
|
||||
"dbWarehouse": "简尚-运营D苏北专用仓",
|
||||
"wmsCoId": 15406876
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-运营C睢宁专用仓主仓",
|
||||
"dbWarehouse": "简尚-运营C睢宁专用仓",
|
||||
"wmsCoId": 15301813
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-运营C新苏北专用仓主仓",
|
||||
"dbWarehouse": "简尚-运营C新苏北专用仓",
|
||||
"wmsCoId": 15301812
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-运营D睢宁专用仓主仓",
|
||||
"dbWarehouse": "简尚-运营D睢宁专用仓",
|
||||
"wmsCoId": 15051907
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-山东青岗集京东仓主仓",
|
||||
"dbWarehouse": "简尚-山东青岗集京东仓",
|
||||
"wmsCoId": 15406664
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-运营D专用仓库主仓",
|
||||
"dbWarehouse": "简尚-运营D专用仓库",
|
||||
"wmsCoId": 15051915
|
||||
},
|
||||
{
|
||||
"warehouse": "简尚-崔桥工厂京东仓主仓",
|
||||
"dbWarehouse": "简尚-崔桥工厂京东仓",
|
||||
"wmsCoId": 15045345
|
||||
},
|
||||
{
|
||||
"warehouse": "京仓-横林二仓京东自营仓主仓",
|
||||
"dbWarehouse": "京仓-横林二仓京东自营仓",
|
||||
"wmsCoId": 15835893
|
||||
}
|
||||
];
|
||||
|
||||
module.exports = {
|
||||
DAILY_INVENTORY_LEVEL1,
|
||||
DAILY_INVENTORY_LEVEL2,
|
||||
FLOW_COLUMNS,
|
||||
DAILY_INVENTORY_BASELINE,
|
||||
};
|
||||
@ -1,184 +0,0 @@
|
||||
/**
|
||||
* 每日进销存 —— 聚水潭「官方进销存导出」校正数据
|
||||
*
|
||||
* 背景:业务库(MSSQL / JSReportReplica)只能还原 4/12 个流水列,且口径与
|
||||
* 聚水潭进销存报表不一致(采购按单据日期 vs 实际入库、销售出仓按订单发货 vs 出库单等),
|
||||
* 其余 8 个流水列(调拨/盘点/加工/其它出入库)业务库根本没有对应单据表。
|
||||
* 因此「每日进销存」以聚水潭官方导出的进销存报表为准:
|
||||
* 官方有值 → 直接采用(source = official)
|
||||
* 官方无值 → 回退业务库自动推算(source = derived)
|
||||
*
|
||||
* 存储:本地 MySQL 表 daily_inventory_official,主键 (biz_date, warehouse)。
|
||||
*/
|
||||
const { getPool } = require('./dailyInventorySnapshotDb');
|
||||
|
||||
const TABLE = 'daily_inventory_official';
|
||||
|
||||
/** 流水列 field(与 dailyInventoryBaseline.FLOW_COLUMNS 一致)→ 数据库列名 */
|
||||
const FLOW_DB_COLUMNS = {
|
||||
processIn: 'process_in',
|
||||
purchaseReturn: 'purchase_return',
|
||||
purchaseIn: 'purchase_in',
|
||||
otherIn: 'other_in',
|
||||
otherReturn: 'other_return',
|
||||
salesOut: 'sales_out',
|
||||
stocktaking: 'stocktaking',
|
||||
processOut: 'process_out',
|
||||
transferOut: 'transfer_out',
|
||||
salesReturn: 'sales_return',
|
||||
transferIn: 'transfer_in',
|
||||
otherOut: 'other_out',
|
||||
};
|
||||
|
||||
const CREATE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
biz_date DATE NOT NULL COMMENT '业务日期',
|
||||
wms_co_id BIGINT NULL COMMENT '聚水潭仓库 ID(未知仓为空)',
|
||||
warehouse VARCHAR(128) NOT NULL COMMENT '仓库名(聚水潭主仓)',
|
||||
opening_qty DECIMAL(18,2) NULL COMMENT '期初数量',
|
||||
closing_qty DECIMAL(18,2) NULL COMMENT '期末数量',
|
||||
process_in DECIMAL(18,2) NULL COMMENT '加工进仓',
|
||||
purchase_return DECIMAL(18,2) NULL COMMENT '采购退货',
|
||||
purchase_in DECIMAL(18,2) NULL COMMENT '采购进仓',
|
||||
other_in DECIMAL(18,2) NULL COMMENT '其它进仓',
|
||||
other_return DECIMAL(18,2) NULL COMMENT '其它退货',
|
||||
sales_out DECIMAL(18,2) NULL COMMENT '销售出仓',
|
||||
stocktaking DECIMAL(18,2) NULL COMMENT '盘点',
|
||||
process_out DECIMAL(18,2) NULL COMMENT '加工出仓',
|
||||
transfer_out DECIMAL(18,2) NULL COMMENT '调拨出',
|
||||
sales_return DECIMAL(18,2) NULL COMMENT '销售退货',
|
||||
transfer_in DECIMAL(18,2) NULL COMMENT '调拨入',
|
||||
other_out DECIMAL(18,2) NULL COMMENT '其它出库',
|
||||
source VARCHAR(32) NOT NULL DEFAULT 'jst-export' COMMENT '数据来源',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_wh (biz_date, warehouse),
|
||||
KEY idx_date (biz_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日进销存-聚水潭官方进销存校正数据';`;
|
||||
|
||||
async function ensureTable() {
|
||||
await getPool().query(CREATE_SQL);
|
||||
}
|
||||
|
||||
const num = (v) => {
|
||||
if (v == null || v === '') return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 写入某业务日的官方进销存(幂等,重复导入覆盖)
|
||||
* @param {string} bizDate
|
||||
* @param {Array<{warehouse:string, wmsCoId?:number|null, openingQty?:number|null, closingQty?:number|null, flows?:Object}>} rows
|
||||
* @param {string} source
|
||||
*/
|
||||
async function upsertOfficial(bizDate, rows, source = 'jst-export') {
|
||||
if (!bizDate || !rows.length) return 0;
|
||||
await ensureTable();
|
||||
const conn = await getPool().getConnection();
|
||||
try {
|
||||
for (const r of rows) {
|
||||
const flows = r.flows || {};
|
||||
await conn.query(
|
||||
`INSERT INTO ${TABLE}
|
||||
(biz_date, wms_co_id, warehouse, opening_qty, closing_qty,
|
||||
process_in, purchase_return, purchase_in, other_in, other_return,
|
||||
sales_out, stocktaking, process_out, transfer_out, sales_return, transfer_in, other_out, source)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
wms_co_id = VALUES(wms_co_id),
|
||||
opening_qty = VALUES(opening_qty), closing_qty = VALUES(closing_qty),
|
||||
process_in = VALUES(process_in), purchase_return = VALUES(purchase_return),
|
||||
purchase_in = VALUES(purchase_in), other_in = VALUES(other_in),
|
||||
other_return = VALUES(other_return), sales_out = VALUES(sales_out),
|
||||
stocktaking = VALUES(stocktaking), process_out = VALUES(process_out),
|
||||
transfer_out = VALUES(transfer_out), sales_return = VALUES(sales_return),
|
||||
transfer_in = VALUES(transfer_in), other_out = VALUES(other_out),
|
||||
source = VALUES(source)`,
|
||||
[
|
||||
bizDate,
|
||||
r.wmsCoId ?? null,
|
||||
r.warehouse,
|
||||
num(r.openingQty),
|
||||
num(r.closingQty),
|
||||
num(flows.processIn), num(flows.purchaseReturn), num(flows.purchaseIn),
|
||||
num(flows.otherIn), num(flows.otherReturn), num(flows.salesOut),
|
||||
num(flows.stocktaking), num(flows.processOut), num(flows.transferOut),
|
||||
num(flows.salesReturn), num(flows.transferIn), num(flows.otherOut),
|
||||
source,
|
||||
]
|
||||
);
|
||||
}
|
||||
return rows.length;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除某业务日的官方数据(重新导入前清理) */
|
||||
async function deleteOfficial(bizDate) {
|
||||
if (!bizDate) return 0;
|
||||
await ensureTable();
|
||||
const [res] = await getPool().query(`DELETE FROM ${TABLE} WHERE biz_date = ?`, [bizDate]);
|
||||
return res.affectedRows || 0;
|
||||
}
|
||||
|
||||
/** 读取某业务日的官方进销存:key = 仓库名 */
|
||||
async function fetchOfficial(bizDate) {
|
||||
const map = new Map();
|
||||
if (!bizDate) return map;
|
||||
try {
|
||||
await ensureTable();
|
||||
const cols = Object.values(FLOW_DB_COLUMNS).map((c) => `o.${c} AS ${c}`).join(', ');
|
||||
const [rows] = await getPool().query(
|
||||
`SELECT o.wms_co_id AS wmsCoId, o.warehouse AS warehouse,
|
||||
o.opening_qty AS openingQty, o.closing_qty AS closingQty, o.source AS source,
|
||||
${cols}
|
||||
FROM ${TABLE} o WHERE o.biz_date = ?`,
|
||||
[bizDate]
|
||||
);
|
||||
const dbToField = Object.fromEntries(Object.entries(FLOW_DB_COLUMNS).map(([f, c]) => [c, f]));
|
||||
rows.forEach((r) => {
|
||||
const flows = {};
|
||||
for (const [col, field] of Object.entries(dbToField)) flows[field] = num(r[col]);
|
||||
map.set(String(r.warehouse), {
|
||||
warehouse: r.warehouse,
|
||||
wmsCoId: r.wmsCoId == null ? null : Number(r.wmsCoId),
|
||||
openingQty: num(r.openingQty),
|
||||
closingQty: num(r.closingQty),
|
||||
source: r.source,
|
||||
flows,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory-official] 读取官方数据失败:', error.message);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 已有官方数据的业务日(倒序) */
|
||||
async function listOfficialDates(limit = 30) {
|
||||
try {
|
||||
await ensureTable();
|
||||
const [rows] = await getPool().query(
|
||||
`SELECT DISTINCT biz_date AS d FROM ${TABLE} ORDER BY biz_date DESC LIMIT ?`,
|
||||
[Number(limit) || 30]
|
||||
);
|
||||
return rows.map((r) => (typeof r.d === 'string' ? r.d.slice(0, 10) : String(r.d).slice(0, 10)));
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory-official] 读取官方日期失败:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TABLE,
|
||||
CREATE_SQL,
|
||||
FLOW_DB_COLUMNS,
|
||||
ensureTable,
|
||||
upsertOfficial,
|
||||
deleteOfficial,
|
||||
fetchOfficial,
|
||||
listOfficialDates,
|
||||
};
|
||||
@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 每日进销存 —— 本地库存快照库访问
|
||||
*
|
||||
* 背景:业务库(MSSQL / JSReportReplica)只有「当前」实时库存 jst_inventoryInfo,
|
||||
* 没有按天的历史库存结存表,因此「期初数量」无法回溯。
|
||||
* 解决办法:项目本地 MySQL 每天落一份按仓库存的快照,
|
||||
* 今日期初 = 上一业务日快照,期末 = 实时库存。
|
||||
*
|
||||
* 连接复用 LOCAL_DB_* 配置(与项目其它本地表同库)。
|
||||
*/
|
||||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
|
||||
|
||||
const TABLE = 'daily_inventory_snapshot';
|
||||
|
||||
const CREATE_SQL = `
|
||||
CREATE TABLE IF NOT EXISTS ${TABLE} (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
biz_date DATE NOT NULL COMMENT '业务日期(T-1)',
|
||||
wms_co_id BIGINT NOT NULL COMMENT '聚水潭仓库 ID',
|
||||
warehouse VARCHAR(128) NOT NULL COMMENT '仓库名(主仓)',
|
||||
db_warehouse VARCHAR(128) NULL COMMENT '监控表仓库名',
|
||||
closing_qty DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '该业务日期末数量',
|
||||
sku_count INT NOT NULL DEFAULT 0 COMMENT '有库存 SKU 数',
|
||||
taken_at DATETIME NULL COMMENT '源库库存数据更新时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_date_wh (biz_date, wms_co_id),
|
||||
KEY idx_date (biz_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='每日进销存-按仓库存快照';`;
|
||||
|
||||
let pool = null;
|
||||
|
||||
function getPool() {
|
||||
if (pool) return pool;
|
||||
pool = mysql.createPool({
|
||||
host: process.env.LOCAL_DB_HOST,
|
||||
port: Number(process.env.LOCAL_DB_PORT || 3306),
|
||||
user: process.env.LOCAL_DB_USER,
|
||||
password: process.env.LOCAL_DB_PASSWORD,
|
||||
database: process.env.LOCAL_DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
connectTimeout: 10000,
|
||||
// DATE/DATETIME 直接返回字符串,避免 JS Date 按本地时区回读时整体偏移一天
|
||||
dateStrings: true,
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
|
||||
async function ensureTable() {
|
||||
await getPool().query(CREATE_SQL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入某业务日的期末快照(幂等,重复执行覆盖)
|
||||
* @param {string} bizDate YYYY-MM-DD
|
||||
* @param {Array<{wmsCoId:number, warehouse:string, dbWarehouse?:string, closingQty:number, skuCount:number, takenAt?:Date|null}>} rows
|
||||
*/
|
||||
async function upsertSnapshot(bizDate, rows) {
|
||||
if (!bizDate || !rows.length) return 0;
|
||||
await ensureTable();
|
||||
const conn = await getPool().getConnection();
|
||||
try {
|
||||
for (const r of rows) {
|
||||
await conn.query(
|
||||
`INSERT INTO ${TABLE} (biz_date, wms_co_id, warehouse, db_warehouse, closing_qty, sku_count, taken_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE warehouse = VALUES(warehouse), db_warehouse = VALUES(db_warehouse),
|
||||
closing_qty = VALUES(closing_qty), sku_count = VALUES(sku_count), taken_at = VALUES(taken_at)`,
|
||||
[bizDate, r.wmsCoId, r.warehouse, r.dbWarehouse || null, r.closingQty, r.skuCount, r.takenAt || null]
|
||||
);
|
||||
}
|
||||
return rows.length;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取某业务日的期末快照
|
||||
* @returns {Promise<Map<string, {closingQty:number, skuCount:number, takenAt:Date|null}>>} key = wmsCoId 字符串
|
||||
*/
|
||||
async function fetchSnapshot(bizDate) {
|
||||
const map = new Map();
|
||||
if (!bizDate) return map;
|
||||
try {
|
||||
await ensureTable();
|
||||
const [rows] = await getPool().query(
|
||||
`SELECT wms_co_id AS wmsCoId, closing_qty AS closingQty, sku_count AS skuCount, taken_at AS takenAt
|
||||
FROM ${TABLE} WHERE biz_date = ?`,
|
||||
[bizDate]
|
||||
);
|
||||
rows.forEach((r) => map.set(String(r.wmsCoId), {
|
||||
closingQty: Number(r.closingQty),
|
||||
skuCount: Number(r.skuCount),
|
||||
takenAt: r.takenAt || null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory-snapshot] 读取快照失败:', error.message);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** 已有快照的业务日(倒序,最多 n 条) */
|
||||
async function listSnapshotDates(limit = 10) {
|
||||
try {
|
||||
await ensureTable();
|
||||
const [rows] = await getPool().query(
|
||||
`SELECT DISTINCT biz_date AS d FROM ${TABLE} ORDER BY biz_date DESC LIMIT ?`,
|
||||
[Number(limit) || 10]
|
||||
);
|
||||
return rows.map((r) => {
|
||||
if (typeof r.d === 'string') return r.d.slice(0, 10);
|
||||
const v = new Date(r.d);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${v.getFullYear()}-${pad(v.getMonth() + 1)}-${pad(v.getDate())}`;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory-snapshot] 读取快照日期失败:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
TABLE,
|
||||
CREATE_SQL,
|
||||
getPool,
|
||||
ensureTable,
|
||||
upsertSnapshot,
|
||||
fetchSnapshot,
|
||||
listSnapshotDates,
|
||||
};
|
||||
@ -1,78 +0,0 @@
|
||||
/**
|
||||
* 拣选库位动销分析 —— 口径配置(纯规则,不含业务数值)
|
||||
*
|
||||
* 一级菜单:大货库存监控
|
||||
* 二级菜单:拣选库位动销分析
|
||||
*
|
||||
* 表头(需求表原列为 所在仓库 | 仓位 | 数量 | 类型 | 动销等级,这里做了扩充与优化):
|
||||
* 仓库 | 仓位 | 类型 | 商品编码 | 库存数量 | 近N天销量 | 日销 | 动销等级
|
||||
*
|
||||
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用。
|
||||
*
|
||||
* 口径:
|
||||
* 库存数量:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty
|
||||
* 统计范围:只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);
|
||||
* 箱记录(pack_type=Pack)与存货位(bin_group=存货)不纳入
|
||||
* 销量: 4 个来源分支,均按 send_date 窗口(详见 services/pickingBinSalesAnalysis.js):
|
||||
* ① jst_Orders 普通行;② jst_Orders 组合装行 → jst_comboProductItem 展开为子件;
|
||||
* ③ jst_qmcust_orderlist(全民客户 B2B 分销)普通行(排除拆单父单 Split);
|
||||
* ④ qmcust 组合装行 → 同样展开。
|
||||
* 日销: 商品窗口销量 / 窗口天数(整档,不按仓位分摊)
|
||||
* 等级: 严格按商品日销判定,同一商品占多少个仓位都是同一等级
|
||||
*
|
||||
* 【为什么不分摊来定等级】同一 SKU 常常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个
|
||||
* 拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。若把销量摊到仓位再定等级,
|
||||
* 一件爆款商品放在 10 个仓位就会被算成日销 1/10 的滞销品 —— 判断的是「摆法」而不是「商品」。
|
||||
* 因此等级一律以商品自身的销售速度为准。
|
||||
*
|
||||
* 【分摊销量列】导出仍附带「分摊销量」= 商品销量 ÷ 其占用的同类型仓位数,
|
||||
* 供需要按行求和的场景使用:恒等式(已实测校验,差异 0)
|
||||
* 全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
|
||||
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
|
||||
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
|
||||
*/
|
||||
|
||||
const PICKING_BIN_LEVEL1 = '大货库存监控';
|
||||
const PICKING_BIN_LEVEL2 = '拣选库位动销分析';
|
||||
|
||||
/** 可选的统计窗口天数;默认 30 天(需求指定按近 30 天销量划分动销等级) */
|
||||
const PICKING_BIN_WINDOW_OPTIONS = [7, 15, 30, 60];
|
||||
const PICKING_BIN_DEFAULT_DAYS = 30;
|
||||
|
||||
/**
|
||||
* 仓位类型:本功能只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空)。
|
||||
* 箱记录(pack_type=Pack)和存货位(bin_group=存货)不纳入动销等级统计,
|
||||
* 取数在仓位库存表上直接过滤,
|
||||
* 页面也不再提供类型切换。
|
||||
*/
|
||||
const BIN_TYPE_PICKING = '拣选';
|
||||
/** 固定统计范围:仅拣选位(保持数组形式,便于接口字段与后续扩展) */
|
||||
const PICKING_BIN_BIN_TYPES = [BIN_TYPE_PICKING];
|
||||
|
||||
/**
|
||||
* 动销等级(顺序即展示顺序)。判定依据为「商品日销」= 商品窗口整档销量 ÷ 天数,
|
||||
* 不做仓位分摊,同一商品在所有仓位显示同一等级。
|
||||
* lower 为日销下界(含),upper 为日销上界(不含);null 表示不限制。
|
||||
* zero 档为「窗口内无销量」,由 salesQty <= 0 兜底。
|
||||
*/
|
||||
const PICKING_BIN_GRADE_RULES = [
|
||||
{ key: 'hot', label: '爆款:日销≥20', lower: 20, upper: null, tip: '商品日均销量 ≥ 20 件' },
|
||||
{ key: 'best', label: '热卖款:10≤日销<20', lower: 10, upper: 20, tip: '商品日均销量 10 ~ 20 件(含 10,不含 20)' },
|
||||
{ key: 'normal', label: '平销款:3≤日销<10', lower: 3, upper: 10, tip: '商品日均销量 3 ~ 10 件(含 3,不含 10)' },
|
||||
{ key: 'slow1', label: '滞销款-1:1≤日销<3', lower: 1, upper: 3, tip: '商品日均销量 1 ~ 3 件(含 1,不含 3)' },
|
||||
{ key: 'slow2', label: '滞销款-2:0<日销<1', lower: 0, upper: 1, tip: '商品日均销量大于 0 且小于 1 件' },
|
||||
{ key: 'zero', label: '销售=0', lower: 0, upper: 0, tip: '统计窗口内该商品无销量(仍占用库存)' },
|
||||
];
|
||||
|
||||
const PICKING_BIN_TOTAL_LABEL = '汇总';
|
||||
|
||||
module.exports = {
|
||||
PICKING_BIN_LEVEL1,
|
||||
PICKING_BIN_LEVEL2,
|
||||
PICKING_BIN_WINDOW_OPTIONS,
|
||||
PICKING_BIN_DEFAULT_DAYS,
|
||||
BIN_TYPE_PICKING,
|
||||
PICKING_BIN_BIN_TYPES,
|
||||
PICKING_BIN_GRADE_RULES,
|
||||
PICKING_BIN_TOTAL_LABEL,
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
/**
|
||||
* 仓库库存动销分析 —— 口径配置(纯规则,不含业务数值)
|
||||
*
|
||||
* 一级菜单:大货库存监控
|
||||
* 二级菜单:库存动销分析
|
||||
*
|
||||
* 表头(与需求表一致):仓库 | 动销等级 | SKU数量 | SKU占比 | 库存数量 | 库存占比 | 销量
|
||||
*
|
||||
* 口径:
|
||||
* 日销(日均销量)= 统计窗口内已发货件数 / 窗口天数
|
||||
* 动销等级按「单个 SKU 的日销」分档,落在哪一档就计入哪一档
|
||||
* 仓库内汇总:SKU占比 = 该等级 SKU 数 / 该仓库 SKU 总数
|
||||
* 库存占比 = 该等级库存件数 / 该仓库库存总件数
|
||||
* 仓库清单与「每日进销存」保持同一份(DAILY_INVENTORY_BASELINE)
|
||||
*/
|
||||
|
||||
const STOCK_SALES_LEVEL1 = '大货库存监控';
|
||||
const STOCK_SALES_LEVEL2 = '库存动销分析';
|
||||
|
||||
/** 可选的统计窗口天数;默认 30 天 */
|
||||
const STOCK_SALES_WINDOW_OPTIONS = [7, 14, 30];
|
||||
const STOCK_SALES_DEFAULT_DAYS = 30;
|
||||
|
||||
/**
|
||||
* 动销等级(顺序即表格行顺序,汇总行由 total 单独给出)。
|
||||
* lower 为日销下界(含),upper 为日销上界(不含);null 表示不限制。
|
||||
*/
|
||||
const SALES_GRADE_RULES = [
|
||||
{ key: 'hot', label: '爆款:日销≥20', lower: 20, upper: null, tip: '单个 SKU 日均销量 ≥ 20 件' },
|
||||
{ key: 'best', label: '热卖款:10≤日销<20', lower: 10, upper: 20, tip: '单个 SKU 日均销量 10 ~ 20 件(含 10,不含 20)' },
|
||||
{ key: 'normal', label: '平销款:3≤日销<10', lower: 3, upper: 10, tip: '单个 SKU 日均销量 3 ~ 10 件(含 3,不含 10)' },
|
||||
{ key: 'slow1', label: '滞销款-1:1≤日销<3', lower: 1, upper: 3, tip: '单个 SKU 日均销量 1 ~ 3 件(含 1,不含 3)' },
|
||||
{ key: 'slow2', label: '滞销款-2:0<日销<1', lower: 0, upper: 1, tip: '单个 SKU 日均销量大于 0 且小于 1 件' },
|
||||
{ key: 'zero', label: '销售=0', lower: 0, upper: 0, tip: '统计窗口内无销量的 SKU(仍占用库存)' },
|
||||
];
|
||||
|
||||
/** 汇总行的等级名(表内展示用) */
|
||||
const SALES_TOTAL_LABEL = '汇总';
|
||||
|
||||
module.exports = {
|
||||
STOCK_SALES_LEVEL1,
|
||||
STOCK_SALES_LEVEL2,
|
||||
STOCK_SALES_WINDOW_OPTIONS,
|
||||
STOCK_SALES_DEFAULT_DAYS,
|
||||
SALES_GRADE_RULES,
|
||||
SALES_TOTAL_LABEL,
|
||||
};
|
||||
@ -40,16 +40,6 @@ const SIDEBAR_PAGES = [
|
||||
path: '/planning/operation/mkh-bom-data',
|
||||
}),
|
||||
buildPlanningPage({ pageKey: 'work_orders', pageName: '工单待办', path: '/planning/operation/work-orders' }),
|
||||
buildPlanningPage({
|
||||
pageKey: 'stock_sales_analysis',
|
||||
pageName: '大货库存监控 · 库存动销分析',
|
||||
path: '/planning/operation/stock-sales-analysis',
|
||||
}),
|
||||
buildPlanningPage({
|
||||
pageKey: 'picking_bin_sales_analysis',
|
||||
pageName: '大货库存监控 · 拣选库位动销分析',
|
||||
path: '/planning/operation/picking-bin-sales-analysis',
|
||||
}),
|
||||
{
|
||||
pageKey: 'settings',
|
||||
pageName: '系统设置',
|
||||
|
||||
@ -35,16 +35,6 @@ const promotionModuleRoutes = require('./api/promotionModule');
|
||||
const productMonitoringRoutes = require('./api/productMonitoring');
|
||||
const modularStockMonitorRoutes = require('./api/modularStockMonitor');
|
||||
const mkhBomRoutes = require('./api/mkhBom');
|
||||
const warehouseShipTimelinessRoutes = require('./api/warehouseShipTimeliness');
|
||||
const warehouseLogisticsExceptionRoutes = require('./api/warehouseLogisticsException');
|
||||
const dailyInventoryRoutes = require('./api/dailyInventory');
|
||||
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
|
||||
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
|
||||
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
|
||||
const binReplenishRoutes = require('./api/binReplenish');
|
||||
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
|
||||
const returnMonitorRoutes = require('./api/returnMonitor');
|
||||
const returnStockWarningRoutes = require('./api/returnStockWarning');
|
||||
const database = require('./config/database');
|
||||
const cloudDb = require('./config/cloudDatabase');
|
||||
const jianSunDb = require('./config/jianSunDatabase');
|
||||
@ -57,21 +47,12 @@ const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMa
|
||||
const { startPlanningDingWorkflowSyncSchedulers } = require('./jobs/planningDingWorkflowSyncJob');
|
||||
const { startOpsBIncentiveSnapshotSchedulers } = require('./jobs/opsBIncentiveSnapshotJob');
|
||||
const { startGoodsFirstSeenScheduler } = require('./jobs/goodsFirstSeenJob');
|
||||
const {
|
||||
startDailyInventorySnapshotScheduler,
|
||||
} = require('./jobs/dailyInventorySnapshotJob');
|
||||
const {
|
||||
startDailyInventoryNotificationScheduler,
|
||||
} = require('./jobs/dailyInventoryNotificationJob');
|
||||
const {
|
||||
startAlibaba1688ImageSearchExpiryScheduler,
|
||||
} = require('./jobs/alibaba1688ImageSearchExpiryJob');
|
||||
const {
|
||||
startMkhReplenishAlertScheduler,
|
||||
} = require('./jobs/mkhReplenishAlertJob');
|
||||
const {
|
||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||
} = require('./jobs/warehouseLogisticsExceptionNotifyJob');
|
||||
const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||
const { ensureLocalSchema } = require('./services/localSchema');
|
||||
const {
|
||||
@ -678,21 +659,6 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes);
|
||||
cubejsApp.use('/api/modular-stock-monitor', authMiddleware, modularStockMonitorRoutes);
|
||||
cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes);
|
||||
// 仓储中心 24 小时发货时效(2026-10-20 新增):列表左侧侧栏,11 个主仓超时未发
|
||||
cubejsApp.use('/api/warehouse-ship-timeliness', authMiddleware, warehouseShipTimelinessRoutes);
|
||||
// 仓储中心物流转运异常(2026-10-21 新增):每日 09:00 核验前一日已发货单的物流记录
|
||||
cubejsApp.use('/api/warehouse-logistics-exception', authMiddleware, warehouseLogisticsExceptionRoutes);
|
||||
cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes);
|
||||
cubejsApp.use('/api/daily-inventory-notification', authMiddleware, dailyInventoryNotificationRoutes);
|
||||
cubejsApp.use('/api/stock-sales-analysis', authMiddleware, stockSalesAnalysisRoutes);
|
||||
cubejsApp.use('/api/picking-bin-sales-analysis', authMiddleware, pickingBinSalesAnalysisRoutes);
|
||||
cubejsApp.use('/api/return-inventory-warning', authMiddleware, returnInventoryWarningRoutes);
|
||||
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控
|
||||
cubejsApp.use('/api/return-monitor', authMiddleware, returnMonitorRoutes);
|
||||
// 崔桥工厂仓客退:退货库存预警(箱及仓位库存 · 崔桥工厂客退临时上架仓)
|
||||
cubejsApp.use('/api/return-stock-warning', authMiddleware, returnStockWarningRoutes);
|
||||
// 缺货补货监控(拣货位缺货 → 从存货位补货)
|
||||
cubejsApp.use('/api/bin-replenish', authMiddleware, binReplenishRoutes);
|
||||
|
||||
// =====================
|
||||
// 数据分析 API(公开访问,可根据需要添加认证)
|
||||
@ -728,36 +694,6 @@ async function startServer(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
// =====================
|
||||
// 统一 API 兜底(必须放在所有路由之后)
|
||||
// =====================
|
||||
// 背景:此前没有任何 4 参错误处理中间件,路由里抛出的异常会落到 Express
|
||||
// 默认处理器,返回一张 HTML 的 "Internal Server Error" 页面;前端 axios
|
||||
// 读不到 error / message 字段,只能显示 "Request failed with status code 500",
|
||||
// 出问题时完全看不到真实原因。这里统一返回 JSON 并打印堆栈。
|
||||
cubejsApp.use('/api', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: `接口不存在: ${req.method} ${req.originalUrl}`,
|
||||
});
|
||||
});
|
||||
|
||||
cubejsApp.use((err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
const rawStatus = Number(err?.status || err?.statusCode);
|
||||
const status = Number.isFinite(rawStatus) && rawStatus >= 400 && rawStatus <= 599
|
||||
? rawStatus
|
||||
: 500;
|
||||
const messageText = err?.message || '服务器内部错误';
|
||||
console.error(`❌ [API ${status}] ${req.method} ${req.originalUrl} -> ${messageText}`);
|
||||
if (err?.stack) {
|
||||
console.error(err.stack);
|
||||
}
|
||||
return res.status(status).json({ success: false, error: messageText });
|
||||
});
|
||||
|
||||
console.log('='.repeat(50));
|
||||
console.log('🚀 后端服务已启动');
|
||||
console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`);
|
||||
@ -777,11 +713,6 @@ async function startServer(options = {}) {
|
||||
console.log(` [缓存] ${redisTest.message}`);
|
||||
console.log('='.repeat(50));
|
||||
|
||||
// 每日进销存截图通知:与「后台定时任务总开关」解耦,保证即使总开关关闭,
|
||||
// 只要功能启用就仍会按配置时间推送(可用 DAILY_INVENTORY_NOTIFICATION_ENABLED=false 单独关闭)。
|
||||
// 它每分钟只做一次轻量判断,命中配置时间才真正截图上传。
|
||||
startDailyInventoryNotificationScheduler();
|
||||
|
||||
if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') {
|
||||
console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理');
|
||||
return;
|
||||
@ -797,8 +728,6 @@ async function startServer(options = {}) {
|
||||
startGoodsFirstSeenScheduler();
|
||||
startAlibaba1688ImageSearchExpiryScheduler();
|
||||
startMkhReplenishAlertScheduler();
|
||||
startWarehouseLogisticsExceptionNotifyScheduler();
|
||||
startDailyInventorySnapshotScheduler();
|
||||
|
||||
// Keep the first selection-pool page request from paying the remote schema
|
||||
// inspection round-trip over the planning database tunnel.
|
||||
|
||||
@ -1,351 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 每日进销存 · 定时截图通知
|
||||
//
|
||||
// 执行流程:
|
||||
// ① 单实例锁(MySQL GET_LOCK),避免多实例重复截图推送
|
||||
// ② 校验:功能开启 / 有收件人 / 通知中心已配置 / 对象存储已配置,任一不满足就记录原因并退出
|
||||
// ③ 按配置时间(默认 01:00 Asia/Shanghai)触发;业务日期 = 北京时间昨日(与页面 T-1 口径一致)
|
||||
// ④ 截图 → 上传对象存储 → 拿到公开 HTTPS 地址后才发通知
|
||||
// ⑤ 逐用户发送,用「业务日期 + 收件人」幂等键去重;单人失败不影响其他人
|
||||
// ⑥ 截图/上传/通知失败均做有限次数重试与指数退避
|
||||
// ⑦ 每次执行都落一条记录(时间 / 截图地址 / 通知对象 / 成功状态 / 失败原因)
|
||||
//
|
||||
// 立即测试(mode='test'):走同一条链路,但幂等键带随机数,**不写入正式幂等记录**,
|
||||
// 因此不会影响下一次正式定时推送。
|
||||
|
||||
const crypto = require('crypto');
|
||||
const cron = require('node-cron');
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
appendRunRecord,
|
||||
buildIdempotencyKey,
|
||||
getDailyInventoryNotificationRuntimeConfig,
|
||||
getObjectStorageStatus,
|
||||
hasNotified,
|
||||
loadDailyInventoryNotificationState,
|
||||
markNotified,
|
||||
normalizeRecipients,
|
||||
} = require('../services/dailyInventoryNotificationSettings');
|
||||
const {
|
||||
captureAndUploadDailyInventory,
|
||||
chinaTimeParts,
|
||||
resolvePageUrl,
|
||||
} = require('../services/dailyInventoryScreenshot');
|
||||
const {
|
||||
getYydNotificationCenterRuntimeConfig,
|
||||
isYydNotificationCenterConfigured,
|
||||
sendYydNotification,
|
||||
} = require('../services/yydNotificationCenterClient');
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const LOCK_KEY = 'daily_inventory_notification_job';
|
||||
const LOCK_WAIT_SECONDS = 2;
|
||||
const NOTIFICATION_TITLE = '每日进销存数据';
|
||||
const ACTION_TITLE = '查看每日进销存';
|
||||
|
||||
// 重试策略:最多 3 次,退避 2s / 4s
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_BASE_DELAY_MS = 2000;
|
||||
// 命中时间后允许补跑的窗口与次数上限(防止全天每分钟重试刷屏)
|
||||
const RETRY_WINDOW_MINUTES = 30;
|
||||
const MAX_SCHEDULED_ATTEMPTS_PER_DAY = 3;
|
||||
|
||||
let running = false;
|
||||
let timer = null;
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/** 有限次数重试 + 指数退避 */
|
||||
async function withRetry(task, { label, attempts = MAX_ATTEMPTS, baseDelayMs = RETRY_BASE_DELAY_MS } = {}) {
|
||||
let lastError = null;
|
||||
for (let index = 0; index < attempts; index += 1) {
|
||||
try {
|
||||
return await task();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const delay = baseDelayMs * (2 ** index);
|
||||
console.error(
|
||||
`[DailyInventoryNotify] ${label} 第 ${index + 1}/${attempts} 次失败:${error.message}`
|
||||
+ `${index < attempts - 1 ? `,${delay}ms 后重试` : ''}`
|
||||
);
|
||||
if (index < attempts - 1) await sleep(delay);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/** 业务日期 = 北京时间昨日(页面标注 T-1,凌晨跑批产出昨日快照) */
|
||||
function resolveBizDate(now = new Date()) {
|
||||
const target = now instanceof Date ? now : new Date(now);
|
||||
const shifted = new Date(target.getTime() - 24 * 60 * 60 * 1000);
|
||||
return chinaTimeParts(shifted).bizDate;
|
||||
}
|
||||
|
||||
function beijingTimeText(date = new Date()) {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(target);
|
||||
}
|
||||
|
||||
function currentBeijingHhMm(now = new Date()) {
|
||||
const text = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', hour12: false,
|
||||
}).format(now instanceof Date ? now : new Date(now));
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 全实例锁:拿到锁才继续执行,拿不到说明其它实例正在跑 */
|
||||
async function withJobLock(runner) {
|
||||
const pool = database.local.pool;
|
||||
let connection = null;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
const [rows] = await connection.query('SELECT GET_LOCK(?, ?) AS lockStatus', [LOCK_KEY, LOCK_WAIT_SECONDS]);
|
||||
const acquired = Number(rows?.[0]?.lockStatus || 0) === 1;
|
||||
if (!acquired) {
|
||||
return { success: false, skipped: true, reason: 'lock_not_acquired' };
|
||||
}
|
||||
return await runner();
|
||||
} catch (error) {
|
||||
return { success: false, skipped: true, reason: 'lock_error', error: error.message };
|
||||
} finally {
|
||||
if (connection) {
|
||||
try {
|
||||
await connection.query('DO RELEASE_LOCK(?)', [LOCK_KEY]);
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildNotificationContent({ bizDate, takenAt, imageUrl, pageUrl }) {
|
||||
return [
|
||||
`**数据日期:** ${bizDate}`,
|
||||
`**截图生成时间:** ${takenAt}(${TIMEZONE})`,
|
||||
`**图片地址:** ${imageUrl}`,
|
||||
`**页面地址:** ${pageUrl}`,
|
||||
'',
|
||||
`点击右下角「${ACTION_TITLE}」可直达每日进销存页面。`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次(正式 / 测试)
|
||||
* @param {Object} options
|
||||
* mode 'scheduled' | 'manual' | 'test'
|
||||
* force true 时忽略「今天已调度过」的节流判断(手动触发用)
|
||||
* now 指定执行时刻,便于测试
|
||||
*/
|
||||
async function runDailyInventoryNotificationJob(options = {}) {
|
||||
const mode = options.mode || 'scheduled';
|
||||
const now = options.now ? new Date(options.now) : new Date();
|
||||
const startedAt = now.toISOString();
|
||||
|
||||
if (running) {
|
||||
return { success: false, skipped: true, reason: 'already_running' };
|
||||
}
|
||||
running = true;
|
||||
|
||||
try {
|
||||
return await withJobLock(async () => {
|
||||
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
|
||||
const recipients = normalizeRecipients(config.recipients);
|
||||
const bizDate = resolveBizDate(now);
|
||||
|
||||
const finish = async (payload) => {
|
||||
await appendRunRecord({ ...payload, runAt: startedAt, bizDate, mode, notifyTime: config.notifyTime });
|
||||
return payload;
|
||||
};
|
||||
|
||||
if (!config.enabled) {
|
||||
return finish({ success: false, reason: 'disabled', error: '功能未启用' });
|
||||
}
|
||||
if (!recipients.length) {
|
||||
return finish({ success: false, reason: 'no_recipient', error: '未配置通知用户' });
|
||||
}
|
||||
|
||||
let centerConfig = null;
|
||||
try {
|
||||
centerConfig = await getYydNotificationCenterRuntimeConfig();
|
||||
} catch (_error) {
|
||||
centerConfig = null;
|
||||
}
|
||||
if (!isYydNotificationCenterConfigured(centerConfig)) {
|
||||
return finish({ success: false, reason: 'notification_center_not_configured', error: '统一通知中心未配置' });
|
||||
}
|
||||
|
||||
const storage = await getObjectStorageStatus();
|
||||
if (!storage.configured) {
|
||||
return finish({ success: false, reason: 'object_storage_not_configured', error: '对象存储未配置' });
|
||||
}
|
||||
|
||||
// 截图 → 上传(失败直接结束,绝不发通知)
|
||||
let shot;
|
||||
try {
|
||||
shot = await withRetry(
|
||||
// 对象键按业务日期归档:daily-inventory/2026-09-25/daily-inventory-20260925-010000.png
|
||||
() => captureAndUploadDailyInventory({ now, bizDate }),
|
||||
{ label: '截图并上传' }
|
||||
);
|
||||
} catch (error) {
|
||||
return finish({ success: false, reason: 'screenshot_failed', error: error.message });
|
||||
}
|
||||
|
||||
const pageUrl = resolvePageUrl();
|
||||
const takenAt = beijingTimeText(now);
|
||||
const nonce = crypto.randomBytes(8).toString('hex');
|
||||
|
||||
const deliveries = [];
|
||||
for (const recipient of recipients) {
|
||||
const idempotencyKey = buildIdempotencyKey({ bizDate, recipient, mode, nonce });
|
||||
// 正式推送先查幂等:同一业务日期 + 同一用户只成功通知一次
|
||||
if (mode !== 'test' && await hasNotified(bizDate, recipient)) {
|
||||
deliveries.push({ recipient, status: 'skipped', reason: 'already_notified' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await withRetry(
|
||||
() => sendYydNotification({
|
||||
idempotencyKey,
|
||||
recipientAlias: recipient,
|
||||
title: NOTIFICATION_TITLE,
|
||||
content: buildNotificationContent({
|
||||
bizDate, takenAt, imageUrl: shot.url, pageUrl,
|
||||
}),
|
||||
actionTitle: ACTION_TITLE,
|
||||
actionUrl: pageUrl,
|
||||
}),
|
||||
{ label: `通知 ${recipient}` }
|
||||
);
|
||||
const deliveryId = response?.id || response?.data?.id || null;
|
||||
if (mode !== 'test') await markNotified(bizDate, recipient, { deliveryId, idempotencyKey });
|
||||
deliveries.push({ recipient, status: 'sent', deliveryId });
|
||||
} catch (error) {
|
||||
// 单人失败不阻断其他人
|
||||
console.error(`[DailyInventoryNotify] 通知 ${recipient} 失败:${error.message}`);
|
||||
deliveries.push({ recipient, status: 'failed', error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
const sentCount = deliveries.filter((d) => d.status === 'sent').length;
|
||||
const failedCount = deliveries.filter((d) => d.status === 'failed').length;
|
||||
const success = sentCount > 0;
|
||||
|
||||
console.log(
|
||||
`[DailyInventoryNotify] ${mode} 完成:业务日 ${bizDate},截图 ${shot.url},`
|
||||
+ `通知成功 ${sentCount} / 失败 ${failedCount} / 跳过 ${deliveries.length - sentCount - failedCount}`
|
||||
);
|
||||
|
||||
return finish({
|
||||
success,
|
||||
reason: success ? null : 'notify_failed',
|
||||
error: success ? null : '所有收件人均通知失败',
|
||||
screenshotUrl: shot.url,
|
||||
screenshotKey: shot.key,
|
||||
rowCount: shot.rowCount,
|
||||
recipients: deliveries,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一业务日期已经发起过的正式调度次数与最后一次结果(用于失败重试节流) */
|
||||
async function scheduledAttemptStatus(bizDate) {
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const runs = (Array.isArray(state.runs) ? state.runs : [])
|
||||
.filter((r) => r.bizDate === bizDate && r.mode === 'scheduled');
|
||||
return {
|
||||
count: runs.length,
|
||||
lastSuccess: runs.length ? runs[runs.length - 1].success === true : null,
|
||||
};
|
||||
}
|
||||
|
||||
function minutesOfDay(hhmm) {
|
||||
const match = /^(\d{1,2}):(\d{2})$/.exec(String(hhmm || ''));
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (hour > 23 || minute > 59) return null;
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否应当发起一次正式调度:
|
||||
* · 恰好命中配置时间 → 执行
|
||||
* · 命中时间之后的 RETRY_WINDOW_MINUTES 分钟内 → 仅在上一次失败且未超出重试上限时补跑
|
||||
* · 其余时间 → 不执行
|
||||
*/
|
||||
async function shouldRunScheduled(config, now) {
|
||||
const target = minutesOfDay(config.notifyTime);
|
||||
const current = minutesOfDay(currentBeijingHhMm(now));
|
||||
if (target === null || current === null) return false;
|
||||
|
||||
const diff = current - target;
|
||||
if (diff < 0 || diff > RETRY_WINDOW_MINUTES) return false;
|
||||
if (diff === 0) return true;
|
||||
|
||||
const status = await scheduledAttemptStatus(resolveBizDate(now));
|
||||
return status.lastSuccess === false && status.count < MAX_SCHEDULED_ATTEMPTS_PER_DAY;
|
||||
}
|
||||
|
||||
function startDailyInventoryNotificationScheduler() {
|
||||
const disabled = String(process.env.DAILY_INVENTORY_NOTIFICATION_ENABLED || 'true').trim().toLowerCase() === 'false';
|
||||
if (disabled) {
|
||||
console.log('[DailyInventoryNotify] 已通过环境变量关闭');
|
||||
return null;
|
||||
}
|
||||
if (timer) return timer;
|
||||
// 每分钟检查一次:命中配置的通知时间才真正执行,配置改动无需重启
|
||||
timer = cron.schedule('* * * * *', async () => {
|
||||
try {
|
||||
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
|
||||
if (!config.enabled) return;
|
||||
const now = new Date();
|
||||
if (!(await shouldRunScheduled(config, now))) return;
|
||||
await runDailyInventoryNotificationJob({ mode: 'scheduled', now });
|
||||
} catch (error) {
|
||||
console.error('[DailyInventoryNotify] 定时检查失败:', error.message);
|
||||
}
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
console.log(
|
||||
`[Scheduler] 每日进销存截图通知已注册(按配置时间触发,${TIMEZONE},`
|
||||
+ `失败后 ${RETRY_WINDOW_MINUTES} 分钟内最多补跑 ${MAX_SCHEDULED_ATTEMPTS_PER_DAY} 次)`
|
||||
);
|
||||
return timer;
|
||||
}
|
||||
|
||||
function stopDailyInventoryNotificationScheduler() {
|
||||
if (timer) {
|
||||
timer.stop();
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ACTION_TITLE,
|
||||
LOCK_KEY,
|
||||
MAX_ATTEMPTS,
|
||||
MAX_SCHEDULED_ATTEMPTS_PER_DAY,
|
||||
NOTIFICATION_TITLE,
|
||||
RETRY_BASE_DELAY_MS,
|
||||
TIMEZONE,
|
||||
RETRY_WINDOW_MINUTES,
|
||||
beijingTimeText,
|
||||
buildNotificationContent,
|
||||
currentBeijingHhMm,
|
||||
minutesOfDay,
|
||||
resolveBizDate,
|
||||
runDailyInventoryNotificationJob,
|
||||
startDailyInventoryNotificationScheduler,
|
||||
stopDailyInventoryNotificationScheduler,
|
||||
withRetry,
|
||||
};
|
||||
@ -1,122 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 每日库存快照任务
|
||||
*
|
||||
* 业务库没有按天的历史库存结存表,「期初数量」无法回溯。
|
||||
* 本任务每天定时把 26 个仓库的实时库存落一份到本地快照表 daily_inventory_snapshot,
|
||||
* 使「今日期初 = 上一业务日期末」能够自动滚动。
|
||||
*
|
||||
* 默认 01:30 执行(报表库 01:00 跑批之后)。可用环境变量覆盖:
|
||||
* DAILY_INVENTORY_SNAPSHOT_ENABLED=false 关闭
|
||||
* DAILY_INVENTORY_SNAPSHOT_CRON='30 1 * * *'
|
||||
*/
|
||||
const cron = require('node-cron');
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
const snapshotDb = require('../config/dailyInventorySnapshotDb');
|
||||
const { DAILY_INVENTORY_BASELINE } = require('../config/dailyInventoryBaseline');
|
||||
|
||||
let running = false;
|
||||
let timer = null;
|
||||
|
||||
const JOB_ENABLED = String(process.env.DAILY_INVENTORY_SNAPSHOT_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const JOB_CRON = process.env.DAILY_INVENTORY_SNAPSHOT_CRON || '30 1 * * *';
|
||||
|
||||
/** 业务日 = 库存快照库最新日期(报表库每日凌晨跑批,标注 T-1) */
|
||||
async function resolveBizDate() {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT MAX(CONVERT(VARCHAR(10), date, 23)) AS d FROM rpt.pm_stockMonitor`
|
||||
);
|
||||
return rows[0]?.d || null;
|
||||
}
|
||||
|
||||
function buildInClause(values, prefix) {
|
||||
const params = {};
|
||||
const clause = values
|
||||
.map((v, i) => {
|
||||
params[`${prefix}${i}`] = v;
|
||||
return `@${prefix}${i}`;
|
||||
})
|
||||
.join(',');
|
||||
return { clause, params };
|
||||
}
|
||||
|
||||
async function syncOnce(trigger = 'cron') {
|
||||
if (running) {
|
||||
console.log(`[DailyInventorySnapshot] 上一次任务未结束,跳过 trigger=${trigger}`);
|
||||
return { success: false, skipped: true, reason: 'already_running' };
|
||||
}
|
||||
running = true;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const bizDate = await resolveBizDate();
|
||||
if (!bizDate) {
|
||||
console.log('[DailyInventorySnapshot] 未取到业务日,跳过');
|
||||
return { success: false, skipped: true, reason: 'no_biz_date' };
|
||||
}
|
||||
|
||||
const whs = DAILY_INVENTORY_BASELINE.filter((b) => b.wmsCoId != null);
|
||||
const { clause, params } = buildInClause(whs.map((b) => b.wmsCoId), 'w');
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT i.wms_co_id AS wmsCoId,
|
||||
ISNULL(SUM(CAST(i.qty AS DECIMAL(18,2))), 0) AS closingQty,
|
||||
SUM(CASE WHEN ABS(ISNULL(i.qty, 0)) > 0.0001 THEN 1 ELSE 0 END) AS skuCount,
|
||||
MAX(i.modified) AS takenAt
|
||||
FROM rpt.jst_inventoryInfo i
|
||||
WHERE i.wms_co_id IN (${clause})
|
||||
GROUP BY i.wms_co_id`,
|
||||
params,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
const m = new Map(rows.map((r) => [String(r.wmsCoId), r]));
|
||||
const payload = whs.map((b) => {
|
||||
const r = m.get(String(b.wmsCoId));
|
||||
return {
|
||||
wmsCoId: b.wmsCoId,
|
||||
warehouse: b.warehouse,
|
||||
dbWarehouse: b.dbWarehouse,
|
||||
closingQty: Number(r?.closingQty ?? 0),
|
||||
skuCount: Number(r?.skuCount ?? 0),
|
||||
takenAt: r?.takenAt ? new Date(r.takenAt) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const n = await snapshotDb.upsertSnapshot(bizDate, payload);
|
||||
console.log(`[DailyInventorySnapshot] 业务日 ${bizDate} 写入 ${n}/${whs.length} 仓,耗时 ${Date.now() - startedAt}ms`);
|
||||
return { success: true, bizDate, count: n };
|
||||
} catch (error) {
|
||||
console.error('[DailyInventorySnapshot] 执行失败:', error.message);
|
||||
return { success: false, error: error.message };
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startDailyInventorySnapshotScheduler() {
|
||||
if (!JOB_ENABLED) {
|
||||
console.log('[DailyInventorySnapshot] 已通过环境变量关闭');
|
||||
return null;
|
||||
}
|
||||
if (timer) return timer;
|
||||
if (!cron.validate(JOB_CRON)) {
|
||||
console.error(`[DailyInventorySnapshot] cron 表达式非法: ${JOB_CRON}`);
|
||||
return null;
|
||||
}
|
||||
timer = cron.schedule(JOB_CRON, () => { syncOnce('cron'); }, { timezone: 'Asia/Shanghai' });
|
||||
console.log(`[DailyInventorySnapshot] 已启动,cron=${JOB_CRON} (Asia/Shanghai)`);
|
||||
return timer;
|
||||
}
|
||||
|
||||
function stopDailyInventorySnapshotScheduler() {
|
||||
if (timer) {
|
||||
timer.stop();
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncOnce,
|
||||
startDailyInventorySnapshotScheduler,
|
||||
stopDailyInventorySnapshotScheduler,
|
||||
};
|
||||
@ -1,34 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const cron = require('node-cron');
|
||||
const { runDailyCompare, todayInShanghai } = require('../services/returnMonitorService');
|
||||
|
||||
// 每天 07:00(东八区)跑一轮:同步当日退货入库 + 现有库存 + 每日比对核销
|
||||
const JOB_CRON = String(process.env.RETURN_MONITOR_CRON || '0 7 * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const LOOKBACK_DAYS = Number(process.env.RETURN_MONITOR_LOOKBACK_DAYS || 3);
|
||||
|
||||
async function runReturnMonitorCompare(trigger = 'cron') {
|
||||
const bizDate = todayInShanghai();
|
||||
try {
|
||||
const result = await runDailyCompare({ date: bizDate, trigger, lookbackDays: LOOKBACK_DAYS });
|
||||
console.log(`[ReturnMonitor] 每日比对完成: ${JSON.stringify(result)}`);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[ReturnMonitor] 每日比对失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function startReturnMonitorScheduler() {
|
||||
if (String(process.env.RETURN_MONITOR_JOB_DISABLED || '').trim().toLowerCase() === 'true') {
|
||||
console.log('[ReturnMonitor] 退货入库核销定时任务已禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runReturnMonitorCompare('cron').catch(() => {});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 崔桥工厂仓客退库龄核销任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { startReturnMonitorScheduler, runReturnMonitorCompare };
|
||||
@ -1,505 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常 · 每天定点把异常明细以**文本消息**推到钉钉群
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 业务口径(2026-10-22 定稿):
|
||||
* ① 每天 09:10(核验 09:00 之后)把**当天核验批次**的异常情况发到指定钉钉群;
|
||||
* **0 条也推**(群里看到「本批 0 异常」= 任务没挂)。
|
||||
* ② 不发 .xlsx 附件 —— 群机器人 webhook 发不了文件,业务也确认群里看文本就够。
|
||||
* 内容 = 汇总(发货/异常/异常率/分类型/分仓)+ 前 N 条明细 + 页面链接。
|
||||
* N 由页面配置(默认 30,上限 50);要全部明细就点链接去页面导出 Excel。
|
||||
* 明细一行 = 仓|快递单号|快递|异常类型(**只有 4 段**)。
|
||||
* ⚠️ **不带发货时间**、**不带待处理小时数**(业务 2026-10-22 要求),时间类都到页面看。
|
||||
* ⚠️ 第二段是**快递单号**不是内部单号(同 10-22 二次要求;无单号才退回内部单号)。
|
||||
* ⚠️ 明细**按仓聚集**:同一个仓的行相邻排在一起,组间按该仓异常包裹数降序,
|
||||
* 组内保持服务层时长降序(业务 10-22 要求,方便各仓在群里只看自己那段)。
|
||||
* ⚠️ 取前 N 条时**每仓保底 1 条**(剩余额度按各仓异常数比例分),
|
||||
* 否则异常最多的仓会把额度吃满、其它仓一条都看不到(10-22 真数据踩到过)。
|
||||
* ③ 消息里**必须带「异常」字样**:钉钉群机器人若用「自定义关键词」安全设置,
|
||||
* 关键词一般是「异常」,缺了会被拒(errcode 310000)。
|
||||
* ④ 一天只推一次:状态存 system_config,按「推送日(北京时间)」去重,
|
||||
* 服务重启不会重复轰炸;手动「立即推送」才强制重发。
|
||||
*
|
||||
* 时区:生产容器是 UTC,核验批次和「到点没有」全部用 Asia/Shanghai 判断。
|
||||
*/
|
||||
|
||||
const cron = require('node-cron');
|
||||
const { getLogisticsExceptionExportRows } = require('../services/warehouseLogisticsExceptionService');
|
||||
const { sendTextToWebhook, MAX_CONTENT_BYTES } = require('../services/dingtalkRobotWebhookSender');
|
||||
const {
|
||||
DEFAULT_MAX_DETAIL_ROWS,
|
||||
DEFAULT_PAGE_URL,
|
||||
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
|
||||
isWarehouseLogisticsExceptionNotifyReady,
|
||||
loadWarehouseLogisticsExceptionNotifyState,
|
||||
saveWarehouseLogisticsExceptionNotifyState,
|
||||
} = require('../services/warehouseLogisticsExceptionNotifySettings');
|
||||
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
// 每分钟醒一次,比对「现在是不是配置的推送时刻」—— 这样业务在页面上改时间不用重启后端
|
||||
const DEFAULT_TICK_CRON = '* * * * *';
|
||||
// 钉钉 text 上限 20000 字节;留 300 字节给「已精简」尾巴,避免临界失败
|
||||
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 300;
|
||||
|
||||
let tickRunning = false;
|
||||
|
||||
function pad2(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 北京时间的 YYYY-MM-DD */
|
||||
function chinaDateKey(date = new Date()) {
|
||||
const base = date instanceof Date ? date : new Date(date);
|
||||
if (!Number.isFinite(base.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(base);
|
||||
}
|
||||
|
||||
/** 北京时间的 HH:mm —— 用来跟配置里的 sendTime 比对 */
|
||||
function chinaHourMinute(date = new Date()) {
|
||||
const base = date instanceof Date ? date : new Date(date);
|
||||
if (!Number.isFinite(base.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TIMEZONE,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段清洗:换行/连续空白压成单空格。
|
||||
* ⚠️ 物流描述里常有 `\n`,直接拼进 text 消息会把一条明细劈成两行,看着像两条异常。
|
||||
*/
|
||||
function cleanCell(value, fallback = '') {
|
||||
const text = String(value ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim();
|
||||
return text || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细一行:仓|快递单号|快递|异常类型
|
||||
* ⚠️ 业务明确要求明细**不带发货时间**(10-22 反馈),别再加 sendTime 回去 ——
|
||||
* 仓库看单子只需知道「哪个单、什么异常」,时间类信息一律到页面上看。
|
||||
* ⚠️ 第二段用**快递单号**(`trackingNo` = 上游 `logistic_id`),不是内部单号 `oId`(10-22 二次要求)。
|
||||
* 「未揽收 / 无物流单」这类包裹本来就没有快递单号 → 退回内部单号,否则群里这条无法定位。
|
||||
* ⚠️ 末尾的「待处理小时数」(`pendingHours`)也已按 10-22 三次要求去掉,别再加回来。
|
||||
*/
|
||||
function buildDetailLine(row, index) {
|
||||
const parts = [
|
||||
cleanCell(row?.warehouseName, '未知仓'),
|
||||
cleanCell(row?.trackingNo || row?.oId, '无单号'),
|
||||
cleanCell(row?.carrier, '未知快递'),
|
||||
cleanCell(row?.exceptionLabel, '异常'),
|
||||
];
|
||||
return `${index}. ${parts.join('|')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按仓分桶:组间按异常包裹数降序(与上面「按仓(异常包裹)」块一致),数量相同按首次出现顺序。
|
||||
* 组内保持服务层原顺序(已按 pendingHours 降序,压得最久的排最前),别在这里再排。
|
||||
*/
|
||||
function bucketRowsByWarehouse(rows) {
|
||||
const buckets = new Map();
|
||||
const firstSeen = new Map();
|
||||
(Array.isArray(rows) ? rows : []).forEach((row, index) => {
|
||||
const key = cleanCell(row?.warehouseName, '未知仓');
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, []);
|
||||
firstSeen.set(key, index);
|
||||
}
|
||||
buckets.get(key).push(row);
|
||||
});
|
||||
|
||||
return Array.from(buckets.entries())
|
||||
.map(([key, items]) => ({ key, items, index: firstSeen.get(key) }))
|
||||
.sort((a, b) => b.items.length - a.items.length || a.index - b.index);
|
||||
}
|
||||
|
||||
/**
|
||||
* 明细按仓聚集:同一个仓的行排在一起(10-22 业务要求,便于各仓在群里只看自己那一段)。
|
||||
* - 组间:按该仓异常包裹数降序;组内:保持服务层原顺序(时长降序)。
|
||||
* - 仓名取 `warehouseName`,缺失归到「未知仓」一组,避免 undefined 行被拆成散行。
|
||||
*/
|
||||
function groupRowsByWarehouse(rows) {
|
||||
return bucketRowsByWarehouse(rows).flatMap((group) => group.items);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按仓分配明细额度,返回**已按仓聚集**的行。
|
||||
* ⚠️ 不能直接 `rows.slice(0, N)`:某个仓异常特别多时会一口气吃满额度,
|
||||
* 真数据就出现过 40 条明细全是一个仓、其它仓一条都看不到(10-22 实测)。
|
||||
* 规则:
|
||||
* ① 仓数 ≥ 额度 → 每仓 1 条,砍掉的是异常最少的仓;
|
||||
* ② 否则每仓先保底 1 条,剩余额度按「各仓剩余条数」比例分(最大余数法,保证总数正好等于额度)。
|
||||
*/
|
||||
function pickDetailRows(rows, limit) {
|
||||
const groups = bucketRowsByWarehouse(rows);
|
||||
const total = groups.reduce((sum, group) => sum + group.items.length, 0);
|
||||
const quota = Math.max(0, Math.min(Math.round(Number(limit) || 0), total));
|
||||
|
||||
if (quota === 0) return [];
|
||||
if (quota >= total) return groups.flatMap((group) => group.items);
|
||||
if (groups.length >= quota) return groups.slice(0, quota).map((group) => group.items[0]);
|
||||
|
||||
const alloc = groups.map((group) => ({ group, take: 1, left: group.items.length - 1 }));
|
||||
const remaining = quota - groups.length;
|
||||
const leftTotal = alloc.reduce((sum, item) => sum + item.left, 0);
|
||||
|
||||
if (remaining > 0 && leftTotal > 0) {
|
||||
alloc.forEach((item) => {
|
||||
item.exact = (item.left / leftTotal) * remaining;
|
||||
item.extra = Math.min(item.left, Math.floor(item.exact));
|
||||
});
|
||||
let assigned = alloc.reduce((sum, item) => sum + item.extra, 0);
|
||||
// 取整剩下的零星额度给「小数部分最大」的仓,避免白浪费额度
|
||||
[...alloc]
|
||||
.sort((a, b) => (b.exact - Math.floor(b.exact)) - (a.exact - Math.floor(a.exact)))
|
||||
.forEach((item) => {
|
||||
if (assigned >= remaining || item.extra >= item.left) return;
|
||||
item.extra += 1;
|
||||
assigned += 1;
|
||||
});
|
||||
alloc.forEach((item) => {
|
||||
item.take += item.extra;
|
||||
});
|
||||
}
|
||||
|
||||
return alloc.flatMap((item) => item.group.items.slice(0, item.take));
|
||||
}
|
||||
|
||||
/** 汇总里的「按仓」块:只列有异常的仓,按异常包裹数降序,最多 12 行(12 个发货仓够用)*/
|
||||
function buildCenterLines(data = {}) {
|
||||
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||
const packagesByCenter = rows.reduce((acc, row) => {
|
||||
const key = String(row?.centerKey || '');
|
||||
if (!key) return acc;
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (data.centers || [])
|
||||
.map((center) => ({
|
||||
name: cleanCell(center.name || center.key, '未知仓'),
|
||||
exceptionPackages: packagesByCenter[center.key] || 0,
|
||||
shippedOrders: Number(center.shippedOrders) || 0,
|
||||
}))
|
||||
.filter((item) => item.exceptionPackages > 0)
|
||||
.sort((a, b) => b.exceptionPackages - a.exceptionPackages)
|
||||
.slice(0, 12)
|
||||
.map((item, index) => `${index + 1}. ${item.name} ${item.exceptionPackages} 个(发货 ${item.shippedOrders} 单)`);
|
||||
}
|
||||
|
||||
function buildTypeLine(data = {}) {
|
||||
const summary = data.summary || {};
|
||||
const entries = Object.entries(summary.byType || {});
|
||||
if (!entries.length) return '';
|
||||
return entries
|
||||
.map(([type, count]) => {
|
||||
const meta = (data.types || []).find((item) => item.key === type);
|
||||
return { label: meta?.label || type, count: Number(count) || 0 };
|
||||
})
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.map((item) => `${item.label} ${item.count}`)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function buildPageLink(pageUrl, verifyDate) {
|
||||
const base = String(pageUrl || DEFAULT_PAGE_URL);
|
||||
if (!verifyDate) return base;
|
||||
const glue = base.includes('?') ? '&' : '?';
|
||||
return `${base}${glue}date=${encodeURIComponent(verifyDate)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装群消息正文(纯函数,便于单测)。
|
||||
* 结构:抬头 → 数字 → 分类型 → 按仓 → 明细前 N 条 → 页面链接
|
||||
* 超长处理:从明细末尾往前砍,砍到能发为止(绝不让钉钉 400 把整条消息吞掉)。
|
||||
*
|
||||
* @returns {{text:string,rowCount:number,listedCount:number,bytes:number,truncated:boolean}}
|
||||
*/
|
||||
function buildNotifyText(data = {}, options = {}) {
|
||||
const batch = data.batch || {};
|
||||
const summary = data.summary || {};
|
||||
const rows = Array.isArray(data.rows) ? data.rows : [];
|
||||
const exclusions = data.exclusions || {};
|
||||
|
||||
const maxDetailRows = Number.isFinite(Number(options.maxDetailRows))
|
||||
? Math.max(0, Math.min(50, Math.round(Number(options.maxDetailRows))))
|
||||
: DEFAULT_MAX_DETAIL_ROWS;
|
||||
const pageUrl = buildPageLink(options.pageUrl || DEFAULT_PAGE_URL, batch.verifyDate);
|
||||
|
||||
const totalPackages = Number(summary.exceptionPackages) || rows.length;
|
||||
const shipped = Number(summary.shippedOrders) || 0;
|
||||
const ratio = Number.isFinite(Number(summary.exceptionRate)) ? `${Number(summary.exceptionRate)}%` : '—';
|
||||
|
||||
const head = [
|
||||
`【仓储物流转运异常】${cleanCell(batch.label, '本批核验')}`,
|
||||
`已发货 ${shipped} 单 / ${Number(summary.packageCount) || 0} 个包裹,异常 ${totalPackages} 个包裹(${ratio})`,
|
||||
];
|
||||
|
||||
const typeLine = buildTypeLine(data);
|
||||
if (typeLine) head.push(`类型:${typeLine}`);
|
||||
|
||||
if (totalPackages === 0) {
|
||||
head.push('本批无转运异常,全部正常。');
|
||||
}
|
||||
|
||||
const excludedCount = (Number(exclusions.carrierPackages) || 0)
|
||||
+ (Number(exclusions.voidedPackages) || 0)
|
||||
+ (Number(exclusions.nonRealPackages) || 0);
|
||||
if (excludedCount > 0) {
|
||||
head.push(`已剔除 ${excludedCount} 个包裹(承运商 / 已终止 / 刷单·信誉单,不计考核)`);
|
||||
}
|
||||
|
||||
const centerLines = totalPackages > 0 ? buildCenterLines(data) : [];
|
||||
// 明细按仓聚集 + 每仓保底 1 条(避免大仓吃满额度),组间按异常数降序、组内保持时长降序
|
||||
const detailAll = pickDetailRows(rows, maxDetailRows).map((row, index) => buildDetailLine(row, index + 1));
|
||||
const wantCount = detailAll.length;
|
||||
|
||||
// 尾部固定:剩余条数说明 + 页面链接(明细看全量、要 Excel 就在页面上导出)
|
||||
const buildTail = (listed) => {
|
||||
const lines = [];
|
||||
if (totalPackages > 0) {
|
||||
if (listed === 0) {
|
||||
lines.push('');
|
||||
lines.push(`明细共 ${totalPackages} 条,消息里不放,请点下面链接查看。`);
|
||||
} else if (totalPackages > listed) {
|
||||
lines.push(`…其余 ${totalPackages - listed} 条见页面。`);
|
||||
}
|
||||
lines.push(`查看全部 / 导出 Excel:${pageUrl}`);
|
||||
}
|
||||
return lines;
|
||||
};
|
||||
|
||||
const assemble = (listed, withCenters, extraNote = '') => {
|
||||
const blocks = [...head];
|
||||
if (withCenters && centerLines.length) {
|
||||
blocks.push('');
|
||||
blocks.push('按仓(异常包裹):');
|
||||
blocks.push(...centerLines);
|
||||
}
|
||||
if (listed > 0) {
|
||||
blocks.push('');
|
||||
blocks.push(`明细前 ${listed} 条:`);
|
||||
blocks.push(...detailAll.slice(0, listed));
|
||||
}
|
||||
const tail = buildTail(listed);
|
||||
if (tail.length) blocks.push(...tail);
|
||||
if (extraNote) blocks.push(extraNote);
|
||||
const text = blocks.join('\n');
|
||||
return { text, bytes: Buffer.byteLength(text, 'utf8') };
|
||||
};
|
||||
|
||||
// 先按期望条数拼;超长就从明细末尾往前砍(先砍明细,再砍分仓块)
|
||||
// ⚠️ truncated 只表示「因字节上限被动砍过」,不表示「按配置只列了前 N 条」——
|
||||
// 后者是正常行为(listedCount < rowCount),混在一起会让页面误报「超长精简」。
|
||||
let listed = wantCount;
|
||||
let withCenters = centerLines.length > 0;
|
||||
let result = assemble(listed, withCenters);
|
||||
let truncated = false;
|
||||
|
||||
while (result.bytes > SAFE_CONTENT_BYTES && listed > 0) {
|
||||
listed -= 1;
|
||||
truncated = true;
|
||||
result = assemble(listed, withCenters);
|
||||
}
|
||||
if (result.bytes > SAFE_CONTENT_BYTES && withCenters) {
|
||||
withCenters = false;
|
||||
result = assemble(listed, withCenters);
|
||||
}
|
||||
if (result.bytes > SAFE_CONTENT_BYTES) {
|
||||
// 极端情况(仓名/链接特别长):硬截断,至少保证消息能发出去
|
||||
const chars = Array.from(result.text);
|
||||
let bytes = 0;
|
||||
let cut = '';
|
||||
for (const char of chars) {
|
||||
const size = Buffer.byteLength(char, 'utf8');
|
||||
if (bytes + size > SAFE_CONTENT_BYTES) break;
|
||||
bytes += size;
|
||||
cut += char;
|
||||
}
|
||||
result = { text: cut, bytes };
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
return {
|
||||
text: result.text,
|
||||
rowCount: totalPackages,
|
||||
listedCount: listed,
|
||||
bytes: result.bytes,
|
||||
truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次推送。参数:
|
||||
* now 指定时间(测试用)
|
||||
* date 指定核验日 'YYYY-MM-DD'
|
||||
* force 忽略「今天已经推过」的限制(页面「立即推送」用)
|
||||
* dryRun 只组装消息不发送(预览 / 单测用)
|
||||
* probe 只发一条通道测试文本(不查库、不依赖业务数据)
|
||||
*/
|
||||
async function pushWarehouseLogisticsExceptionNotify(options = {}) {
|
||||
const now = options.now === undefined ? new Date() : new Date(options.now);
|
||||
const pushDate = chinaDateKey(now);
|
||||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
|
||||
|
||||
// 通道测试:只验 webhook,不读业务数据(数据慢不该背「通道坏」的锅)
|
||||
if (options.probe === true) {
|
||||
if (!isWarehouseLogisticsExceptionNotifyReady(config)) {
|
||||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||||
}
|
||||
const delivery = await sendTextToWebhook({
|
||||
webhookUrl: config.webhookUrl,
|
||||
secret: config.webhookSecret,
|
||||
content: '【仓储物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。',
|
||||
});
|
||||
return { sent: true, probe: true, delivery };
|
||||
}
|
||||
|
||||
const state = await loadWarehouseLogisticsExceptionNotifyState();
|
||||
if (state?.lastSentDate === pushDate && options.force !== true) {
|
||||
return { sent: false, reason: 'already_sent_today', pushDate, lastSentAt: state.lastSentAt || null };
|
||||
}
|
||||
if (!config.enabled && options.force !== true) {
|
||||
return { sent: false, reason: 'disabled' };
|
||||
}
|
||||
|
||||
const dryRun = options.dryRun === true;
|
||||
if (!dryRun && !isWarehouseLogisticsExceptionNotifyReady(config)) {
|
||||
return { sent: false, reason: 'not_configured', missingFields: config.missingFields };
|
||||
}
|
||||
|
||||
const data = await getLogisticsExceptionExportRows({ date: options.date });
|
||||
if (data?.notStarted) {
|
||||
// 还没到核验时刻(例如把推送时间设在 09:00 之前)→ 不推空数据,等下一轮或人工
|
||||
return { sent: false, reason: 'not_started', batch: data.batch || null };
|
||||
}
|
||||
|
||||
const built = buildNotifyText(data, {
|
||||
maxDetailRows: config.maxDetailRows,
|
||||
pageUrl: config.pageUrl,
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
sent: false,
|
||||
reason: 'dry_run',
|
||||
text: built.text,
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
batch: data.batch || null,
|
||||
summary: data.summary || null,
|
||||
};
|
||||
}
|
||||
|
||||
const delivery = await sendTextToWebhook({
|
||||
webhookUrl: config.webhookUrl,
|
||||
secret: config.webhookSecret,
|
||||
content: built.text,
|
||||
atAll: config.atAll === true,
|
||||
});
|
||||
|
||||
await saveWarehouseLogisticsExceptionNotifyState({
|
||||
lastSentDate: pushDate,
|
||||
lastSentAt: new Date().toISOString(),
|
||||
lastStatus: 'sent',
|
||||
lastError: '',
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
verifyDate: data.batch?.verifyDate || '',
|
||||
});
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
rowCount: built.rowCount,
|
||||
listedCount: built.listedCount,
|
||||
bytes: built.bytes,
|
||||
truncated: built.truncated,
|
||||
text: built.text,
|
||||
batch: data.batch || null,
|
||||
summary: data.summary || null,
|
||||
delivery,
|
||||
};
|
||||
}
|
||||
|
||||
async function runWarehouseLogisticsExceptionNotifyJob(source = 'scheduled', options = {}) {
|
||||
try {
|
||||
const result = await pushWarehouseLogisticsExceptionNotify(options);
|
||||
if (result.sent) {
|
||||
console.log(`[WleNotify] ${source} 已推送(${result.rowCount} 条异常,列出 ${result.listedCount} 条,${result.bytes} 字节)`);
|
||||
} else {
|
||||
console.log(`[WleNotify] ${source} 跳过: ${result.reason}`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[WleNotify] ${source} 执行失败:`, error.message || error);
|
||||
try {
|
||||
await saveWarehouseLogisticsExceptionNotifyState({
|
||||
...(await loadWarehouseLogisticsExceptionNotifyState()),
|
||||
lastError: String(error.message || error).slice(0, 500),
|
||||
lastFailedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (_ignored) { /* 状态写失败不影响主流程 */ }
|
||||
return { sent: false, reason: 'error', error: error.message || String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调度:每分钟比对「北京时间 HH:mm == 配置的 sendTime」。
|
||||
* 之所以不用固定 cron 表达式:业务在页面上改时间后要立即生效,不能等后端重启。
|
||||
*/
|
||||
function startWarehouseLogisticsExceptionNotifyScheduler() {
|
||||
const disabled = String(process.env.DISABLE_WLE_NOTIFY || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] 物流转运异常钉钉推送已禁用');
|
||||
return;
|
||||
}
|
||||
const expression = String(process.env.WLE_NOTIFY_TICK_CRON || DEFAULT_TICK_CRON).trim();
|
||||
cron.schedule(expression, () => {
|
||||
if (tickRunning) return;
|
||||
tickRunning = true;
|
||||
(async () => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig();
|
||||
if (!config.enabled) return;
|
||||
if (chinaHourMinute(now) !== config.sendTime) return;
|
||||
await runWarehouseLogisticsExceptionNotifyJob('daily_schedule', { now });
|
||||
} catch (error) {
|
||||
console.error('[WleNotify] 定时检查失败:', error.message || error);
|
||||
} finally {
|
||||
tickRunning = false;
|
||||
}
|
||||
})();
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
console.log(`[Scheduler] 物流转运异常钉钉推送已注册: 每分钟检查,到点(默认 09:10)推送 (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_TICK_CRON,
|
||||
SAFE_CONTENT_BYTES,
|
||||
TIMEZONE,
|
||||
buildCenterLines,
|
||||
buildDetailLine,
|
||||
buildNotifyText,
|
||||
buildTypeLine,
|
||||
chinaDateKey,
|
||||
chinaHourMinute,
|
||||
groupRowsByWarehouse,
|
||||
pickDetailRows,
|
||||
cleanCell,
|
||||
pushWarehouseLogisticsExceptionNotify,
|
||||
runWarehouseLogisticsExceptionNotifyJob,
|
||||
startWarehouseLogisticsExceptionNotifyScheduler,
|
||||
};
|
||||
@ -1,53 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 缺货补货监控(拣货位 → 存货位)本地表
|
||||
* --------------------------------------------------------------------------
|
||||
* 为什么需要本地表:
|
||||
* 聚水潭「箱及仓位库存」在副本里是**视图** rpt.jst_packInventoryInfo,
|
||||
* 只有货位号(bin)与数量,**没有货位类型**(拣货位 / 存货位)这层主数据
|
||||
* (全库 5,496 个字段里没有 bin_type / location_type 一类的列,见 2026-10-22 复核)。
|
||||
* 而业务判断「拣货位缺货、要从存货位补」必须先知道哪些 bin 是拣货位,
|
||||
* 所以把业务给的货位清单导入本地库,作为判定基准。
|
||||
*
|
||||
* 两张表:
|
||||
* bin_replenish_pick_bin —— 拣货位清单(按仓库隔离,可重复导入覆盖)
|
||||
* bin_replenish_import_batch —— 导入批次日志(谁、什么时候、导入多少个)
|
||||
*
|
||||
* 未导入清单时,服务层回退到「pack_type = 'Bin' 的记录」作为拣货位口径,
|
||||
* 页面会明确提示当前用的是回退口径。
|
||||
*/
|
||||
|
||||
const database = require('../config/database');
|
||||
|
||||
const SCHEMA_SQL = [
|
||||
`CREATE TABLE IF NOT EXISTS bin_replenish_pick_bin (
|
||||
warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识(changzhou/henglin3/subei/suining/henglin2/wh7)',
|
||||
bin_code VARCHAR(64) NOT NULL COMMENT '货位号(大写去空格)',
|
||||
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
|
||||
PRIMARY KEY (warehouse_key, bin_code),
|
||||
KEY idx_brpb_batch (batch_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位清单'`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS bin_replenish_import_batch (
|
||||
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
|
||||
warehouse_key VARCHAR(32) NOT NULL COMMENT '仓库标识',
|
||||
file_name VARCHAR(255) NULL COMMENT '文件名',
|
||||
total_cells INT NOT NULL DEFAULT 0 COMMENT '扫描到的候选单元格数',
|
||||
imported_rows INT NOT NULL DEFAULT 0 COMMENT '写入的货位数',
|
||||
imported_by VARCHAR(100) NULL COMMENT '导入人',
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
|
||||
PRIMARY KEY (batch_id),
|
||||
KEY idx_brpib_wh (warehouse_key, imported_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='缺货补货监控-拣货位导入批次'`,
|
||||
];
|
||||
|
||||
async function ensureBinReplenishSchema() {
|
||||
for (const sql of SCHEMA_SQL) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await database.local.query(sql);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ensureBinReplenishSchema };
|
||||
@ -1,463 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 缺货补货监控(订单驱动版,2026-10-22 重写)
|
||||
* ==========================================================================
|
||||
* 业务需求(用户确认):
|
||||
* 监控崔桥工厂仓(主仓)现有库存 —— 看哪些「拣货库位」的货不够今天的订单拣,
|
||||
* 需要从「存储库位」把货补过去。
|
||||
*
|
||||
* ── 货位口径(前缀规则,用户指定)──────────────────────────────────────
|
||||
* 拣货库位(拣货仓位):以 XA / XD / XE 开头
|
||||
* 存储库位(存储仓位):以 XX / X9 / X5 / X4 开头
|
||||
* 其余前缀(DB / Y* / Z* / YC 等,崔桥占比极小)归为「未分类」,不计入拣货/存储。
|
||||
*
|
||||
* ── 数据源 ───────────────────────────────────────────────────────────
|
||||
* 库存:rpt.jst_packInventoryInfo(按 wms_co_id + sku_id 聚合,按前缀切拣货/存储)
|
||||
* 订单:rpt.jst_Orders × jst_OrderItem(线上店铺)
|
||||
* + rpt.jst_qmcust_orderlist × jst_qmcust_orderlist_item(全民客户 B2B)
|
||||
* + 组合装(sku_type='combine')→ jst_comboProductItem 展开为子件
|
||||
* ⚠️ 仓库维度必须用 **wms_co_id**:wh_id 是货主(简尚=1),
|
||||
* 按 wh_id 会把多仓混成一个桶。
|
||||
* 订单日期:取该仓订单表 send_date 的最大日期(即「最近一个有订单的营业日」,
|
||||
* 副本 ETL 有延迟时不会为空,页面会明确标注统计的是哪一天)。
|
||||
*
|
||||
* ── 判断逻辑(核心)──────────────────────────────────────────────────
|
||||
* 对每一个 SKU(在拣货/存储位上有库存):
|
||||
* 拣货位库存 pickQty = 该 SKU 在 XA/XD/XE 货位上的 qty 合计
|
||||
* 存储位库存 storageQty = 该 SKU 在 XX/X9/X5/X4 货位上的 qty 合计
|
||||
* 当日订单 orderQty = 该 SKU 在订单日期当天的订单量(含组合装展开)
|
||||
* 超出订单数 excess = max(0, orderQty − pickQty)
|
||||
* —— 拣货位不够今天的订单拣,超出的这部分要从存储位补到拣货位
|
||||
* 建议补货量 suggestQty = min(excess, storageQty) (不能超过存储位现有量)
|
||||
* 存储缺口 shortfall = max(0, excess − storageQty)(存储位也不够,需采购/急调)
|
||||
*
|
||||
* 状态:
|
||||
* short 存储不足(orderQty > pickQty 且 storageQty < excess)—— 最紧急
|
||||
* need 需补货 (orderQty > pickQty 且 storageQty ≥ excess)
|
||||
* enough 拣货位充足(0 < orderQty ≤ pickQty)
|
||||
* no_order 今日无单(orderQty = 0)
|
||||
* (pickQty = 0 且 orderQty > 0 归入 need/short,并标记 pickEmpty 强调)
|
||||
*
|
||||
* ⚠️ 性能:库存视图聚合 + 当日订单 4 分支 union 各自都要 30s~1min,
|
||||
* 必须缓存(默认 10 分钟)+ 启动预热,不要每次请求都回源。
|
||||
*/
|
||||
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
|
||||
/**
|
||||
* 仓库清单:**按 wms_co_id 取数**。
|
||||
* 前缀货位口径(XA/XD/XE 拣货、XX/X9/X5/X4 存储)是崔桥工厂仓的约定;
|
||||
* 其他仓若用不同前缀,页面会标注,默认聚焦崔桥工厂仓。
|
||||
*/
|
||||
const WAREHOUSES = Object.freeze({
|
||||
cuiqiao: { key: 'cuiqiao', wmsCoId: 15038426, name: '简尚-崔桥工厂仓', shortName: '崔桥工厂仓', order: 10 },
|
||||
shandong: { key: 'shandong', wmsCoId: 15137523, name: '简尚-山东青岗集仓', shortName: '山东青岗集仓', order: 20 },
|
||||
henglin2: { key: 'henglin2', wmsCoId: 15033302, name: '简尚-横林二仓', shortName: '横林二仓', order: 30 },
|
||||
subei: { key: 'subei', wmsCoId: 15038347, name: '简尚-新苏北仓', shortName: '新苏北仓', order: 40 },
|
||||
henan: { key: 'henan', wmsCoId: 15539084, name: '简尚-河南洛宁仓', shortName: '河南洛宁仓', order: 50 },
|
||||
shandongBrand: { key: 'shandongBrand', wmsCoId: 15182705, name: '简尚-山东品牌仓', shortName: '山东品牌仓', order: 60 },
|
||||
cuiqiaoBcp: { key: 'cuiqiaoBcp', wmsCoId: 15718196, name: '简尚-崔桥工厂半成品包装项目仓', shortName: '崔桥半成品包装仓', order: 70 },
|
||||
suining: { key: 'suining', wmsCoId: 15032603, name: '简尚-睢宁仓', shortName: '睢宁仓', order: 80 },
|
||||
cuiqiaoBrand: { key: 'cuiqiaoBrand', wmsCoId: 15870495, name: '简尚-崔桥工厂品牌仓', shortName: '崔桥品牌仓', order: 90 },
|
||||
henglin3: { key: 'henglin3', wmsCoId: 15043561, name: '简尚-横林三仓', shortName: '横林三仓', order: 100 },
|
||||
});
|
||||
|
||||
const WAREHOUSE_LIST = Object.values(WAREHOUSES).sort((a, b) => a.order - b.order);
|
||||
|
||||
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
|
||||
const DEFAULT_CACHE_TTL_MS = 10 * 60 * 1000; // 与上游 ETL 的 10 分钟增量节奏对齐
|
||||
const MAX_PAGE_SIZE = 5000;
|
||||
|
||||
// ── 货位前缀规则(用户指定)────────────────────────────────────────
|
||||
const PICK_PREFIXES = Object.freeze(['XA', 'XD', 'XE']);
|
||||
const STORE_PREFIXES = Object.freeze(['XX', 'X9', 'X5', 'X4']);
|
||||
const PICK_PREFIX_IN = PICK_PREFIXES.map((p) => `'${p}'`).join(',');
|
||||
const STORE_PREFIX_IN = STORE_PREFIXES.map((p) => `'${p}'`).join(',');
|
||||
|
||||
const STATUS = Object.freeze({
|
||||
SHORT: 'short',
|
||||
NEED: 'need',
|
||||
ENOUGH: 'enough',
|
||||
NO_ORDER: 'no_order',
|
||||
});
|
||||
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
short: '存储不足',
|
||||
need: '需补货',
|
||||
enough: '拣货位充足',
|
||||
no_order: '今日无单',
|
||||
});
|
||||
|
||||
// 排序权重:最紧急的排最前
|
||||
const STATUS_RANK = Object.freeze({ short: 4, need: 3, no_order: 2, enough: 1 });
|
||||
|
||||
const cacheStore = new Map();
|
||||
let cacheEpoch = 0;
|
||||
|
||||
function toText(value, maxLength = 200) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
}
|
||||
|
||||
function round2(value) {
|
||||
return Math.round((toNumber(value) + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
|
||||
function resolveWarehouseKey(value) {
|
||||
const key = toText(value, 32).toLowerCase();
|
||||
return WAREHOUSES[key] ? key : DEFAULT_WAREHOUSE_KEY;
|
||||
}
|
||||
|
||||
function resolveWarehouse(value) {
|
||||
return WAREHOUSES[resolveWarehouseKey(value)];
|
||||
}
|
||||
|
||||
function resolveCacheTtlMs() {
|
||||
const raw = process.env.BIN_REPLENISH_CACHE_TTL_MS;
|
||||
if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CACHE_TTL_MS;
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num) || num < 0) return DEFAULT_CACHE_TTL_MS;
|
||||
return num;
|
||||
}
|
||||
|
||||
/** 订单日期的窗口边界(仅用日期字符串,避免时区问题;副本 send_date 按中国营业日存储) */
|
||||
function orderWindow(orderDate) {
|
||||
const d = new Date(`${orderDate}T00:00:00Z`);
|
||||
d.setUTCDate(d.getUTCDate() + 1);
|
||||
const next = d.toISOString().slice(0, 10);
|
||||
return { shipFrom: `${orderDate} 00:00:00`, shipTo: `${next} 00:00:00` };
|
||||
}
|
||||
|
||||
// ── 副本取数 ────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 按 SKU 聚合库存,并按货位前缀切出拣货位 / 存储位库存。
|
||||
* 只统计用户指定的前缀宇宙(XA/XD/XE/XX/X9/X5/X4),其余货位不计入。
|
||||
*/
|
||||
async function loadReplicaInventory(warehouse) {
|
||||
const sql = `
|
||||
SELECT p.sku_id AS skuId,
|
||||
SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN})
|
||||
THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS pickQty,
|
||||
SUM(CASE WHEN UPPER(LEFT(LTRIM(p.bin), 2)) IN (${STORE_PREFIX_IN})
|
||||
THEN CAST(ISNULL(p.qty, 0) AS DECIMAL(18,2)) ELSE 0 END) AS storageQty,
|
||||
MAX(s.name) AS productName,
|
||||
MAX(s.supplier_name) AS supplier
|
||||
FROM rpt.jst_packInventoryInfo p
|
||||
LEFT JOIN rpt.jst_sku s ON s.sku_id = p.sku_id
|
||||
WHERE p.wms_co_id = @wmsCoId
|
||||
AND ISNULL(p.bin, '') <> ''
|
||||
AND UPPER(LEFT(LTRIM(p.bin), 2)) IN (${PICK_PREFIX_IN}, ${STORE_PREFIX_IN})
|
||||
GROUP BY p.sku_id`;
|
||||
return cloudDb.query(sql, { wmsCoId: warehouse.wmsCoId }, { requestTimeout: 600000 });
|
||||
}
|
||||
|
||||
/** 该仓最近的订单日期(线上 + 全民客户 取最大 send_date 日期) */
|
||||
async function loadOrderDate(warehouse) {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT MAX(d) AS d FROM (
|
||||
SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
|
||||
FROM rpt.jst_Orders WHERE wms_co_id = @wmsCoId
|
||||
UNION ALL
|
||||
SELECT MAX(CONVERT(VARCHAR(10), send_date, 23)) AS d
|
||||
FROM rpt.jst_qmcust_orderlist WHERE wms_co_id = @wmsCoId
|
||||
) t`,
|
||||
{ wmsCoId: warehouse.wmsCoId },
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
return (rows && rows[0] && rows[0].d) || null;
|
||||
}
|
||||
|
||||
/** 当日(订单日期)各 SKU 的订单量:4 个来源分支,组合装展开为子件。 */
|
||||
async function loadTodayOrderMap(warehouse, orderDate) {
|
||||
if (!orderDate) return new Map();
|
||||
const { shipFrom, shipTo } = orderWindow(orderDate);
|
||||
const sql = `
|
||||
WITH ship AS (
|
||||
-- 1) 线上店铺订单:普通商品行
|
||||
SELECT oi.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||||
FROM rpt.jst_OrderItem oi
|
||||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||
WHERE o.wms_co_id = @wmsCoId
|
||||
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
|
||||
GROUP BY oi.sku_id
|
||||
UNION ALL
|
||||
-- 2) 线上店铺订单:组合装行 → 展开为子件
|
||||
SELECT ci.src_sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||||
FROM rpt.jst_OrderItem oi
|
||||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
|
||||
WHERE o.wms_co_id = @wmsCoId
|
||||
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
AND oi.sku_type = 'combine'
|
||||
GROUP BY ci.src_sku_id
|
||||
UNION ALL
|
||||
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
|
||||
SELECT oi.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||||
FROM rpt.jst_qmcust_orderlist_item oi
|
||||
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
|
||||
WHERE o.wms_co_id = @wmsCoId
|
||||
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
|
||||
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
|
||||
GROUP BY oi.sku_id
|
||||
UNION ALL
|
||||
-- 4) 全民客户订单:组合装行 → 展开为子件
|
||||
SELECT ci.src_sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18,2))) AS orderQty
|
||||
FROM rpt.jst_qmcust_orderlist_item oi
|
||||
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
|
||||
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
|
||||
WHERE o.wms_co_id = @wmsCoId
|
||||
AND o.send_date >= @shipFrom AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
|
||||
AND oi.sku_type = 'combine'
|
||||
GROUP BY ci.src_sku_id
|
||||
)
|
||||
SELECT skuId, SUM(orderQty) AS orderQty
|
||||
FROM ship
|
||||
GROUP BY skuId`;
|
||||
|
||||
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
|
||||
const rows = await runQuery(
|
||||
sql,
|
||||
{ wmsCoId: warehouse.wmsCoId, shipFrom, shipTo },
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
const map = new Map();
|
||||
(rows || []).forEach((row) => {
|
||||
const skuId = toText(row.skuId, 120);
|
||||
if (skuId) map.set(skuId, toNumber(row.orderQty));
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
// ── 组装 ────────────────────────────────────────────────────────────
|
||||
function buildSkuRows({ invRows, orderMap, orderDate }) {
|
||||
const rows = [];
|
||||
(invRows || []).forEach((row) => {
|
||||
const skuId = toText(row.skuId, 120);
|
||||
if (!skuId) return;
|
||||
const pickQty = round2(row.pickQty);
|
||||
const storageQty = round2(row.storageQty);
|
||||
const orderQty = round2(toNumber(orderMap.get(skuId), 0));
|
||||
const excess = Math.max(0, round2(orderQty - pickQty));
|
||||
|
||||
let status = STATUS.NO_ORDER;
|
||||
let suggestQty = 0;
|
||||
let shortfall = 0;
|
||||
let pickEmpty = false;
|
||||
|
||||
if (orderQty > 0) {
|
||||
if (pickQty <= 0) pickEmpty = true;
|
||||
if (excess > 0) {
|
||||
const can = Math.min(excess, storageQty);
|
||||
suggestQty = round2(can);
|
||||
if (storageQty >= excess) {
|
||||
status = STATUS.NEED;
|
||||
} else {
|
||||
status = STATUS.SHORT;
|
||||
shortfall = round2(excess - storageQty);
|
||||
}
|
||||
} else {
|
||||
status = STATUS.ENOUGH;
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
skuId,
|
||||
productName: toText(row.productName, 300) || '',
|
||||
supplier: toText(row.supplier, 120) || '',
|
||||
status,
|
||||
statusLabel: STATUS_LABELS[status],
|
||||
pickQty,
|
||||
storageQty,
|
||||
totalQty: round2(pickQty + storageQty),
|
||||
orderQty,
|
||||
excess,
|
||||
suggestQty,
|
||||
shortfall,
|
||||
pickEmpty,
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildSummary(rows, orderSkuCount) {
|
||||
const summary = {
|
||||
skuTotal: rows.length,
|
||||
need: 0,
|
||||
short: 0,
|
||||
enough: 0,
|
||||
noOrder: 0,
|
||||
pickEmpty: 0,
|
||||
suggestQtyTotal: 0,
|
||||
orderSkuCount: orderSkuCount || 0,
|
||||
orderQtyTotal: 0,
|
||||
exceedTotal: 0,
|
||||
shortfallTotal: 0,
|
||||
pickTotal: 0,
|
||||
storageTotal: 0,
|
||||
};
|
||||
const STATUS_SUM_KEY = { need: 'need', short: 'short', enough: 'enough', no_order: 'noOrder' };
|
||||
rows.forEach((row) => {
|
||||
const key = STATUS_SUM_KEY[row.status];
|
||||
if (key && summary[key] !== undefined) summary[key] += 1;
|
||||
summary.suggestQtyTotal += row.suggestQty || 0;
|
||||
summary.orderQtyTotal += row.orderQty || 0;
|
||||
summary.exceedTotal += row.excess || 0;
|
||||
summary.shortfallTotal += row.shortfall || 0;
|
||||
summary.pickTotal += row.pickQty || 0;
|
||||
summary.storageTotal += row.storageQty || 0;
|
||||
if (row.pickEmpty) summary.pickEmpty += 1;
|
||||
});
|
||||
summary.suggestQtyTotal = round2(summary.suggestQtyTotal);
|
||||
summary.orderQtyTotal = round2(summary.orderQtyTotal);
|
||||
summary.exceedTotal = round2(summary.exceedTotal);
|
||||
summary.shortfallTotal = round2(summary.shortfallTotal);
|
||||
summary.pickTotal = round2(summary.pickTotal);
|
||||
summary.storageTotal = round2(summary.storageTotal);
|
||||
return summary;
|
||||
}
|
||||
|
||||
async function loadDataset(warehouse, { refresh = false } = {}) {
|
||||
const orderDate = await loadOrderDate(warehouse);
|
||||
// 把订单日期纳入缓存键:跨天后自动失效,无需等 TTL
|
||||
const cacheKey = `${warehouse.key}::${orderDate || 'none'}`;
|
||||
const cached = cacheStore.get(cacheKey);
|
||||
const ttl = resolveCacheTtlMs();
|
||||
const now = Date.now();
|
||||
if (!refresh && cached && ttl > 0 && now - cached.at < ttl) {
|
||||
return { ...cached, cacheHit: true };
|
||||
}
|
||||
|
||||
const epoch = cacheEpoch;
|
||||
const [invRows, orderMap] = await Promise.all([
|
||||
loadReplicaInventory(warehouse),
|
||||
loadTodayOrderMap(warehouse, orderDate).catch(() => new Map()),
|
||||
]);
|
||||
|
||||
const rows = buildSkuRows({ invRows, orderMap, orderDate });
|
||||
|
||||
const dataset = {
|
||||
at: now,
|
||||
rows,
|
||||
warehouse,
|
||||
orderDate,
|
||||
meta: {
|
||||
rowCount: rows.length,
|
||||
orderSkuCount: orderMap.size,
|
||||
},
|
||||
};
|
||||
// 只有最新一轮(未被清缓存)的结果才写回,避免清缓存后旧查询覆盖新数据
|
||||
if (epoch === cacheEpoch) cacheStore.set(cacheKey, dataset);
|
||||
return { ...dataset, cacheHit: false };
|
||||
}
|
||||
|
||||
function invalidateCache(warehouseKey) {
|
||||
cacheEpoch += 1;
|
||||
if (warehouseKey) cacheStore.delete(resolveWarehouseKey(warehouseKey));
|
||||
else cacheStore.clear();
|
||||
}
|
||||
|
||||
function matchesStatus(row, status) {
|
||||
const key = toText(status, 20).toLowerCase();
|
||||
if (!key || key === 'all') return true;
|
||||
if (key === 'action' || key === 'todo' || key === 'replenish') {
|
||||
return row.status === STATUS.NEED || row.status === STATUS.SHORT;
|
||||
}
|
||||
return row.status === key;
|
||||
}
|
||||
|
||||
function sortRows(rows) {
|
||||
return rows.slice().sort((a, b) => (
|
||||
(STATUS_RANK[b.status] || 0) - (STATUS_RANK[a.status] || 0)
|
||||
|| (b.suggestQty || 0) - (a.suggestQty || 0)
|
||||
|| (b.excess || 0) - (a.excess || 0)
|
||||
|| String(a.skuId).localeCompare(String(b.skuId))
|
||||
));
|
||||
}
|
||||
|
||||
function filterRows(rows, { keyword, status }) {
|
||||
const kw = toText(keyword, 120).toLowerCase();
|
||||
return rows.filter((row) => {
|
||||
if (!matchesStatus(row, status)) return false;
|
||||
if (!kw) return true;
|
||||
return row.skuId.toLowerCase().includes(kw)
|
||||
|| String(row.productName || '').toLowerCase().includes(kw)
|
||||
|| String(row.supplier || '').toLowerCase().includes(kw);
|
||||
});
|
||||
}
|
||||
|
||||
async function getReplenishList(options = {}) {
|
||||
const warehouse = resolveWarehouse(options.warehouse);
|
||||
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
|
||||
const filtered = sortRows(filterRows(dataset.rows, options));
|
||||
const summary = buildSummary(dataset.rows, dataset.meta.orderSkuCount);
|
||||
const page = Math.max(1, Number.parseInt(options.page, 10) || 1);
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number.parseInt(options.pageSize, 10) || 50));
|
||||
const start = (page - 1) * pageSize;
|
||||
|
||||
return {
|
||||
rows: filtered.slice(start, start + pageSize),
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
summary,
|
||||
warehouse,
|
||||
warehouses: Object.values(WAREHOUSES),
|
||||
orderDate: dataset.orderDate,
|
||||
cacheHit: dataset.cacheHit,
|
||||
updatedAt: new Date(dataset.at).toISOString(),
|
||||
cacheTtlMs: resolveCacheTtlMs(),
|
||||
};
|
||||
}
|
||||
|
||||
async function getReplenishExportRows(options = {}) {
|
||||
const warehouse = resolveWarehouse(options.warehouse);
|
||||
const dataset = await loadDataset(warehouse, { refresh: String(options.refresh) === 'true' });
|
||||
return {
|
||||
warehouse,
|
||||
orderDate: dataset.orderDate,
|
||||
rows: sortRows(filterRows(dataset.rows, options)),
|
||||
summary: buildSummary(dataset.rows, dataset.meta.orderSkuCount),
|
||||
};
|
||||
}
|
||||
|
||||
/** 启动预热:库存视图 + 当日订单 union 各自 30s~1min,不能让首个用户请求等 */
|
||||
async function warmBinReplenishCache() {
|
||||
try {
|
||||
const warehouse = WAREHOUSES[DEFAULT_WAREHOUSE_KEY];
|
||||
const t0 = Date.now();
|
||||
await loadDataset(warehouse, { refresh: true });
|
||||
console.log(`[BinReplenish] 预热完成 ${warehouse.name},耗时 ${Date.now() - t0}ms`);
|
||||
} catch (error) {
|
||||
console.warn('[BinReplenish] 预热失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_WAREHOUSE_KEY,
|
||||
PICK_PREFIXES,
|
||||
STORE_PREFIXES,
|
||||
STATUS,
|
||||
STATUS_LABELS,
|
||||
WAREHOUSES,
|
||||
clearPickBins: async () => ({ removed: 0 }),
|
||||
getReplenishExportRows,
|
||||
getReplenishList,
|
||||
loadDataset,
|
||||
resolveWarehouse,
|
||||
resolveWarehouseKey,
|
||||
warmBinReplenishCache,
|
||||
};
|
||||
@ -1,121 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 每日进销存 —— 流水列取数(业务库原生口径)
|
||||
*
|
||||
* 业务库 JSReportReplica 能还原的 4 个流水列(其余 8 列缺单据表,保持「—」):
|
||||
* 采购进仓(+): rpt.jst_purchase × jst_purchaseItem,按 po_date,状态 Confirmed/Finished,取明细 qty
|
||||
* (明细 inQty 全为 0 未同步,故用 qty)
|
||||
* 采购退货(−): rpt.jst_purchaseout × jst_purchaseoutItem,按 io_date
|
||||
* 销售出仓(−): rpt.jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled
|
||||
* (与「库存周转天数」的发货口径一致;与聚水潭进销存导出的出库单口径有偏差,
|
||||
* 因为销售出库单明细数量在业务库未同步,无法精确还原)
|
||||
* 销售退货(+): rpt.jst_returnAndRefundInfo × jst_returnAndRefundItem,按 as_date,
|
||||
* 类型限定 普通退货/拒收退货/换货/维修,取明细 r_qty(实收数量;
|
||||
* qty 字段大多未同步,不能用)
|
||||
*
|
||||
* 符号约定与页面一致:流入为正、流出为负(前端负数标红)。
|
||||
* 因为还有 8 类单据(调拨/盘点/加工/其它出入库)在业务库无表,
|
||||
* 「期末 ≠ 期初 + 流水之和」,差值即缺失单据的影响。
|
||||
*/
|
||||
|
||||
const FLOW_QUERIES = [
|
||||
{
|
||||
field: 'purchaseIn',
|
||||
sign: 1,
|
||||
sql: `
|
||||
SELECT p.wms_co_id AS wmsCoId,
|
||||
SUM(CAST(ISNULL(pi.qty, 0) AS DECIMAL(18, 2))) AS qty
|
||||
FROM rpt.jst_purchase p
|
||||
JOIN rpt.jst_purchaseItem pi ON pi.po_id = p.po_id
|
||||
WHERE p.wms_co_id IN (%WAREHOUSES%)
|
||||
AND p.po_date >= @from
|
||||
AND ISNULL(p.status, '') IN ('Confirmed', 'Finished')
|
||||
GROUP BY p.wms_co_id`,
|
||||
},
|
||||
{
|
||||
field: 'purchaseReturn',
|
||||
sign: -1,
|
||||
sql: `
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS qty
|
||||
FROM rpt.jst_purchaseout o
|
||||
JOIN rpt.jst_purchaseoutItem oi ON oi.io_id = o.io_id
|
||||
WHERE o.wms_co_id IN (%WAREHOUSES%)
|
||||
AND o.io_date >= @from
|
||||
GROUP BY o.wms_co_id`,
|
||||
},
|
||||
{
|
||||
field: 'salesOut',
|
||||
sign: -1,
|
||||
sql: `
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS qty
|
||||
FROM rpt.jst_Orders o
|
||||
JOIN rpt.jst_OrderItem oi ON oi.o_id = o.o_id
|
||||
WHERE o.wms_co_id IN (%WAREHOUSES%)
|
||||
AND o.send_date >= @from
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
GROUP BY o.wms_co_id`,
|
||||
},
|
||||
{
|
||||
field: 'salesReturn',
|
||||
sign: 1,
|
||||
sql: `
|
||||
SELECT r.wms_co_id AS wmsCoId,
|
||||
SUM(CAST(ISNULL(ri.r_qty, 0) AS DECIMAL(18, 2))) AS qty
|
||||
FROM rpt.jst_returnAndRefundInfo r
|
||||
JOIN rpt.jst_returnAndRefundItem ri ON ri.as_id = r.as_id
|
||||
WHERE r.wms_co_id IN (%WAREHOUSES%)
|
||||
AND r.as_date >= @from
|
||||
AND ISNULL(r.type, '') IN (N'普通退货', N'拒收退货', N'换货', N'维修')
|
||||
GROUP BY r.wms_co_id`,
|
||||
},
|
||||
];
|
||||
|
||||
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
|
||||
function buildInClause(values, prefix) {
|
||||
const params = {};
|
||||
const clause = values
|
||||
.map((v, i) => {
|
||||
params[`${prefix}${i}`] = v;
|
||||
return `@${prefix}${i}`;
|
||||
})
|
||||
.join(',');
|
||||
return { clause, params };
|
||||
}
|
||||
|
||||
/** 业务库可还原的流水字段(带符号:流入 +1 / 流出 −1) */
|
||||
const AVAILABLE_FLOW_FIELDS = FLOW_QUERIES.map((q) => q.field);
|
||||
|
||||
/**
|
||||
* 拉取自 fromText(含)以来各仓的流水合计。
|
||||
* @returns {Promise<Map<string, Object>>} key = wmsCoId 字符串,value = { purchaseIn, purchaseReturn, salesOut, salesReturn }(带符号,无记录的字段不出现)
|
||||
*/
|
||||
async function fetchFlows(cloudDb, wmsCoIds, fromText) {
|
||||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||||
throw new TypeError('fetchFlows 第一个参数必须是数据库实例(防呆:调用漏传 cloudDb 时立即报错而非静默返回空)');
|
||||
}
|
||||
const map = new Map();
|
||||
if (!wmsCoIds.length || !fromText) return map;
|
||||
for (const q of FLOW_QUERIES) {
|
||||
const { clause, params } = buildInClause(wmsCoIds, 'w');
|
||||
const sqlText = q.sql.replace('%WAREHOUSES%', clause);
|
||||
const rows = await cloudDb.query(sqlText, { ...params, from: fromText }, { requestTimeout: 120000 });
|
||||
for (const r of rows) {
|
||||
const key = String(r.wmsCoId);
|
||||
const entry = map.get(key) || {};
|
||||
const qty = Number(r.qty) || 0;
|
||||
entry[q.field] = q.sign * qty;
|
||||
map.set(key, entry);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AVAILABLE_FLOW_FIELDS,
|
||||
FLOW_QUERIES,
|
||||
fetchFlows,
|
||||
buildInClause,
|
||||
};
|
||||
@ -1,330 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 每日进销存 · 定时截图通知 —— 配置 + 执行记录 + 幂等记录(本地库 system_config)
|
||||
//
|
||||
// ── 配置键 daily_inventory_notification_config ────────────────────
|
||||
// { enabled, notifyTime: 'HH:mm', recipients: ['张三', '李四'] }
|
||||
//
|
||||
// ── 状态键 daily_inventory_notification_state ──────────────────────
|
||||
// { version, notified: { 'YYYY-MM-DD': { '别名': { notifiedAt, deliveryId } } }, runs: [...] }
|
||||
// · notified 用于「同一业务日期 + 同一用户只成功通知一次」的幂等判断
|
||||
// · runs 保留最近 N 次执行明细,供管理员在页面查询
|
||||
//
|
||||
// 安全约定:本文件对外只返回「是否已配置」这类布尔值与脱敏预览,
|
||||
// 绝不返回通知中心 projectSecret、对象存储 secretAccessKey 等密钥。
|
||||
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getYydNotificationCenterRuntimeConfig,
|
||||
toAdminConfig: toNotificationCenterAdminConfig,
|
||||
} = require('./yydNotificationCenterSettings');
|
||||
const { getDefaultS3Config } = require('./objectStorage');
|
||||
|
||||
const CONFIG_KEY = 'daily_inventory_notification_config';
|
||||
const STATE_KEY = 'daily_inventory_notification_state';
|
||||
|
||||
const DEFAULT_NOTIFY_TIME = '01:00';
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const MAX_RECIPIENTS = 20;
|
||||
const MAX_RECIPIENT_LENGTH = 50;
|
||||
const MAX_RUN_RECORDS = 30;
|
||||
const CONFIG_CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
let cachedConfig = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
function normalizeText(value, maxLength = 200) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback = {}) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
try {
|
||||
const parsed = JSON.parse(String(value || ''));
|
||||
return parsed && typeof parsed === 'object' ? parsed : fallback;
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFlag(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知时间规范化:只接受 HH:mm(24 小时制),非法值回退默认 01:00。
|
||||
* 允许传 'HH:mm:ss',截断到分钟。
|
||||
*/
|
||||
function normalizeNotifyTime(value) {
|
||||
const text = normalizeText(value, 10);
|
||||
const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(text);
|
||||
if (!match) return DEFAULT_NOTIFY_TIME;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) return DEFAULT_NOTIFY_TIME;
|
||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) return DEFAULT_NOTIFY_TIME;
|
||||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收件人名单规范化:trim → 去空 → 去重(保持输入顺序)→ 限长限量。
|
||||
* 支持数组、逗号/换行/分号分隔的字符串。
|
||||
*/
|
||||
function normalizeRecipients(input) {
|
||||
let list = [];
|
||||
if (Array.isArray(input)) {
|
||||
list = input;
|
||||
} else if (typeof input === 'string') {
|
||||
list = input.split(/[,,;;\n\r\t]+/);
|
||||
} else if (input && typeof input === 'object') {
|
||||
list = Object.values(input);
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const raw of list) {
|
||||
const name = normalizeText(raw, MAX_RECIPIENT_LENGTH);
|
||||
if (!name) continue;
|
||||
if (seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
result.push(name);
|
||||
if (result.length >= MAX_RECIPIENTS) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 幂等键:正式推送按「业务日期 + 收件人」去重;测试推送带时间戳,不污染正式记录 */
|
||||
function buildIdempotencyKey({ bizDate, recipient, mode = 'scheduled', nonce = '' }) {
|
||||
const scope = mode === 'test' ? `test:${normalizeText(nonce, 64)}` : 'daily';
|
||||
return `daily_inventory_notification:${scope}:${bizDate}:${recipient}:v1`.slice(0, 160);
|
||||
}
|
||||
|
||||
async function readSystemConfigValue(key) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[key]
|
||||
);
|
||||
return rows?.[0]?.configValue;
|
||||
}
|
||||
|
||||
async function upsertSystemConfigValue(key, value, configType = 'json') {
|
||||
const payload = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[key]
|
||||
);
|
||||
if (rows?.[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[payload, configType, key]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: key,
|
||||
config_value: payload,
|
||||
config_type: configType,
|
||||
});
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
// ── 配置 ────────────────────────────────────────────────────────
|
||||
async function getDailyInventoryNotificationRuntimeConfig({ forceRefresh = false } = {}) {
|
||||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CONFIG_CACHE_TTL_MS) {
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
let stored = {};
|
||||
try {
|
||||
stored = safeJsonParse(await readSystemConfigValue(CONFIG_KEY), {});
|
||||
} catch (error) {
|
||||
console.warn('读取每日进销存通知配置失败,使用默认值:', error.message);
|
||||
}
|
||||
cachedConfig = {
|
||||
enabled: normalizeFlag(stored.enabled, false),
|
||||
notifyTime: normalizeNotifyTime(stored.notifyTime ?? stored.notify_time),
|
||||
recipients: normalizeRecipients(stored.recipients),
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
|
||||
async function saveDailyInventoryNotificationConfig(input = {}) {
|
||||
const current = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
|
||||
const next = {
|
||||
enabled: normalizeFlag(input.enabled, current.enabled),
|
||||
notifyTime: normalizeNotifyTime(
|
||||
input.notifyTime ?? input.notify_time ?? current.notifyTime
|
||||
),
|
||||
recipients: normalizeRecipients(
|
||||
input.recipients === undefined ? current.recipients : input.recipients
|
||||
),
|
||||
};
|
||||
await upsertSystemConfigValue(CONFIG_KEY, next);
|
||||
cachedConfig = { ...next };
|
||||
cachedAt = Date.now();
|
||||
return { ...next };
|
||||
}
|
||||
|
||||
// ── 执行记录 / 幂等记录 ─────────────────────────────────────────
|
||||
async function loadDailyInventoryNotificationState() {
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = safeJsonParse(await readSystemConfigValue(STATE_KEY), {});
|
||||
} catch (error) {
|
||||
console.warn('读取每日进销存通知状态失败,按空状态处理:', error.message);
|
||||
}
|
||||
const notified = parsed.notified && typeof parsed.notified === 'object' && !Array.isArray(parsed.notified)
|
||||
? parsed.notified
|
||||
: {};
|
||||
const runs = Array.isArray(parsed.runs) ? parsed.runs : [];
|
||||
return { version: 1, notified, runs };
|
||||
}
|
||||
|
||||
async function saveDailyInventoryNotificationState(state = {}) {
|
||||
const value = {
|
||||
version: 1,
|
||||
notified: state.notified && typeof state.notified === 'object' && !Array.isArray(state.notified)
|
||||
? state.notified
|
||||
: {},
|
||||
runs: Array.isArray(state.runs) ? state.runs.slice(-MAX_RUN_RECORDS) : [],
|
||||
};
|
||||
await upsertSystemConfigValue(STATE_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 判断某业务日期是否已成功通知过某人(只认正式推送) */
|
||||
async function hasNotified(bizDate, recipient) {
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const day = state.notified?.[bizDate];
|
||||
return Boolean(day && day[recipient]);
|
||||
}
|
||||
|
||||
/** 记录一次成功通知(正式推送才写;测试推送不写) */
|
||||
async function markNotified(bizDate, recipient, extra = {}) {
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const day = { ...(state.notified?.[bizDate] || {}) };
|
||||
day[recipient] = {
|
||||
notifiedAt: new Date().toISOString(),
|
||||
deliveryId: extra.deliveryId || null,
|
||||
idempotencyKey: extra.idempotencyKey || null,
|
||||
};
|
||||
const notified = { ...(state.notified || {}), [bizDate]: day };
|
||||
// 只保留最近 14 个业务日,避免状态无限膨胀
|
||||
const keys = Object.keys(notified).sort();
|
||||
const trimmed = {};
|
||||
keys.slice(-14).forEach((k) => { trimmed[k] = notified[k]; });
|
||||
return saveDailyInventoryNotificationState({ ...state, notified: trimmed });
|
||||
}
|
||||
|
||||
/** 追加一条执行记录 */
|
||||
async function appendRunRecord(record = {}) {
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const runs = [...(state.runs || []), {
|
||||
runAt: record.runAt || new Date().toISOString(),
|
||||
bizDate: record.bizDate || null,
|
||||
mode: record.mode || 'scheduled',
|
||||
success: record.success === true,
|
||||
reason: record.reason || null,
|
||||
error: record.error || null,
|
||||
screenshotUrl: record.screenshotUrl || null,
|
||||
screenshotKey: record.screenshotKey || null,
|
||||
notifyTime: record.notifyTime || null,
|
||||
recipients: Array.isArray(record.recipients) ? record.recipients : [],
|
||||
}];
|
||||
return saveDailyInventoryNotificationState({ ...state, runs });
|
||||
}
|
||||
|
||||
function listRunRecords(limit = 10) {
|
||||
return loadDailyInventoryNotificationState().then((state) => {
|
||||
const runs = Array.isArray(state.runs) ? state.runs : [];
|
||||
return runs.slice(-Math.max(Number(limit) || 10, 1)).reverse();
|
||||
});
|
||||
}
|
||||
|
||||
// ── 管理员视图(不含任何密钥) ───────────────────────────────────
|
||||
async function getObjectStorageStatus() {
|
||||
try {
|
||||
const config = await getDefaultS3Config(false);
|
||||
return {
|
||||
configured: Boolean(config && config.bucket && config.endpoint && config.accessKeyId),
|
||||
bucket: config?.bucket || '',
|
||||
endpoint: config?.endpoint || '',
|
||||
// 只给脱敏信息,secretAccessKey 从不读取、从不返回
|
||||
accessKeyIdPreview: config?.accessKeyId
|
||||
? `${String(config.accessKeyId).slice(0, 4)}***`
|
||||
: '',
|
||||
};
|
||||
} catch (error) {
|
||||
return { configured: false, bucket: '', endpoint: '', accessKeyIdPreview: '', error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function getDailyInventoryNotificationAdminConfig() {
|
||||
const config = await getDailyInventoryNotificationRuntimeConfig({ forceRefresh: true });
|
||||
let center = {};
|
||||
try {
|
||||
center = await getYydNotificationCenterRuntimeConfig();
|
||||
} catch (_error) {
|
||||
center = {};
|
||||
}
|
||||
const centerAdmin = toNotificationCenterAdminConfig(center);
|
||||
const storage = await getObjectStorageStatus();
|
||||
const state = await loadDailyInventoryNotificationState();
|
||||
const runs = Array.isArray(state.runs) ? state.runs : [];
|
||||
|
||||
const blockingReasons = [];
|
||||
if (!config.enabled) blockingReasons.push('功能未启用');
|
||||
if (!config.recipients.length) blockingReasons.push('未配置通知用户');
|
||||
if (!centerAdmin.configured) blockingReasons.push('统一通知中心未配置');
|
||||
if (!storage.configured) blockingReasons.push('对象存储未配置');
|
||||
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
notifyTime: config.notifyTime,
|
||||
recipients: config.recipients,
|
||||
timezone: TIMEZONE,
|
||||
maxRecipients: MAX_RECIPIENTS,
|
||||
notificationCenter: {
|
||||
configured: centerAdmin.configured,
|
||||
baseUrl: centerAdmin.baseUrl,
|
||||
projectCode: centerAdmin.projectCode,
|
||||
projectSecretConfigured: centerAdmin.projectSecretConfigured,
|
||||
projectSecretPreview: centerAdmin.projectSecretPreview,
|
||||
},
|
||||
objectStorage: storage,
|
||||
blockingReasons,
|
||||
lastRunAt: runs.length ? runs[runs.length - 1].runAt : null,
|
||||
lastRunSuccess: runs.length ? runs[runs.length - 1].success === true : null,
|
||||
runs: runs.slice(-10).reverse(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_KEY,
|
||||
STATE_KEY,
|
||||
DEFAULT_NOTIFY_TIME,
|
||||
MAX_RECIPIENTS,
|
||||
MAX_RECIPIENT_LENGTH,
|
||||
MAX_RUN_RECORDS,
|
||||
TIMEZONE,
|
||||
appendRunRecord,
|
||||
buildIdempotencyKey,
|
||||
getDailyInventoryNotificationAdminConfig,
|
||||
getDailyInventoryNotificationRuntimeConfig,
|
||||
getObjectStorageStatus,
|
||||
hasNotified,
|
||||
listRunRecords,
|
||||
loadDailyInventoryNotificationState,
|
||||
markNotified,
|
||||
normalizeFlag,
|
||||
normalizeNotifyTime,
|
||||
normalizeRecipients,
|
||||
normalizeText,
|
||||
saveDailyInventoryNotificationConfig,
|
||||
saveDailyInventoryNotificationState,
|
||||
};
|
||||
@ -1,137 +0,0 @@
|
||||
/**
|
||||
* 每日进销存 —— 聚水潭官方进销存导出解析 / 导入
|
||||
*
|
||||
* 输入为聚水潭进销存报表按仓导出(xlsx),列包含:
|
||||
* 仓库、期初数量、[12 个流水列]、期末数量
|
||||
* 流水列符号沿用导出原值(出仓为负、入仓为正),前端不做二次转换。
|
||||
*/
|
||||
const XLSX = require('xlsx');
|
||||
const {
|
||||
FLOW_COLUMNS,
|
||||
DAILY_INVENTORY_BASELINE,
|
||||
} = require('../config/dailyInventoryBaseline');
|
||||
const officialDb = require('../config/dailyInventoryOfficialDb');
|
||||
|
||||
const FLOW_FIELDS = FLOW_COLUMNS.map((c) => c.field);
|
||||
|
||||
/** 流水列中文表头 → field */
|
||||
const LABEL_TO_FIELD = new Map(FLOW_COLUMNS.map((c) => [c.label, c.field]));
|
||||
|
||||
/** 仓库名 → wms_co_id(来自项目仓库清单) */
|
||||
const WAREHOUSE_TO_ID = new Map(
|
||||
DAILY_INVENTORY_BASELINE.map((b) => [b.warehouse, b.wmsCoId])
|
||||
);
|
||||
|
||||
const num = (v) => {
|
||||
if (v == null || v === '' || v === '-') return null;
|
||||
const n = Number(String(v).replace(/,/g, '').trim());
|
||||
return Number.isFinite(n) ? n : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 把「表头 → 值」的记录数组归一化为官方行
|
||||
* @param {Array<Object>} records
|
||||
* @returns {{rows: Array, warnings: string[]}}
|
||||
*/
|
||||
function normalizeRecords(records) {
|
||||
const rows = [];
|
||||
const warnings = [];
|
||||
if (!Array.isArray(records)) return { rows, warnings: ['导入内容不是数组'] };
|
||||
|
||||
records.forEach((rec, idx) => {
|
||||
const keys = Object.keys(rec);
|
||||
const warehouseKey = keys.find((k) => String(k).trim() === '仓库');
|
||||
if (!warehouseKey) return;
|
||||
const warehouse = String(rec[warehouseKey] ?? '').trim();
|
||||
if (!warehouse) return;
|
||||
|
||||
const openingKey = keys.find((k) => String(k).trim() === '期初数量');
|
||||
const closingKey = keys.find((k) => String(k).trim() === '期末数量');
|
||||
if (!openingKey && !closingKey) {
|
||||
warnings.push(`第 ${idx + 2} 行缺少「期初数量 / 期末数量」列,已跳过`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 12 个流水列先置 null,导出行里没出现的列保持 null(区别于显式为 0)
|
||||
const flows = {};
|
||||
for (const field of FLOW_FIELDS) flows[field] = null;
|
||||
for (const k of keys) {
|
||||
const label = String(k).trim();
|
||||
const field = LABEL_TO_FIELD.get(label);
|
||||
if (!field) continue;
|
||||
flows[field] = num(rec[k]);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
warehouse,
|
||||
wmsCoId: WAREHOUSE_TO_ID.get(warehouse) ?? null,
|
||||
openingQty: openingKey ? num(rec[openingKey]) : null,
|
||||
closingQty: closingKey ? num(rec[closingKey]) : null,
|
||||
flows,
|
||||
});
|
||||
});
|
||||
|
||||
if (!rows.length) warnings.push('未解析到任何仓库行,请确认包含「仓库」列');
|
||||
return { rows, warnings };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 xlsx 缓冲区(自动定位含「仓库」表头的工作表与表头行)
|
||||
* @param {Buffer} buffer
|
||||
*/
|
||||
function parseWorkbook(buffer) {
|
||||
const wb = XLSX.read(buffer, { type: 'buffer', cellDates: true });
|
||||
const warnings = [];
|
||||
for (const sheetName of wb.SheetNames) {
|
||||
const ws = wb.Sheets[sheetName];
|
||||
const grid = XLSX.utils.sheet_to_json(ws, { header: 1, defval: '', blankrows: false });
|
||||
const headerIdx = grid.findIndex((r) => r.some((c) => String(c).trim() === '仓库'));
|
||||
if (headerIdx < 0) continue;
|
||||
const header = grid[headerIdx].map((c) => String(c).trim());
|
||||
const body = grid.slice(headerIdx + 1)
|
||||
.filter((r) => r.some((c) => String(c).trim() !== ''))
|
||||
.map((r) => Object.fromEntries(header.map((h, i) => [h, r[i]])));
|
||||
const { rows, warnings: w } = normalizeRecords(body);
|
||||
if (rows.length) return { sheetName, rows, warnings: warnings.concat(w) };
|
||||
warnings.push(...w);
|
||||
}
|
||||
return { sheetName: null, rows: [], warnings: warnings.length ? warnings : ['未找到含「仓库」列的工作表'] };
|
||||
}
|
||||
|
||||
/** 恒等式校验:期初 + Σ流水 = 期末 */
|
||||
function checkBalance(rows, tolerance = 1) {
|
||||
return rows
|
||||
.map((r) => {
|
||||
const flowSum = FLOW_FIELDS.reduce((acc, f) => acc + (Number(r.flows?.[f]) || 0), 0);
|
||||
const opening = Number(r.openingQty) || 0;
|
||||
const closing = Number(r.closingQty) || 0;
|
||||
const diff = opening + flowSum - closing;
|
||||
return { warehouse: r.warehouse, opening, flowSum, closing, diff: Number(diff.toFixed(2)) };
|
||||
})
|
||||
.filter((r) => Math.abs(r.diff) > tolerance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 导入某业务日的官方进销存(先清后写)
|
||||
* @returns {Promise<{bizDate:string, imported:number, warnings:string[], mismatches:Array, unknownWarehouses:string[], missingWarehouses:string[]}>}
|
||||
*/
|
||||
async function importOfficial(bizDate, rows, { source = 'jst-export' } = {}) {
|
||||
await officialDb.deleteOfficial(bizDate);
|
||||
const imported = await officialDb.upsertOfficial(bizDate, rows, source);
|
||||
const mismatches = checkBalance(rows);
|
||||
const unknownWarehouses = rows.filter((r) => r.wmsCoId == null).map((r) => r.warehouse);
|
||||
const importedNames = new Set(rows.map((r) => r.warehouse));
|
||||
const missingWarehouses = DAILY_INVENTORY_BASELINE
|
||||
.filter((b) => !importedNames.has(b.warehouse))
|
||||
.map((b) => b.warehouse);
|
||||
return { bizDate, imported, warnings: [], mismatches, unknownWarehouses, missingWarehouses };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FLOW_FIELDS,
|
||||
LABEL_TO_FIELD,
|
||||
normalizeRecords,
|
||||
parseWorkbook,
|
||||
checkBalance,
|
||||
importOfficial,
|
||||
};
|
||||
@ -1,91 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 每日进销存 —— 期初快照缺失时的兜底推导
|
||||
*
|
||||
* 正常情况下「期初 = 上一业务日快照」(每日 01:30 自动落库)。
|
||||
* 若快照缺失(服务停机、新纳入仓库等),用「实时库存扣流水回推」推导
|
||||
* 「上一业务日期末结存」并补写快照:
|
||||
*
|
||||
* closing(D-1) = 当前实时库存 − 自 D 01:00 以来的净流入
|
||||
* 净流入 = 采购进仓 + 销售退货 − 销售出仓 − 采购退货(带符号流水求和,见 dailyInventoryFlows)
|
||||
*
|
||||
* 口径校准(2026-09-27,对照聚水潭进销存导出 09-23 期初基准):
|
||||
* 崔桥工厂仓 基准 92388 / 回推 ≈89650;横林二仓 基准 63780 / 回推 ≈64029;
|
||||
* 山东青岗集 基准 88377 / 回推 ≈88210;河南洛宁 基准 83305 / 回推 ≈82473。
|
||||
* 偏差 0.3%~2.7%,来源为业务库缺失的 8 类单据(调拨/盘点/加工/其它出入库)。
|
||||
*
|
||||
* 注意:不要用 rpt.pm_stockMonitor 聚合 jsStock 充当全仓结存 ——
|
||||
* 该表只覆盖纳入监控范围的 SKU 子集(横林二仓实测 1197 vs 全仓 63780),
|
||||
* 系统性偏低,已在校准后弃用。
|
||||
*/
|
||||
|
||||
const { fetchFlows } = require('./dailyInventoryFlows');
|
||||
|
||||
/**
|
||||
* 推导 openingDate 的期末快照并补写。
|
||||
* @param {Object} opts
|
||||
* @param {Object} opts.cloudDb 业务库(mssql)
|
||||
* @param {Object} opts.snapshotDb 本地快照表访问模块
|
||||
* @param {Array} opts.baseline 仓库基线(warehouse/dbWarehouse/wmsCoId)
|
||||
* @param {string} opts.openingDate 期初业务日(YYYY-MM-DD,即要补快照的日期)
|
||||
* @param {string} opts.latestBizDate 最新业务日(YYYY-MM-DD,流水起点 = 其当日 01:00)
|
||||
* @param {Date|null} opts.takenAt 实时库存数据时间
|
||||
* @returns {Promise<{count:number, derivedCount:number}>}
|
||||
*/
|
||||
async function deriveAndStoreOpeningSnapshot(opts) {
|
||||
const { cloudDb, snapshotDb, baseline, openingDate, latestBizDate, takenAt } = opts;
|
||||
if (!openingDate || !latestBizDate) return { count: 0, derivedCount: 0 };
|
||||
const whs = baseline.filter((b) => b.wmsCoId != null);
|
||||
const wmsCoIds = whs.map((b) => b.wmsCoId);
|
||||
const flowFrom = `${latestBizDate} 01:00:00`;
|
||||
|
||||
// 实时库存(与每日快照任务同口径:jst_inventoryInfo 按仓聚合)
|
||||
let realtimeMap = new Map();
|
||||
try {
|
||||
const rows = await cloudDb.query(
|
||||
`SELECT i.wms_co_id AS wmsCoId,
|
||||
ISNULL(SUM(CAST(i.qty AS DECIMAL(18, 2))), 0) AS qty,
|
||||
SUM(CASE WHEN ABS(ISNULL(i.qty, 0)) > 0.0001 THEN 1 ELSE 0 END) AS skuCount
|
||||
FROM rpt.jst_inventoryInfo i
|
||||
WHERE i.wms_co_id IN (${wmsCoIds.map((_, i) => `@w${i}`).join(',')})
|
||||
GROUP BY i.wms_co_id`,
|
||||
Object.fromEntries(wmsCoIds.map((id, i) => [`w${i}`, id])),
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
realtimeMap = new Map(rows.map((r) => [String(r.wmsCoId), r]));
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory-opening] 实时库存聚合失败:', error.message);
|
||||
}
|
||||
|
||||
// 自期初时刻以来的带符号流水(流出为负)
|
||||
let flowMap = new Map();
|
||||
try {
|
||||
flowMap = await fetchFlows(cloudDb, wmsCoIds, flowFrom);
|
||||
} catch (error) {
|
||||
console.error('[daily-inventory-opening] 流水取数失败:', error.message);
|
||||
}
|
||||
|
||||
let derivedCount = 0;
|
||||
const payload = whs.map((b) => {
|
||||
const rt = realtimeMap.get(String(b.wmsCoId));
|
||||
const f = flowMap.get(String(b.wmsCoId)) || {};
|
||||
const netInflow = (f.purchaseIn || 0) + (f.salesReturn || 0) + (f.salesOut || 0) + (f.purchaseReturn || 0);
|
||||
const closingQty = Number(rt?.qty ?? 0) - netInflow;
|
||||
derivedCount += 1;
|
||||
return {
|
||||
wmsCoId: b.wmsCoId,
|
||||
warehouse: b.warehouse,
|
||||
dbWarehouse: b.dbWarehouse,
|
||||
closingQty: Number(closingQty.toFixed(2)),
|
||||
skuCount: Number(rt?.skuCount ?? 0),
|
||||
takenAt: takenAt ? new Date(takenAt) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const count = await snapshotDb.upsertSnapshot(openingDate, payload);
|
||||
console.log(`[daily-inventory-opening] 已补写 ${openingDate} 快照 ${count}/${whs.length} 仓(实时库存扣流水回推)`);
|
||||
return { count, derivedCount };
|
||||
}
|
||||
|
||||
module.exports = { deriveAndStoreOpeningSnapshot };
|
||||
@ -1,307 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 每日进销存 · 页面截图与上传
|
||||
//
|
||||
// 要点:
|
||||
// 1. 使用服务端签发的**短时效** JWT 作为专用身份访问页面(15 分钟过期),
|
||||
// 令牌只写入浏览器 localStorage,不会出现在截图地址、日志或接口响应里。
|
||||
// 2. 截图前必须等到数据真正加载完成:表格有数据行、没有 Spin 加载中、没有错误提示。
|
||||
// 3. 表格带内部滚动,直接截屏只能看到一屏;截图前注入 CSS 展开滚动容器,
|
||||
// 再整页截图,保证数据是完整的。
|
||||
// 4. 截图落在系统临时目录,上传成功后立即删除。
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const database = require('../config/database');
|
||||
const { uploadFileToDefaultS3 } = require('./objectStorage');
|
||||
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const PAGE_PATH = '/planning/operation/daily-inventory';
|
||||
// 生产主站(见《服务器部署规范》):可用 DAILY_INVENTORY_SCREENSHOT_BASE_URL / PUBLIC_SITE_URL 覆盖
|
||||
const DEFAULT_BASE_URL = 'https://ck.yydd.ai';
|
||||
// 页面首次加载要扫订单明细,约 15~20 秒,等待上限给到 3 分钟
|
||||
const PAGE_READY_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
/**
|
||||
* 截图专用服务账号
|
||||
*
|
||||
* 页面启动时会调用 /api/auth/verify,该接口要求令牌的 sub 必须是系统库 users 表中
|
||||
* 真实且 active 的用户;仅签名合法但用户不存在的令牌会被判定为未登录并跳转登录页,
|
||||
* 截图也就拿不到任何数据。因此这里不使用凭空捏造的身份,而是在系统库维护一个
|
||||
* 仅供服务端使用的专用账号,令牌以该账号的 id 作为 sub。
|
||||
*
|
||||
* 该账号只以服务端签发的短时效令牌方式使用:密码哈希是随机值且永不对外,
|
||||
* 任何人无法通过密码登录;删除该行即可彻底回收。
|
||||
*/
|
||||
const SERVICE_USERNAME = String(
|
||||
process.env.DAILY_INVENTORY_SCREENSHOT_USERNAME || 'daily_inventory_screenshot_bot'
|
||||
).trim().slice(0, 50) || 'daily_inventory_screenshot_bot';
|
||||
const SERVICE_NAME = '每日进销存截图服务';
|
||||
const SERVICE_EMAIL = `${SERVICE_USERNAME}@service.local`;
|
||||
|
||||
let cachedServiceUserId = null;
|
||||
|
||||
const EXPAND_CSS = `
|
||||
.ant-table-body, .ant-table-content, .ant-table-container { max-height: none !important; overflow: visible !important; }
|
||||
.daily-inventory-page { overflow: visible !important; }
|
||||
.ant-table-cell-fix-left, .ant-table-cell-fix-right { position: static !important; }
|
||||
`;
|
||||
|
||||
function normalizeText(value, maxLength = 500) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
/** 北京时间日期/时间片段:YYYY-MM-DD、YYYYMMDD、HHmmss */
|
||||
function chinaTimeParts(date = new Date()) {
|
||||
const target = date instanceof Date ? date : new Date(date);
|
||||
const dateText = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIMEZONE, year: 'numeric', month: '2-digit', day: '2-digit',
|
||||
}).format(target);
|
||||
const timeText = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: TIMEZONE, hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
||||
}).format(target);
|
||||
return {
|
||||
bizDate: dateText,
|
||||
dateKey: dateText.replace(/\D/g, ''),
|
||||
timeKey: timeText.replace(/\D/g, ''),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBaseUrl() {
|
||||
const raw = normalizeText(
|
||||
process.env.DAILY_INVENTORY_SCREENSHOT_BASE_URL
|
||||
|| process.env.PUBLIC_SITE_URL
|
||||
|| DEFAULT_BASE_URL,
|
||||
500
|
||||
).replace(/\/+$/, '');
|
||||
return raw || DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
function resolvePageUrl() {
|
||||
return `${resolveBaseUrl()}${PAGE_PATH}`;
|
||||
}
|
||||
|
||||
/** 清空服务账号 id 缓存(账号被重建或测试时刷新用) */
|
||||
function resetScreenshotServiceUserCache() {
|
||||
cachedServiceUserId = null;
|
||||
}
|
||||
|
||||
/** 生成一个无法用于登录的密码哈希(随机串无人知晓,仅满足字段非空约束) */
|
||||
function buildUnusablePasswordHash() {
|
||||
return bcrypt.hash(crypto.randomBytes(32).toString('hex'), 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保系统库存在截图专用服务账号,并返回其 id。
|
||||
* 只维护 auth_source='service' 的本服务账号,不触碰任何人工账号。
|
||||
*/
|
||||
async function ensureScreenshotServiceUser() {
|
||||
if (cachedServiceUserId) return cachedServiceUserId;
|
||||
|
||||
const rows = await database.local.query(
|
||||
'SELECT id, role, status FROM users WHERE username = ? LIMIT 1',
|
||||
[SERVICE_USERNAME]
|
||||
);
|
||||
let user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
const created = await database.local.insert('users', {
|
||||
username: SERVICE_USERNAME,
|
||||
password_hash: await buildUnusablePasswordHash(),
|
||||
name: SERVICE_NAME,
|
||||
email: SERVICE_EMAIL,
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
auth_source: 'service',
|
||||
});
|
||||
user = { id: created.insertId, role: 'admin', status: 'active' };
|
||||
} else if (user.status !== 'active' || user.role !== 'admin') {
|
||||
await database.local.query(
|
||||
"UPDATE users SET role = 'admin', status = 'active', auth_source = 'service' WHERE id = ? AND auth_source = 'service'",
|
||||
[user.id]
|
||||
);
|
||||
}
|
||||
|
||||
cachedServiceUserId = Number(user.id);
|
||||
return cachedServiceUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发截图专用短时效令牌。
|
||||
* sub 为系统库中的专用服务账号 id,令牌只写入浏览器 localStorage,
|
||||
* 不会出现在截图地址、日志或接口响应里,也不含任何密钥。
|
||||
*/
|
||||
async function signScreenshotToken() {
|
||||
const secret = process.env.CUBEJS_SECRET;
|
||||
if (!secret) throw new Error('缺少 CUBEJS_SECRET,无法签发截图身份令牌');
|
||||
const userId = await ensureScreenshotServiceUser();
|
||||
return jwt.sign(
|
||||
{
|
||||
sub: String(userId),
|
||||
username: SERVICE_USERNAME,
|
||||
name: SERVICE_NAME,
|
||||
role: 'admin',
|
||||
authSource: 'service',
|
||||
allowedPageKeys: [],
|
||||
},
|
||||
secret,
|
||||
{ expiresIn: '15m' }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 截图文件名与对象键。
|
||||
* 目录与文件名里的日期都用**业务日期**,时间用执行时刻,便于按数据日期归档:
|
||||
* daily-inventory/2026-09-25/daily-inventory-20260925-010000.png
|
||||
*/
|
||||
function buildScreenshotNames({ bizDate, timeKey }) {
|
||||
const safeDate = normalizeText(bizDate, 10);
|
||||
const dateKey = safeDate.replace(/\D/g, '');
|
||||
const fileName = `daily-inventory-${dateKey}-${timeKey}.png`;
|
||||
return {
|
||||
fileName,
|
||||
objectKey: `daily-inventory/${safeDate}/${fileName}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 截图存放的临时文件(不落业务目录,上传后立即删除) */
|
||||
function buildTempFilePath({ bizDate, timeKey }) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'daily-inventory-shot-'));
|
||||
const { fileName, objectKey } = buildScreenshotNames({ bizDate, timeKey });
|
||||
return { dir, filePath: path.join(dir, fileName), objectKey };
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let playwright;
|
||||
try {
|
||||
playwright = require('playwright');
|
||||
} catch (_error) {
|
||||
throw new Error('未安装 playwright,无法执行页面截图');
|
||||
}
|
||||
try {
|
||||
return await playwright.chromium.launch({
|
||||
args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'],
|
||||
});
|
||||
} catch (error) {
|
||||
const detail = normalizeText(error?.message, 300);
|
||||
throw new Error(`浏览器启动失败(服务器需先执行 npx playwright install chromium):${detail}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 等到数据真正渲染完成:有数据行、无加载中、无错误提示 */
|
||||
async function waitForPageReady(page) {
|
||||
await page.waitForSelector('.daily-inventory-table .ant-table-tbody tr', {
|
||||
timeout: PAGE_READY_TIMEOUT_MS,
|
||||
state: 'attached',
|
||||
});
|
||||
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const spinning = document.querySelector('.daily-inventory-page .ant-spin-spinning');
|
||||
const skeleton = document.querySelector('.ant-skeleton');
|
||||
const rows = document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr');
|
||||
return !spinning && !skeleton && rows.length > 0;
|
||||
},
|
||||
{ timeout: PAGE_READY_TIMEOUT_MS, polling: 500 }
|
||||
);
|
||||
|
||||
const errorText = await page.evaluate(() => {
|
||||
const node = document.querySelector('.daily-inventory-page .ant-alert-error');
|
||||
return node ? node.textContent || '' : '';
|
||||
});
|
||||
if (errorText) {
|
||||
throw new Error(`页面返回错误提示,放弃截图:${normalizeText(errorText, 200)}`);
|
||||
}
|
||||
|
||||
const rowCount = await page.evaluate(
|
||||
() => document.querySelectorAll('.daily-inventory-table .ant-table-tbody tr').length
|
||||
);
|
||||
if (!rowCount) throw new Error('页面未渲染出任何数据行,放弃截图');
|
||||
return rowCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次截图并上传到默认对象存储桶
|
||||
* @returns {Promise<{url:string, key:string, bizDate:string, size:number, rowCount:number, takenAt:string}>}
|
||||
*/
|
||||
async function captureAndUploadDailyInventory(options = {}) {
|
||||
const now = options.now ? new Date(options.now) : new Date();
|
||||
const { timeKey } = chinaTimeParts(now);
|
||||
// 业务日期由调用方(定时任务)给出;未指定时按北京时间当天兜底
|
||||
const bizDate = normalizeText(options.bizDate, 10) || chinaTimeParts(now).bizDate;
|
||||
const token = await signScreenshotToken();
|
||||
const pageUrl = resolvePageUrl();
|
||||
|
||||
const browser = await launchBrowser();
|
||||
const { dir, filePath, objectKey } = buildTempFilePath({ bizDate, timeKey });
|
||||
let rowCount = 0;
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1680, height: 1050 },
|
||||
deviceScaleFactor: 1,
|
||||
timezoneId: TIMEZONE,
|
||||
locale: 'zh-CN',
|
||||
});
|
||||
// 令牌只注入浏览器 localStorage,URL 与日志里都不出现
|
||||
await context.addInitScript((value) => {
|
||||
try {
|
||||
window.localStorage.setItem('token', value);
|
||||
} catch (_error) {
|
||||
// 忽略:首个 document 可能还不允许访问 localStorage
|
||||
}
|
||||
}, token);
|
||||
|
||||
const page = await context.newPage();
|
||||
page.on('pageerror', () => {});
|
||||
await page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: PAGE_READY_TIMEOUT_MS });
|
||||
rowCount = await waitForPageReady(page);
|
||||
|
||||
// 展开内部滚动容器,保证整页截图包含完整数据区
|
||||
await page.addStyleTag({ content: EXPAND_CSS });
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({ path: filePath, fullPage: true });
|
||||
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
if (!stats.size) throw new Error('截图文件为空');
|
||||
|
||||
const uploaded = await uploadFileToDefaultS3(filePath, {
|
||||
key: objectKey,
|
||||
mimeType: 'image/png',
|
||||
folder: 'daily-inventory',
|
||||
extension: 'png',
|
||||
});
|
||||
|
||||
return {
|
||||
url: uploaded.url,
|
||||
key: uploaded.key,
|
||||
bizDate,
|
||||
size: stats.size,
|
||||
rowCount,
|
||||
takenAt: now.toISOString(),
|
||||
};
|
||||
} finally {
|
||||
await browser.close().catch(() => {});
|
||||
fs.rm(dir, { recursive: true, force: true }, () => {});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_BASE_URL,
|
||||
PAGE_PATH,
|
||||
PAGE_READY_TIMEOUT_MS,
|
||||
SERVICE_NAME,
|
||||
SERVICE_USERNAME,
|
||||
TIMEZONE,
|
||||
buildScreenshotNames,
|
||||
captureAndUploadDailyInventory,
|
||||
chinaTimeParts,
|
||||
ensureScreenshotServiceUser,
|
||||
resetScreenshotServiceUserCache,
|
||||
resolveBaseUrl,
|
||||
resolvePageUrl,
|
||||
signScreenshotToken,
|
||||
};
|
||||
@ -1,210 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 钉钉「群自定义机器人」webhook 发文本通道(2026-10-22 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 为什么不用企业内部应用发文件?
|
||||
* 群机器人 webhook **只能发** text / markdown / link / actionCard / feedCard,
|
||||
* 发 .xlsx 附件必须走企业内部应用(AppKey/AppSecret/robotCode/openConversationId)。
|
||||
* 业务最终选择「群里直接看文本」,于是回到最简单的一条路:
|
||||
* 只要一个 webhook 地址(加签可选),不用申请应用、不用管 token 过期。
|
||||
*
|
||||
* 请求形状(钉钉官方「自定义机器人接入」):
|
||||
* POST https://oapi.dingtalk.com/robot/send?access_token=xxx×tamp=xxx&sign=xxx
|
||||
* { "msgtype": "text", "text": { "content": "..." }, "at": { "isAtAll": false } }
|
||||
* → { "errcode": 0, "errmsg": "ok" }
|
||||
*
|
||||
* 三个必须知道的限制(踩过就知道疼):
|
||||
* ① text.content **上限 20000 字节**(中文 UTF-8 是 3 字节/字)→ 超了钉钉直接 400,
|
||||
* 所以这里按**字节**截断(不是按字符),并返回是否被截断。
|
||||
* ② 加了「自定义关键词」安全设置时,content 里必须含关键词(常用「异常」「提醒」),
|
||||
* 否则 errcode 310000。这个错要让业务自己看得懂,所以原样透出 errmsg。
|
||||
* ③ 加签密钥(SEC 开头)算出来的 sign 必须 **urlEncode**,且 timestamp 是**毫秒**。
|
||||
* 顺序错了就是 310000 sign not match,很难看出是编码问题。
|
||||
*
|
||||
* 失败一律抛错并带上钉钉原始 errcode/errmsg —— 业务排错全靠这个码。
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
const OAPI_BASE = process.env.DINGTALK_OAPI_BASE || 'https://oapi.dingtalk.com';
|
||||
const REQUEST_TIMEOUT_MS = Math.max(Number(process.env.DINGTALK_WEBHOOK_TIMEOUT_MS) || 20000, 3000);
|
||||
// 钉钉 text 消息 content 的硬上限(字节)。留 200 字节余量给自己追加的尾巴
|
||||
const MAX_CONTENT_BYTES = 20000;
|
||||
const SAFE_CONTENT_BYTES = MAX_CONTENT_BYTES - 200;
|
||||
|
||||
function normalizeText(value, maxLength = 512) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
/** 按 UTF-8 **字节**截断,且不把多字节字符切一半 */
|
||||
function truncateBytes(text, maxBytes = SAFE_CONTENT_BYTES) {
|
||||
const source = String(text ?? '');
|
||||
if (Buffer.byteLength(source, 'utf8') <= maxBytes) return { text: source, truncated: false };
|
||||
const chars = Array.from(source);
|
||||
let bytes = 0;
|
||||
let out = '';
|
||||
for (const char of chars) {
|
||||
const size = Buffer.byteLength(char, 'utf8');
|
||||
if (bytes + size > maxBytes) break;
|
||||
bytes += size;
|
||||
out += char;
|
||||
}
|
||||
return { text: out, truncated: true };
|
||||
}
|
||||
|
||||
function buildDingTalkError(label, payload, httpStatus) {
|
||||
const code = payload?.errcode ?? payload?.code ?? httpStatus ?? null;
|
||||
const message = payload?.errmsg || payload?.message || `${label}失败`;
|
||||
const error = new Error(`${label}失败(${code}):${message}`);
|
||||
error.code = code;
|
||||
error.httpStatus = httpStatus || null;
|
||||
error.dingtalk = payload || null;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 webhook 地址。放宽 host(支持自建代理 / 测试 stub),但必须 https 且是 robot/send。
|
||||
* 返回去掉首尾空白后的地址;不合法抛 400(页面上直接提示,不让它跑到定时任务里才炸)。
|
||||
*/
|
||||
function normalizeWebhookUrl(value) {
|
||||
const text = normalizeText(value, 1024);
|
||||
if (!text) {
|
||||
throw Object.assign(new Error('请填写钉钉群机器人的 Webhook 地址'), { status: 400, code: 'WEBHOOK_REQUIRED' });
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = new URL(text);
|
||||
} catch (_error) {
|
||||
throw Object.assign(new Error('Webhook 地址不是合法 URL'), { status: 400, code: 'WEBHOOK_INVALID' });
|
||||
}
|
||||
if (!/^https?:$/.test(url.protocol)) {
|
||||
throw Object.assign(new Error('Webhook 地址必须以 http(s):// 开头'), { status: 400, code: 'WEBHOOK_INVALID' });
|
||||
}
|
||||
// 严格匹配 `/robot/send`(不要用「以 /robot/send 结尾」—— 那样
|
||||
// `topapi/robot/send` 这种企业内部应用地址也会被放过去,最后报一个难懂的 404)
|
||||
if (!/^\/robot\/send\/?$/.test(url.pathname)) {
|
||||
throw Object.assign(
|
||||
new Error('这不像群机器人 Webhook(正常形如 https://oapi.dingtalk.com/robot/send?access_token=xxx)'),
|
||||
{ status: 400, code: 'WEBHOOK_INVALID' }
|
||||
);
|
||||
}
|
||||
if (!url.searchParams.get('access_token')) {
|
||||
throw Object.assign(
|
||||
new Error('Webhook 地址缺少 access_token 参数,请从钉钉群机器人设置里完整复制'),
|
||||
{ status: 400, code: 'WEBHOOK_INVALID' }
|
||||
);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加签:sign = urlEncode(base64(HmacSHA256(secret, `${timestamp}\n${secret}`)))
|
||||
* ⚠️ HMAC 的 key 是 secret、消息是 `timestamp\nsecret`(顺序反过来就是 310000)
|
||||
*/
|
||||
function buildSignedUrl(webhookUrl, secret, timestampMs = Date.now()) {
|
||||
const timestamp = String(Number(timestampMs) || Date.now());
|
||||
const timestamped = new URL(webhookUrl);
|
||||
timestamped.searchParams.set('timestamp', timestamp);
|
||||
|
||||
const text = normalizeText(secret, 256);
|
||||
if (!text) return timestamped.toString();
|
||||
|
||||
const sign = crypto
|
||||
.createHmac('sha256', text)
|
||||
.update(`${timestamp}\n${text}`, 'utf8')
|
||||
.digest('base64');
|
||||
timestamped.searchParams.set('sign', sign);
|
||||
return timestamped.toString();
|
||||
}
|
||||
|
||||
async function requestJson(label, url, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||
const raw = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = raw ? JSON.parse(raw) : {};
|
||||
} catch (_error) {
|
||||
payload = { errmsg: raw.slice(0, 500) };
|
||||
}
|
||||
if (!response.ok) throw buildDingTalkError(label, payload, response.status);
|
||||
if (payload && payload.errcode !== undefined && Number(payload.errcode) !== 0) {
|
||||
throw buildDingTalkError(label, payload, response.status);
|
||||
}
|
||||
return payload || {};
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw Object.assign(new Error(`${label}超时(${REQUEST_TIMEOUT_MS}ms)`), { code: 'TIMEOUT' });
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发一条文本消息到群。
|
||||
* @param {object} params
|
||||
* @param {string} params.webhookUrl 群机器人 Webhook(含 access_token)
|
||||
* @param {string} [params.secret] 加签密钥(安全设置选「加签」时填;选关键词/IP 时留空)
|
||||
* @param {string} params.content 纯文本(支持 \n 换行)
|
||||
* @param {boolean} [params.atAll] 是否 @所有人
|
||||
* @param {string[]} [params.atMobiles]
|
||||
* @returns {Promise<{delivered:boolean,truncated:boolean,bytes:number,content:string}>}
|
||||
*/
|
||||
async function sendTextToWebhook({
|
||||
webhookUrl, secret, content, atAll = false, atMobiles = [],
|
||||
}) {
|
||||
const base = normalizeWebhookUrl(webhookUrl);
|
||||
const raw = String(content ?? '');
|
||||
if (!raw.trim()) {
|
||||
throw Object.assign(new Error('待发送的消息内容为空'), { code: 'EMPTY_CONTENT' });
|
||||
}
|
||||
|
||||
const limited = truncateBytes(raw);
|
||||
const url = buildSignedUrl(base, secret);
|
||||
const mobiles = (Array.isArray(atMobiles) ? atMobiles : [])
|
||||
.map((item) => normalizeText(item, 32))
|
||||
.filter((item) => /^\d{6,20}$/.test(item));
|
||||
|
||||
const payload = await requestJson('发送钉钉群消息', url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json;charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'text',
|
||||
text: { content: limited.text },
|
||||
at: { isAtAll: atAll === true, atMobiles: mobiles },
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
delivered: true,
|
||||
truncated: limited.truncated,
|
||||
bytes: Buffer.byteLength(limited.text, 'utf8'),
|
||||
content: limited.text,
|
||||
errmsg: normalizeText(payload.errmsg, 200) || 'ok',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「测试推送」用一句短文本探活:只验 webhook(+加签)能不能通。
|
||||
* 不读取业务数据 —— 测试时不该因为数据慢而误判成通道坏。
|
||||
*/
|
||||
async function sendProbeText({ webhookUrl, secret, text }) {
|
||||
const content = normalizeText(text, 500)
|
||||
|| '【仓储中心物流转运异常】通知通道测试:收到这条说明 webhook 配置可用。';
|
||||
return sendTextToWebhook({ webhookUrl, secret, content });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_CONTENT_BYTES,
|
||||
OAPI_BASE,
|
||||
buildSignedUrl,
|
||||
normalizeWebhookUrl,
|
||||
sendProbeText,
|
||||
sendTextToWebhook,
|
||||
truncateBytes,
|
||||
};
|
||||
@ -1,100 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 通用表格导出
|
||||
* --------------------------------------------------------------------------
|
||||
* 把 [{ name, header: string[], rows: any[][] }] 渲染成:
|
||||
* - xlsx:多工作表工作簿(基于 backend 已依赖的 xlsx 包)
|
||||
* - csv :单工作表、带 UTF-8 BOM(Excel 直接打开中文不乱码)
|
||||
* 语义与 frontend/src/utils/tableExport.js 的「表头 + 行」保持一致。
|
||||
*/
|
||||
|
||||
const xlsx = require('xlsx');
|
||||
|
||||
const XLSX_CONTENT_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8';
|
||||
|
||||
// Excel 工作表名限制:<=31 字符,且不能包含 : \ / ? * [ ]
|
||||
const sanitizeSheetName = (name, fallback) => {
|
||||
const cleaned = String(name || '').replace(/[:\\/?*[\]]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
return (cleaned || fallback).slice(0, 31);
|
||||
};
|
||||
|
||||
const toCellValue = (value) => (
|
||||
value === null || value === undefined || value === '' ? '' : value
|
||||
);
|
||||
|
||||
const sheetToAoa = (sheet) => ([
|
||||
(sheet?.header || []).map(toCellValue),
|
||||
...((sheet?.rows || []).map((row) => (row || []).map(toCellValue))),
|
||||
]);
|
||||
|
||||
/** 多工作表 xlsx buffer;工作表名重复/非法时自动去重与截断 */
|
||||
const buildXlsxBuffer = (sheets = []) => {
|
||||
const workbook = xlsx.utils.book_new();
|
||||
const usedNames = new Set();
|
||||
|
||||
sheets.forEach((sheet, index) => {
|
||||
const base = sanitizeSheetName(sheet?.name, `工作表${index + 1}`);
|
||||
let name = base;
|
||||
let suffix = 2;
|
||||
while (usedNames.has(name)) {
|
||||
name = `${base.slice(0, 27)}(${suffix})`;
|
||||
suffix += 1;
|
||||
}
|
||||
usedNames.add(name);
|
||||
|
||||
const worksheet = xlsx.utils.aoa_to_sheet(sheetToAoa(sheet));
|
||||
worksheet['!cols'] = (sheet?.header || []).map((title) => ({
|
||||
wch: Math.max(10, Math.min(32, String(title || '').length * 2 + 6)),
|
||||
}));
|
||||
xlsx.utils.book_append_sheet(workbook, worksheet, name);
|
||||
});
|
||||
|
||||
return xlsx.write(workbook, { type: 'buffer', bookType: 'xlsx' });
|
||||
};
|
||||
|
||||
const quoteCsvCell = (value) => {
|
||||
const text = value === null || value === undefined ? '' : String(value);
|
||||
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
|
||||
};
|
||||
|
||||
/** 单工作表 csv buffer(只取第一个工作表),前置 BOM */
|
||||
const buildCsvBuffer = (sheets = []) => {
|
||||
const sheet = sheets[0] || { header: [], rows: [] };
|
||||
const lines = [(sheet.header || []).map(quoteCsvCell).join(',')];
|
||||
(sheet.rows || []).forEach((row) => {
|
||||
lines.push((row || []).map(quoteCsvCell).join(','));
|
||||
});
|
||||
return Buffer.from(`\ufeff${lines.join('\r\n')}`, 'utf8');
|
||||
};
|
||||
|
||||
const sanitizeFileName = (fileName) => String(fileName || '导出数据').replace(/[\\/:*?"<>|]/g, '_').trim() || '导出数据';
|
||||
|
||||
/**
|
||||
* 直接把导出结果写回响应。
|
||||
* @param {import('express').Response} res
|
||||
* @param {{ fileName?: string, format?: 'xlsx'|'csv', sheets?: Array }} payload
|
||||
*/
|
||||
const sendWorkbookDownload = (res, { fileName, format = 'xlsx', sheets = [] } = {}) => {
|
||||
const isCsv = String(format).toLowerCase() === 'csv';
|
||||
const exportSheets = isCsv ? sheets.slice(0, 1) : sheets;
|
||||
const buffer = isCsv ? buildCsvBuffer(exportSheets) : buildXlsxBuffer(exportSheets);
|
||||
const totalRows = exportSheets.reduce((sum, sheet) => sum + ((sheet?.rows || []).length), 0);
|
||||
const fullName = `${sanitizeFileName(fileName)}${isCsv ? '.csv' : '.xlsx'}`;
|
||||
|
||||
res.setHeader('Content-Type', isCsv ? CSV_CONTENT_TYPE : XLSX_CONTENT_TYPE);
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fullName)}`);
|
||||
res.setHeader('X-Export-Count', String(totalRows));
|
||||
res.send(buffer);
|
||||
return { totalRows, fullName };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
XLSX_CONTENT_TYPE,
|
||||
CSV_CONTENT_TYPE,
|
||||
sanitizeSheetName,
|
||||
buildXlsxBuffer,
|
||||
buildCsvBuffer,
|
||||
sendWorkbookDownload,
|
||||
};
|
||||
@ -12,13 +12,6 @@ const {
|
||||
ensureProductMonitoringSchema,
|
||||
warmProductMonitoringDataset,
|
||||
} = require('./productMonitoringService');
|
||||
const { ensureReturnMonitorSchema } = require('./returnMonitorSchema');
|
||||
const {
|
||||
ensureReturnStockWarningSchema,
|
||||
} = require('./returnStockWarningSchema');
|
||||
const { ensureBinReplenishSchema } = require('./binReplenishSchema');
|
||||
const { warmBinReplenishCache } = require('./binReplenishService');
|
||||
const { warmReturnStockWarningCache } = require('./returnStockWarningService');
|
||||
|
||||
const USER_COLUMNS = [
|
||||
['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"],
|
||||
@ -959,13 +952,6 @@ async function ensureLocalSchema() {
|
||||
);
|
||||
|
||||
await ensureProductMonitoringSchema();
|
||||
await ensureReturnMonitorSchema();
|
||||
await ensureReturnStockWarningSchema();
|
||||
await ensureBinReplenishSchema();
|
||||
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
|
||||
void warmReturnStockWarningCache();
|
||||
// 缺货补货监控(仓位库存视图单次查询 35~45s)后台预热,不阻塞启动
|
||||
void warmBinReplenishCache();
|
||||
|
||||
await ensureSidebarRolePermissions();
|
||||
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -13,8 +13,6 @@
|
||||
// 目标仓库共 794 个 SKU
|
||||
// 库存 rpt.jst_inventoryInfo —— 实时库存 qty;按 wms_co_id 区分仓库
|
||||
// 目标仓库 wms_co_id = 15718196(rpt.jst_warehouseInfo 查得)
|
||||
// 该表为 0/缺行而箱及仓位库存已有正库存时,用同仓 rpt.jst_packInventoryInfo 合计补齐;
|
||||
// 只补 0,不覆盖 inventoryInfo 中的正库存,避免箱表未覆盖散货时低估库存。
|
||||
// 监控 rpt.pm_stockMonitor —— 日更快照,目标仓每天 794 行,覆盖全部 SKU:
|
||||
// fifteenAvgSale 近15天日均发货量(2026-09-17 由 7 天改 15 天,见下)
|
||||
// jsUseDays 库存可用天数
|
||||
@ -79,39 +77,13 @@ const SORTABLE_FIELDS = Object.freeze({
|
||||
category: 's.category',
|
||||
realtimeStock: 'realtimeStock',
|
||||
avgShip15d: 'm.fifteenAvgSale',
|
||||
stockDays: 'stockDays',
|
||||
stockDays: 'm.jsUseDays',
|
||||
warningLevel: 'warningOrder',
|
||||
mkhStatus: 'mkhStatus',
|
||||
location: 'location',
|
||||
warehouse: 's.other_3',
|
||||
});
|
||||
|
||||
// 实时库存优先使用库存表;库存表没有正库存时,用同仓「箱及仓位库存」补齐。
|
||||
// 聚水潭上游可能已经生成箱/仓位库存,但 jst_inventoryInfo 仍停留在旧值。
|
||||
// 只补 0/缺行,避免箱表只记录部分装箱库存时压低库存表中的有效库存。
|
||||
const buildRealtimeStockSql = () => (
|
||||
`CASE
|
||||
WHEN inv.qty > 0 THEN inv.qty
|
||||
WHEN ISNULL(ploc.packQty, 0) > 0 THEN ploc.packQty
|
||||
WHEN inv.qty IS NOT NULL THEN inv.qty
|
||||
ELSE ISNULL(m.jsStock, 0)
|
||||
END`
|
||||
);
|
||||
|
||||
// 箱表兜底生效时,监控快照的 jsUseDays 仍基于旧库存,需要同步重算。
|
||||
// 无日销时返回 NULL,不能因为旧快照为 0 天而把已有库存误报为低库存。
|
||||
const buildStockDaysSql = () => (
|
||||
`CASE
|
||||
WHEN ISNULL(inv.qty, 0) <= 0 AND ISNULL(ploc.packQty, 0) > 0 THEN
|
||||
CASE
|
||||
WHEN ISNULL(m.fifteenAvgSale, 0) > 0
|
||||
THEN CAST(ploc.packQty / NULLIF(m.fifteenAvgSale, 0) AS DECIMAL(18, 2))
|
||||
ELSE NULL
|
||||
END
|
||||
ELSE m.jsUseDays
|
||||
END`
|
||||
);
|
||||
|
||||
/**
|
||||
* 「已配置MKH」判定表达式(2026-09-17 晚修正口径):
|
||||
* 该 SKU 作为「配件商品编码」(map_outer_sku_id)出现在聚水潭 SKU 级 BOM 维护
|
||||
@ -135,9 +107,8 @@ const buildConfiguredExistsSql = () => (
|
||||
const buildWarningOrderSql = () => (
|
||||
`CASE
|
||||
WHEN cfg.configured = 0 THEN 2
|
||||
WHEN ${buildRealtimeStockSql()} <= 0 THEN 0
|
||||
WHEN ${buildStockDaysSql()} IS NOT NULL
|
||||
AND ${buildStockDaysSql()} <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
|
||||
WHEN COALESCE(inv.qty, m.jsStock, 0) <= 0 THEN 0
|
||||
WHEN m.jsUseDays IS NOT NULL AND m.jsUseDays <= ${LOW_STOCK_DAYS_THRESHOLD} THEN 1
|
||||
ELSE 2
|
||||
END`
|
||||
);
|
||||
@ -200,29 +171,15 @@ OUTER APPLY (
|
||||
WHERE UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), c.sku)))) = UPPER(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), s.sku_id))))
|
||||
ORDER BY c.createTime DESC
|
||||
) cost
|
||||
-- 仓位及箱库存:先按 SKU/仓位一次性汇总,再关联目标仓,避免对 794 个 SKU
|
||||
-- 分别扫描无独立索引的上游视图。rn=1 是库存最大的主货位,packQty 是该 SKU 全仓合计。
|
||||
LEFT JOIN (
|
||||
SELECT ranked.sku_id, ranked.wms_co_id, ranked.bin, ranked.packQty
|
||||
FROM (
|
||||
SELECT
|
||||
pk.sku_id,
|
||||
pk.wms_co_id,
|
||||
NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin,
|
||||
SUM(ISNULL(pk.qty, 0)) AS binQty,
|
||||
SUM(SUM(ISNULL(pk.qty, 0))) OVER (PARTITION BY pk.sku_id, pk.wms_co_id) AS packQty,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY pk.sku_id, pk.wms_co_id
|
||||
ORDER BY SUM(ISNULL(pk.qty, 0)) DESC, NULLIF(LTRIM(RTRIM(pk.bin)), N'') ASC
|
||||
) AS rn
|
||||
FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK)
|
||||
WHERE pk.wms_co_id = @wmsCoId
|
||||
AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL
|
||||
GROUP BY pk.sku_id, pk.wms_co_id, NULLIF(LTRIM(RTRIM(pk.bin)), N'')
|
||||
HAVING SUM(ISNULL(pk.qty, 0)) > 0
|
||||
) ranked
|
||||
WHERE ranked.rn = 1
|
||||
) ploc ON ploc.sku_id = s.sku_id AND ploc.wms_co_id = @wmsCoId
|
||||
-- 仓位:主货位(真货位),见 buildLocationSql() 说明
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 NULLIF(LTRIM(RTRIM(pk.bin)), N'') AS bin
|
||||
FROM dbo.jst_packInventoryInfo pk WITH (NOLOCK)
|
||||
WHERE pk.sku_id = s.sku_id
|
||||
AND NULLIF(LTRIM(RTRIM(pk.bin)), N'') IS NOT NULL
|
||||
AND ISNULL(pk.qty, 0) > 0
|
||||
ORDER BY pk.qty DESC
|
||||
) ploc
|
||||
-- 模块化配置状态:见文件头部「已配置MKH」口径说明
|
||||
OUTER APPLY (
|
||||
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured
|
||||
@ -405,13 +362,13 @@ const buildProjection = () => `
|
||||
s.sku_id AS skuId,
|
||||
s.name AS productName,
|
||||
s.category AS category,
|
||||
${buildRealtimeStockSql()} AS realtimeStock,
|
||||
COALESCE(inv.qty, m.jsStock, 0) AS realtimeStock,
|
||||
ISNULL(ptr.transit, 0) AS purchaseTransit,
|
||||
ISNULL(pin.inQty, 0) AS inQtyToday,
|
||||
ISNULL(m.nearSecondSale, 0) AS outQtyToday,
|
||||
${buildLocationSql()} AS location,
|
||||
m.fifteenAvgSale AS avgShip15d,
|
||||
${buildStockDaysSql()} AS stockDays,
|
||||
m.jsUseDays AS stockDays,
|
||||
m.safetyDays AS safetyDays,
|
||||
m.suggestStock AS suggestStock,
|
||||
s.other_3 AS warehouse,
|
||||
@ -488,7 +445,7 @@ async function buildSummary(whereSql, params) {
|
||||
`SELECT
|
||||
COUNT_BIG(*) AS skuCount,
|
||||
SUM(CASE WHEN ${buildWarningOrderSql()} = 0 THEN 1 ELSE 0 END) AS outOfStockCount,
|
||||
CAST(ISNULL(SUM(${buildRealtimeStockSql()}), 0) AS BIGINT) AS totalStock,
|
||||
CAST(ISNULL(SUM(COALESCE(inv.qty, m.jsStock, 0)), 0) AS BIGINT) AS totalStock,
|
||||
CAST(ISNULL(SUM(ISNULL(ptr.transit, 0)), 0) AS BIGINT) AS totalPurchaseTransit,
|
||||
SUM(CASE WHEN ${buildWarningOrderSql()} = 1 THEN 1 ELSE 0 END) AS lowStockDaysCount,
|
||||
SUM(CASE WHEN cfg.configured = 1 THEN 1 ELSE 0 END) AS configuredCount,
|
||||
|
||||
@ -1,243 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 拣选库位动销分析 —— 业务库取数
|
||||
*
|
||||
* 库存:rpt.jst_packInventoryInfo 按 wms_co_id + 仓位 + sku_id 汇总 qty(仓位为空的行不参与)
|
||||
* 范围:只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);
|
||||
* 箱记录(pack_type=Pack)与存货位(bin_group=存货)不纳入
|
||||
* 销量(4 个来源分支,均按 send_date 窗口):
|
||||
* 1) rpt.jst_Orders × jst_OrderItem 普通行(sku_type<>combine),排除 Delete/Cancelled
|
||||
* 2) rpt.jst_Orders 组合装行(sku_id 为 ZH-... 拼接串)→ JOIN jst_comboProductItem 展开为子件×数量
|
||||
* 3) rpt.jst_qmcust_orderlist(全民客户 B2B 分销)普通行,排除 Delete/Cancelled/Split/Question
|
||||
* (Split 是拆单父单,与拆出的子单重复,必须排除)
|
||||
* 4) rpt.jst_qmcust_orderlist 组合装行 → 同样按映射表展开
|
||||
* 粒度:仓位 × 商品编码(一个仓位有 N 个商品就输出 N 行),列表与导出共用
|
||||
*
|
||||
* 【日销与动销等级口径】按「商品日销」判定,不做仓位分摊:
|
||||
* 商品日销 = 该商品在窗口内的整档销量 ÷ 窗口天数
|
||||
* 同一件商品无论占用几个仓位,各行显示的销量 / 日销 / 动销等级完全一致 ——
|
||||
* 不会因为货放得散就被算成滞销。
|
||||
*
|
||||
* 附带输出「分摊销量」= 商品销量 ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景:
|
||||
* 恒等式(已实测校验,差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。
|
||||
* 注意它小于「全仓销量」:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,
|
||||
* 没有仓位记录的 SKU 的销量不会体现在任何库位上。
|
||||
*/
|
||||
|
||||
const { PICKING_BIN_GRADE_RULES, BIN_TYPE_PICKING } = require('../config/pickingBinSalesAnalysis');
|
||||
|
||||
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
|
||||
function buildInClause(values, prefix) {
|
||||
const params = {};
|
||||
const clause = values
|
||||
.map((v, i) => {
|
||||
const key = `${prefix}${i}`;
|
||||
params[key] = v;
|
||||
return `@${key}`;
|
||||
})
|
||||
.join(',');
|
||||
return { clause, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 日销分档表达式:从高档往低档依次命中,最后一档「销售=0」由 salesQty <= 0 兜底。
|
||||
* 传入的 salesExpr 是窗口销量(商品整档销量),除以天数得到日销。
|
||||
*/
|
||||
function buildGradeCase(salesExpr = 'ISNULL(s.salesQty, 0)', daysParam = '@days') {
|
||||
const branches = PICKING_BIN_GRADE_RULES
|
||||
.filter((rule) => rule.lower > 0)
|
||||
.map((rule) => {
|
||||
const daily = `(${salesExpr} / ${daysParam})`;
|
||||
const lower = `${daily} >= ${rule.lower}`;
|
||||
const upper = rule.upper == null ? null : `${daily} < ${rule.upper}`;
|
||||
const cond = upper ? `${lower} AND ${upper}` : lower;
|
||||
return `WHEN ${cond} THEN '${rule.key}'`;
|
||||
});
|
||||
|
||||
return `
|
||||
CASE
|
||||
${branches.join('\n ')}
|
||||
WHEN ${salesExpr} > 0 THEN 'slow2'
|
||||
ELSE 'zero'
|
||||
END`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取「仓位 × 商品编码」粒度明细(列表与导出共用)。
|
||||
*
|
||||
* - stockQty:该商品在此仓位的实际库存
|
||||
* - salesQty:该商品在此仓库的窗口销量(整档,不按仓位分摊),动销等级的判定依据
|
||||
* - dailySales:salesQty / 窗口天数 = 商品日销
|
||||
* - allocatedSalesQty:salesQty ÷ 该商品占用的同类型仓位数,用于需要按行求和的场景
|
||||
* - skuCount:该仓位内存放的商品数(仓位粒度,同一仓位多行重复)
|
||||
*
|
||||
* @param {object} cloudDb 业务库实例
|
||||
* @param {string[]} wmsCoIds 仓库编码
|
||||
* @param {{shipFrom:string, shipTo:string, days:number}} options
|
||||
* @returns {Promise<Array<{wmsCoId:string, binName:string, binType:string, skuId:string,
|
||||
* stockQty:number, skuCount:number, salesQty:number, allocatedSalesQty:number,
|
||||
* dailySales:number, gradeKey:string}>>}
|
||||
*/
|
||||
async function fetchPickingBinSkuRows(cloudDb, wmsCoIds, options = {}) {
|
||||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||||
throw new TypeError('fetchPickingBinSkuRows 的第一个参数必须是业务库实例');
|
||||
}
|
||||
const { shipFrom, shipTo, days } = options;
|
||||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||||
if (!shipFrom || !shipTo || !days) return [];
|
||||
|
||||
const binIn = buildInClause(wmsCoIds, 'w');
|
||||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||||
|
||||
// 统计范围固定为真实拣选仓位:pack_type=Bin 且 bin_group 为空。
|
||||
// 仅过滤 bin_group 会把同样为空的 Pack 箱记录误标为拣选位。
|
||||
const sqlText = `
|
||||
WITH bin AS (
|
||||
SELECT p.wms_co_id AS wmsCoId,
|
||||
LTRIM(RTRIM(p.bin)) AS binName,
|
||||
N'${BIN_TYPE_PICKING}' AS binType,
|
||||
p.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binSkuQty
|
||||
FROM rpt.jst_packInventoryInfo p
|
||||
WHERE p.wms_co_id IN (${binIn.clause})
|
||||
AND ISNULL(p.bin, '') <> ''
|
||||
AND ISNULL(p.qty, 0) <> 0
|
||||
AND p.pack_type = N'Bin'
|
||||
AND ISNULL(p.bin_group, '') = ''
|
||||
GROUP BY p.wms_co_id,
|
||||
LTRIM(RTRIM(p.bin)),
|
||||
p.sku_id
|
||||
),
|
||||
binSpan AS (
|
||||
SELECT wmsCoId, skuId, binType, COUNT(DISTINCT binName) AS binCount
|
||||
FROM bin
|
||||
GROUP BY wmsCoId, skuId, binType
|
||||
),
|
||||
ship AS (
|
||||
-- 1) 线上店铺订单:普通商品行(sku_type<>combine,组合装行在下一分支展开)
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
oi.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||||
FROM rpt.jst_OrderItem oi
|
||||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||||
AND o.send_date >= @shipFrom
|
||||
AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
|
||||
GROUP BY o.wms_co_id, oi.sku_id
|
||||
UNION ALL
|
||||
-- 2) 线上店铺订单:组合装行(sku_id 为 ZH-子件A-N-子件B-M 拼接串)→ 按映射表展开为子件
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
ci.src_sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||||
FROM rpt.jst_OrderItem oi
|
||||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
|
||||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||||
AND o.send_date >= @shipFrom
|
||||
AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
AND oi.sku_type = 'combine'
|
||||
GROUP BY o.wms_co_id, ci.src_sku_id
|
||||
UNION ALL
|
||||
-- 3) 全民客户(B2B 分销)订单:普通商品行(拆单父单 Split 与子单重复,需排除)
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
oi.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||||
FROM rpt.jst_qmcust_orderlist_item oi
|
||||
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
|
||||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||||
AND o.send_date >= @shipFrom
|
||||
AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
|
||||
AND ISNULL(oi.sku_type, 'normal') <> 'combine'
|
||||
GROUP BY o.wms_co_id, oi.sku_id
|
||||
UNION ALL
|
||||
-- 4) 全民客户订单:组合装行展开为子件
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
ci.src_sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) * ISNULL(ci.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||||
FROM rpt.jst_qmcust_orderlist_item oi
|
||||
JOIN rpt.jst_qmcust_orderlist o ON o.o_id = oi.o_id
|
||||
JOIN rpt.jst_comboProductItem ci ON ci.combine_sku_id = oi.sku_id
|
||||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||||
AND o.send_date >= @shipFrom
|
||||
AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled', 'Split', 'Question')
|
||||
AND oi.sku_type = 'combine'
|
||||
GROUP BY o.wms_co_id, ci.src_sku_id
|
||||
),
|
||||
shipAgg AS (
|
||||
-- 同一个 仓库+商品 可能同时命中多个来源分支(线上普通/线上组合装/全民客户普通/全民客户组合装),
|
||||
-- 必须先按 仓库+商品 汇总成一行再 join,否则一个仓位行会被拆成多行、每行只带某分支的销量。
|
||||
SELECT wmsCoId,
|
||||
skuId,
|
||||
SUM(CAST(salesQty AS DECIMAL(18, 2))) AS salesQty
|
||||
FROM ship
|
||||
GROUP BY wmsCoId, skuId
|
||||
),
|
||||
binAgg AS (
|
||||
SELECT wmsCoId,
|
||||
binName,
|
||||
binType,
|
||||
COUNT(DISTINCT skuId) AS skuCount
|
||||
FROM bin
|
||||
GROUP BY wmsCoId, binName, binType
|
||||
)
|
||||
SELECT b.wmsCoId,
|
||||
b.binName,
|
||||
b.binType,
|
||||
b.skuId,
|
||||
CAST(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2))) AS DECIMAL(18, 2)) AS stockQty,
|
||||
CAST(a.skuCount AS INT) AS skuCount,
|
||||
CAST(ISNULL(s.salesQty, 0) AS DECIMAL(18, 2)) AS salesQty,
|
||||
CAST(ISNULL(s.salesQty, 0) / NULLIF(sp.binCount, 0) AS DECIMAL(18, 4)) AS allocatedSalesQty,
|
||||
CAST(ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)) AS dailySales,
|
||||
${buildGradeCase('ISNULL(s.salesQty, 0)')} AS gradeKey
|
||||
FROM bin b
|
||||
JOIN binSpan sp
|
||||
ON sp.wmsCoId = b.wmsCoId AND sp.skuId = b.skuId AND sp.binType = b.binType
|
||||
JOIN binAgg a
|
||||
ON a.wmsCoId = b.wmsCoId AND a.binName = b.binName AND a.binType = b.binType
|
||||
LEFT JOIN shipAgg s
|
||||
ON s.wmsCoId = b.wmsCoId AND s.skuId = b.skuId
|
||||
GROUP BY b.wmsCoId, b.binName, b.binType, b.skuId, a.skuCount, s.salesQty, sp.binCount
|
||||
HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001
|
||||
ORDER BY b.wmsCoId, b.binType, b.binName, b.skuId`;
|
||||
|
||||
// 该查询 union 了 4 个销量来源(含 qmcust 全渠道扫描),全仓窗口可能超过默认 60s;
|
||||
// mssql 12.x 请求级 timeout 不生效,必须走池级放宽的慢查询入口。
|
||||
const runQuery = typeof cloudDb.querySlow === 'function' ? cloudDb.querySlow : cloudDb.query;
|
||||
const rows = await runQuery(
|
||||
sqlText,
|
||||
{
|
||||
...binIn.params,
|
||||
...shipIn.params,
|
||||
shipFrom,
|
||||
shipTo,
|
||||
days,
|
||||
},
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
return rows.map((r) => ({
|
||||
wmsCoId: String(r.wmsCoId),
|
||||
binName: r.binName || '',
|
||||
binType: r.binType || '',
|
||||
skuId: r.skuId == null ? '' : String(r.skuId).trim(),
|
||||
stockQty: Number(r.stockQty) || 0,
|
||||
skuCount: Number(r.skuCount) || 0,
|
||||
salesQty: Number(r.salesQty) || 0,
|
||||
allocatedSalesQty: Number(r.allocatedSalesQty) || 0,
|
||||
dailySales: Number(r.dailySales) || 0,
|
||||
gradeKey: r.gradeKey || 'zero',
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildGradeCase,
|
||||
buildInClause,
|
||||
fetchPickingBinSkuRows,
|
||||
};
|
||||
@ -1,412 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
|
||||
/**
|
||||
* 退货库存预警服务
|
||||
* --------------------------------------------------------------------------
|
||||
* 数据来源:业务数据库(MSSQL 报表副本 / JSReportReplica, schema rpt)。
|
||||
* 固定过滤仓库:「销售退仓 横林二仓」对应业务库仓库名「简尚-横林二仓销退仓」。
|
||||
*
|
||||
* 日期口径(重要):
|
||||
* 列表按「退货入库日期」筛选,取 jst_returnAndRefundItem.receive_date(明细级收货日期),
|
||||
* 而不是 jst_returnAndRefundInfo.as_date(售后申请日期)。原因:
|
||||
* - as_date 是售后单申请日,很多退货是几天后才实际收货入库的;
|
||||
* - 只按 as_date 过滤,会漏掉"前几天申请、今天才收货入库"的明细。
|
||||
* 实测(2026-09-12):按 as_date 过滤只有 366 条明细;按 receive_date 过滤有 745 条明细,
|
||||
* 更接近聚水潭「销售退仓」当天口径。
|
||||
*
|
||||
* 数据来源口径(重要,2026-09-15 修正):
|
||||
* 副本里有两条互不重叠的退货入库同步管线,必须取并集才对得上聚水潭「销售退仓」:
|
||||
* ① ERP 售后单 jst_returnAndRefundInfo × jst_returnAndRefundItem
|
||||
* ② 奇门退款单 jst_qmcust_refundlist × jst_qmcust_refundlist_item
|
||||
* 去重键 = (退货单号 as_id, 商品编码 sku_id),同键优先取 ERP 行。
|
||||
*
|
||||
* 字段映射(业务库 -> 逻辑字段,与前端页面表头一一对应):
|
||||
* 退货入库时间 returnInboundTime <- jst_returnAndRefundItem.receive_date(回退 info.as_date)
|
||||
* 快递单号 expressNo <- jst_returnAndRefundInfo.l_id
|
||||
* 供应商 supplier <- jst_sku.supplier_name
|
||||
* 预警 warning <- ope_clear_task.alert(按 sku 取最新月)
|
||||
* 商品编码 productCode <- jst_returnAndRefundItem.sku_id
|
||||
* 入库件数 inboundQty <- jst_returnAndRefundItem.r_qty(实收件数;
|
||||
* 当 r_qty 为 0 时回退到 qty)
|
||||
* 在库天数 stockDays <- DATEDIFF(DAY, 入库日期, 出库日期);出库未发生 → null
|
||||
* 退货总库存 returnTotalStock <- jst_inventoryInfo.return_qty(横林二仓 wms_co_id=15033302)
|
||||
* 出库日期 outboundDate <- 业务库暂无 采购退货 表 → null
|
||||
* 出库件数 outboundQty <- 业务库暂无对应来源 → null
|
||||
*
|
||||
* 说明:dbo.* 引用会被 cloudDatabase.rewriteReportReplicaSql 改写为 rpt.*。
|
||||
* 仓库过滤条件以字面量 N'...' 写入 SQL,规避中文参数化时的 Unicode 问题。
|
||||
* 若业务库不可用或查询失败,接口兜底返回演示数据并附带 notice。
|
||||
*/
|
||||
|
||||
// 前端展示名(入参)与业务库真实仓库名映射
|
||||
const WAREHOUSE_DISPLAY = '销售退仓 横林二仓';
|
||||
const WAREHOUSE_DB = '简尚-横林二仓销退仓';
|
||||
// 横林二仓在 jst_inventoryInfo 中的仓编码(wh_id=2)
|
||||
const HENLIN2_WMS_CO_ID = 15033302;
|
||||
// 单页最大条数(一天 1000+ 条,留足余量)
|
||||
const MAX_PAGE_SIZE = 3000;
|
||||
|
||||
// 已知的副本缺口(前端 / 业务侧可见)
|
||||
const KNOWN_GAPS = [
|
||||
'退货总库存:期望"箱及仓位库存-横林二仓-客退临时上架仓"(仓位级),但 rpt 副本仅有 jst_inventoryInfo(仓+SKU 级),无仓位维度。当前按 wms_co_id=15033302 取 return_qty 作为仓级近似值。',
|
||||
'出库日期/出库件数:期望"聚水潭采购退货-横林二仓",但 rpt 副本中没有采购退货表(jst_purchase/jst_purchaseItem 是采购入库单据)。当前为 null。',
|
||||
'条数口径(2026-09-15 已修正):本表取「ERP 售后单 + 奇门退款单」两条同步管线的并集,去重键为(退货单号 + 商品编码)。实测 09-14 并集 1179 条 vs 聚水潭「销售退仓」1194 条(吻合度 98.7%),较旧的单表口径(797 条 / 66.8%)大幅修正。剩余 1~2% 差额来自副本未覆盖的 WMS 退仓单据(退仓单号 / 上架状态 / 箱号维度在 rpt 副本中不存在)。',
|
||||
];
|
||||
|
||||
function resolveWarehouseDbName(incoming) {
|
||||
const v = String(incoming || '').trim();
|
||||
if (!v || v === WAREHOUSE_DISPLAY) return WAREHOUSE_DB;
|
||||
return v; // 允许直接传入真实仓库名
|
||||
}
|
||||
|
||||
// 取"今天"(东八区)的 YYYY-MM-DD
|
||||
function todayInShanghai() {
|
||||
const now = new Date();
|
||||
const utcMs = now.getTime() + now.getTimezoneOffset() * 60000;
|
||||
const shMs = utcMs + 8 * 3600000;
|
||||
return new Date(shMs).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function normalizeDateInput(value) {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return '';
|
||||
// 接受 YYYY-MM-DD 或 YYYY/MM/DD
|
||||
const m = v.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
|
||||
if (!m) return '';
|
||||
const mm = String(m[2]).padStart(2, '0');
|
||||
const dd = String(m[3]).padStart(2, '0');
|
||||
return `${m[1]}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
// 演示数据(仅当业务库查询失败时兜底,便于核对页面布局与字段)
|
||||
const DEMO_ROWS = [
|
||||
{ returnInboundTime: '2026-09-12 15:38:03', expressNo: '76024510836500010001', supplier: '越子涵', warning: '正常', productCode: 'SZ001541N961V01', inboundQty: 1, stockDays: null, returnTotalStock: 0, outboundDate: null, outboundQty: null },
|
||||
{ returnInboundTime: '2026-09-12 15:37:49', expressNo: '730006113799', supplier: '艾米莉', warning: '正常', productCode: 'SJ004831N1172V01', inboundQty: 1, stockDays: null, returnTotalStock: 0, outboundDate: null, outboundQty: null },
|
||||
{ returnInboundTime: '2026-09-11 10:20:00', expressNo: 'SF2025514008294', supplier: '常州苗苗', warning: '黄色预警', productCode: 'SJZ000316N1253V01', inboundQty: 12, stockDays: null, returnTotalStock: 86, outboundDate: null, outboundQty: null },
|
||||
{ returnInboundTime: '2026-09-10 09:12:00', expressNo: 'SF1234567890123', supplier: '横林家居厂', warning: '红色预警', productCode: 'HL20240001', inboundQty: 120, stockDays: null, returnTotalStock: 320, outboundDate: null, outboundQty: null },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 数据来源(2026-09-15 修正):两条同步管线取并集
|
||||
// ---------------------------------------------------------------------------
|
||||
// 副本里「退货入库」其实有两条互相独立的同步管线,且数据几乎不重叠:
|
||||
// ① ERP 售后单:dbo.jst_returnAndRefundInfo × dbo.jst_returnAndRefundItem(状态以 WaitConfirm 为主)
|
||||
// ② 奇门退款单:dbo.jst_qmcust_refundlist × dbo.jst_qmcust_refundlist_item(状态以 Confirmed 为主)
|
||||
// 实测 2026-09-14:① 797 条、② 497 条,仅 115 条重叠(as_id + SKU 完全一致),并集 1179 条;
|
||||
// 聚水潭「销售退仓」界面当天 1194 条 → 吻合度 98.7%(只用 ① 仅 66.8%)。
|
||||
// 因此统一取并集,去重键 = (退货单号, 商品编码),同键优先取 ERP 售后单(字段更全)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildSourceCte(date) {
|
||||
const mainDateClause = date ? `AND CAST(it.receive_date AS DATE) = @date` : '';
|
||||
const qmDateClause = date ? `AND CAST(qit.receive_date AS DATE) = @date` : '';
|
||||
return `
|
||||
WITH src AS (
|
||||
SELECT
|
||||
CONVERT(NVARCHAR(50), i.as_id) AS as_id,
|
||||
LTRIM(RTRIM(CONVERT(NVARCHAR(120), it.sku_id))) AS product_code,
|
||||
ISNULL(NULLIF(it.r_qty, 0), it.qty) AS inbound_qty,
|
||||
COALESCE(it.receive_date, i.as_date) AS inbound_time,
|
||||
i.l_id AS express_no,
|
||||
1 AS src_rank,
|
||||
N'ERP售后单' AS src_label
|
||||
FROM dbo.jst_returnAndRefundInfo i
|
||||
JOIN dbo.jst_returnAndRefundItem it ON it.as_id = i.as_id
|
||||
WHERE i.warehouse = N'${WAREHOUSE_DB}'
|
||||
AND it.sku_id IS NOT NULL
|
||||
AND LTRIM(RTRIM(CONVERT(NVARCHAR(120), it.sku_id))) <> N''
|
||||
${mainDateClause}
|
||||
UNION ALL
|
||||
SELECT
|
||||
CONVERT(NVARCHAR(50), q.as_id),
|
||||
LTRIM(RTRIM(CONVERT(NVARCHAR(120), qit.sku_id))),
|
||||
ISNULL(NULLIF(qit.r_qty, 0), qit.qty),
|
||||
COALESCE(qit.receive_date, q.as_date),
|
||||
q.l_id,
|
||||
2,
|
||||
N'奇门退款单'
|
||||
FROM dbo.jst_qmcust_refundlist q
|
||||
JOIN dbo.jst_qmcust_refundlist_item qit
|
||||
ON CONVERT(NVARCHAR(50), q.as_id) = CONVERT(NVARCHAR(50), qit.as_id)
|
||||
WHERE q.warehouse = N'${WAREHOUSE_DB}'
|
||||
AND qit.sku_id IS NOT NULL
|
||||
AND LTRIM(RTRIM(CONVERT(NVARCHAR(120), qit.sku_id))) <> N''
|
||||
${qmDateClause}
|
||||
),
|
||||
dedup AS (
|
||||
SELECT as_id, product_code, inbound_qty, inbound_time, express_no, src_label
|
||||
FROM (
|
||||
SELECT src.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY src.as_id, UPPER(src.product_code)
|
||||
ORDER BY src.src_rank
|
||||
) AS rn
|
||||
FROM src
|
||||
) ranked
|
||||
WHERE ranked.rn = 1
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const FROM_JOINS = `
|
||||
FROM dedup u
|
||||
LEFT JOIN dbo.jst_sku sk ON sk.sku_id = u.product_code
|
||||
`;
|
||||
|
||||
const SELECT_COLUMNS = `
|
||||
CONVERT(NVARCHAR(19), u.inbound_time, 120) AS [returnInboundTime],
|
||||
u.express_no AS [expressNo],
|
||||
sk.supplier_name AS [supplier],
|
||||
NULLIF(LTRIM(RTRIM(COALESCE(ct.alert, N''))), N'') AS [warning],
|
||||
u.product_code AS [productCode],
|
||||
u.inbound_qty AS [inboundQty],
|
||||
ISNULL(inv.return_qty, 0) AS [returnTotalStock],
|
||||
NULL AS [outboundDate],
|
||||
NULL AS [outboundQty],
|
||||
u.src_label AS [dataSource]
|
||||
`;
|
||||
|
||||
const OUTER_APPLIES = `
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 alert
|
||||
FROM dbo.ope_clear_task ct
|
||||
WHERE ct.clear_skuoid = u.product_code
|
||||
ORDER BY ct.stat_month DESC
|
||||
) ct
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 return_qty
|
||||
FROM dbo.jst_inventoryInfo inv
|
||||
WHERE inv.sku_id = u.product_code AND inv.wms_co_id = ${HENLIN2_WMS_CO_ID}
|
||||
) inv
|
||||
`;
|
||||
|
||||
// 关键字过滤:与页面上的「商品编码 / 快递单号」搜索保持一致
|
||||
const buildKeywordClause = (keyword) => (
|
||||
keyword ? `AND (u.product_code LIKE @keyword OR u.express_no LIKE @keyword)` : ''
|
||||
);
|
||||
|
||||
function buildListSql({ offset, limit, date, keyword }) {
|
||||
return `
|
||||
${buildSourceCte(date)}
|
||||
SELECT ${SELECT_COLUMNS}
|
||||
${FROM_JOINS}
|
||||
${OUTER_APPLIES}
|
||||
WHERE 1 = 1
|
||||
${buildKeywordClause(keyword)}
|
||||
ORDER BY u.inbound_time DESC, u.as_id DESC
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY
|
||||
`;
|
||||
}
|
||||
|
||||
function buildCountSql({ date, keyword }) {
|
||||
return `
|
||||
${buildSourceCte(date)}
|
||||
SELECT COUNT(*) AS [total]
|
||||
FROM dedup u
|
||||
WHERE 1 = 1
|
||||
${buildKeywordClause(keyword)}
|
||||
`;
|
||||
}
|
||||
|
||||
// 该仓「最新一条明细收货日」——用于前端在"今天还没同步到"时回退到最新有数据的日期
|
||||
function buildLatestDateSql() {
|
||||
return `
|
||||
${buildSourceCte('')}
|
||||
SELECT CONVERT(varchar(10), MAX(u.inbound_time), 120) AS [latestDate]
|
||||
FROM dedup u
|
||||
`;
|
||||
}
|
||||
|
||||
async function fetchLatestDate() {
|
||||
try {
|
||||
const rows = await cloudDb.query(buildLatestDateSql(), {}, { requestTimeout: 180000 });
|
||||
return rows?.[0]?.latestDate || null;
|
||||
} catch (error) {
|
||||
return null; // 单独失败不影响主列表
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRow(row = {}) {
|
||||
return {
|
||||
returnInboundTime: row.returnInboundTime ?? null,
|
||||
expressNo: row.expressNo ?? null,
|
||||
supplier: row.supplier ?? null,
|
||||
warning: row.warning ?? null,
|
||||
productCode: row.productCode ?? null,
|
||||
inboundQty: row.inboundQty === null || row.inboundQty === undefined ? 0 : Number(row.inboundQty),
|
||||
// 在库天数:当前副本无 采购退货 来源,固定为 null(有出库日期后再启用 DATEDIFF)
|
||||
stockDays: null,
|
||||
returnTotalStock: (row.returnTotalStock === null || row.returnTotalStock === undefined) ? null : Number(row.returnTotalStock),
|
||||
outboundDate: null,
|
||||
outboundQty: null,
|
||||
// 并集口径下的数据来源:ERP售后单 / 奇门退款单(便于与聚水潭逐条核对)
|
||||
dataSource: row.dataSource || null,
|
||||
};
|
||||
}
|
||||
|
||||
// 统一的查询口径解析:仓库展示名、业务库仓库名、日期(默认今天,'all'/'' = 不限)、关键字
|
||||
function resolveListQuery(params = {}) {
|
||||
const displayWarehouse = params.warehouse || WAREHOUSE_DISPLAY;
|
||||
const warehouse = resolveWarehouseDbName(displayWarehouse);
|
||||
|
||||
// 日期口径:默认今天(东八区);传 date='' 或 'all' 表示不限日期
|
||||
const rawDate = params.date === undefined ? todayInShanghai() : String(params.date || '').trim();
|
||||
const date = (rawDate === '' || rawDate.toLowerCase() === 'all') ? '' : normalizeDateInput(rawDate);
|
||||
|
||||
const keyword = String(params.keyword || '').trim();
|
||||
return { displayWarehouse, warehouse, date, keyword };
|
||||
}
|
||||
|
||||
function buildSqlParams({ date, keyword }, extra = {}) {
|
||||
const sqlParams = { ...extra };
|
||||
if (date) sqlParams.date = date;
|
||||
if (keyword) sqlParams.keyword = `%${keyword}%`;
|
||||
return sqlParams;
|
||||
}
|
||||
|
||||
async function getReturnInventoryWarningList(params = {}) {
|
||||
const { displayWarehouse, date, keyword } = resolveListQuery(params);
|
||||
const page = Math.max(1, parseInt(params.page, 10) || 1);
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(params.pageSize, 10) || 500));
|
||||
const offset = (page - 1) * pageSize;
|
||||
const dateBasis = 'receive_date';
|
||||
|
||||
try {
|
||||
const [rows, countRows, latestDate] = await Promise.all([
|
||||
cloudDb.query(
|
||||
buildListSql({ offset, limit: pageSize, date, keyword }),
|
||||
buildSqlParams({ date, keyword }, { offset, limit: pageSize }),
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
cloudDb.query(buildCountSql({ date, keyword }), buildSqlParams({ date, keyword }), { requestTimeout: 180000 }),
|
||||
fetchLatestDate(),
|
||||
]);
|
||||
|
||||
const normalized = (rows || []).map(normalizeRow).filter((row) => row.productCode);
|
||||
const total = Number(countRows?.[0]?.total ?? normalized.length);
|
||||
|
||||
return {
|
||||
rows: normalized,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
warehouse: displayWarehouse,
|
||||
dateBasis,
|
||||
date: date || null,
|
||||
latestDate,
|
||||
dateIsEmpty: total === 0,
|
||||
isDemo: false,
|
||||
gaps: KNOWN_GAPS,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error?.message || '未知错误';
|
||||
return {
|
||||
rows: DEMO_ROWS.map((row) => ({ ...row })),
|
||||
total: DEMO_ROWS.length,
|
||||
page,
|
||||
pageSize,
|
||||
warehouse: displayWarehouse,
|
||||
dateBasis,
|
||||
date: date || null,
|
||||
latestDate: null,
|
||||
dateIsEmpty: true,
|
||||
isDemo: true,
|
||||
notice: `业务库查询失败,当前展示演示数据。原始错误:${message}`,
|
||||
gaps: KNOWN_GAPS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 导出
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 导出时按批拉取,避免一次 OFFSET/FETCH 过大导致副本查询超时
|
||||
const EXPORT_PAGE_SIZE = 2000;
|
||||
const EXPORT_MAX_ROWS = 50000;
|
||||
|
||||
// 与页面表头一一对应
|
||||
const EXPORT_COLUMNS = [
|
||||
{ title: '退货入库时间', key: 'returnInboundTime' },
|
||||
{ title: '快递单号', key: 'expressNo' },
|
||||
{ title: '供应商', key: 'supplier' },
|
||||
{ title: '预警', key: 'warning' },
|
||||
{ title: '商品编码', key: 'productCode' },
|
||||
{ title: '入库件数', key: 'inboundQty' },
|
||||
{ title: '在库天数', key: 'stockDays' },
|
||||
{ title: '退货总库存', key: 'returnTotalStock' },
|
||||
{ title: '出库日期', key: 'outboundDate' },
|
||||
{ title: '出库件数', key: 'outboundQty' },
|
||||
{ title: '数据来源', key: 'dataSource' },
|
||||
];
|
||||
|
||||
/** 拉取当前筛选条件下的全部明细(分页拉取,最多 EXPORT_MAX_ROWS 行) */
|
||||
async function getReturnInventoryWarningExportRows(params = {}) {
|
||||
const { displayWarehouse, date, keyword } = resolveListQuery(params);
|
||||
const maxRows = Math.max(1, parseInt(params.limit, 10) || EXPORT_MAX_ROWS);
|
||||
|
||||
const countPromise = cloudDb
|
||||
.query(buildCountSql({ date, keyword }), buildSqlParams({ date, keyword }), { requestTimeout: 180000 })
|
||||
.catch(() => null);
|
||||
|
||||
const rows = [];
|
||||
let page = 1;
|
||||
|
||||
while (rows.length < maxRows) {
|
||||
const offset = (page - 1) * EXPORT_PAGE_SIZE;
|
||||
const limit = Math.min(EXPORT_PAGE_SIZE, maxRows - rows.length);
|
||||
|
||||
const batch = await cloudDb.query(
|
||||
buildListSql({ offset, limit, date, keyword }),
|
||||
buildSqlParams({ date, keyword }, { offset, limit }),
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const normalized = (batch || []).map(normalizeRow).filter((row) => row.productCode);
|
||||
rows.push(...normalized);
|
||||
|
||||
if (!batch || batch.length < limit) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
const countRows = await countPromise;
|
||||
const total = Number(countRows?.[0]?.total ?? rows.length);
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
truncated: total > rows.length,
|
||||
warehouse: displayWarehouse,
|
||||
date: date || null,
|
||||
keyword,
|
||||
};
|
||||
}
|
||||
|
||||
/** 组装导出数据集(供 api 层渲染成 xlsx / csv) */
|
||||
async function buildReturnInventoryWarningExport(params = {}) {
|
||||
const { rows, total, warehouse, date, keyword, truncated } = await getReturnInventoryWarningExportRows(params);
|
||||
|
||||
return {
|
||||
fileName: `横林二仓当日退货入库明细_${date || '全部日期'}${keyword ? `_${keyword}` : ''}`,
|
||||
sheets: [{
|
||||
name: '退货入库明细',
|
||||
header: EXPORT_COLUMNS.map((column) => column.title),
|
||||
rows: rows.map((row) => EXPORT_COLUMNS.map((column) => row[column.key])),
|
||||
}],
|
||||
totalRows: rows.length,
|
||||
matchedTotal: total,
|
||||
truncated,
|
||||
warehouse,
|
||||
date,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getReturnInventoryWarningList,
|
||||
getReturnInventoryWarningExportRows,
|
||||
buildReturnInventoryWarningExport,
|
||||
DEFAULT_WAREHOUSE: WAREHOUSE_DISPLAY,
|
||||
todayInShanghai,
|
||||
};
|
||||
@ -1,128 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 横林二仓客退「退货入库核销 + 每日库存监控」本地表结构
|
||||
* --------------------------------------------------------------------------
|
||||
* 为什么需要本地表:
|
||||
* 业务库(JSReportReplica)只有退货单据的"事实",没有"核销状态"。
|
||||
* 而本需求的「状态(在库/已出库/已报废)」「出库日期」「差异人工确认结果」
|
||||
* 是每天比对后累积出来的状态,必须落库持久化,否则每天重算会丢历史。
|
||||
*
|
||||
* 存放位置:系统库(LOCAL_DB_*,默认 shujuku2_system)
|
||||
*/
|
||||
|
||||
const database = require('../config/database');
|
||||
|
||||
const SCHEMA_SQL = [
|
||||
// 1. 核心:退货入库主数据(一单一行 = 一个退货单的一个商品行)
|
||||
`CREATE TABLE IF NOT EXISTS return_monitor_inbound (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
|
||||
return_no VARCHAR(120) NOT NULL COMMENT '退货单号(聚水潭售后单号 as_id)',
|
||||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码(sku_id)',
|
||||
sku_name VARCHAR(255) NULL COMMENT '商品名称',
|
||||
supplier VARCHAR(255) NULL COMMENT '供应商',
|
||||
qty INT NOT NULL DEFAULT 0 COMMENT '数量(入库件数)',
|
||||
inbound_date DATE NOT NULL COMMENT '入库日期(取单据/收货日期)',
|
||||
express_no VARCHAR(120) NULL COMMENT '快递单号',
|
||||
order_no VARCHAR(120) NULL COMMENT '原订单号',
|
||||
defective_qty INT NULL COMMENT '来源单据的不良品数量(如有)',
|
||||
status VARCHAR(16) NOT NULL DEFAULT '在库' COMMENT '状态:在库/已出库/已报废',
|
||||
outbound_date DATE NULL COMMENT '出库日期',
|
||||
writeoff_mode VARCHAR(20) NOT NULL DEFAULT 'none' COMMENT '核销方式:none/auto_clear/fifo/manual/mark',
|
||||
remark VARCHAR(500) NULL COMMENT '备注',
|
||||
first_seen_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '首次同步时间',
|
||||
last_synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '最近同步时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_rm_inbound_line (warehouse_key, return_no, product_code, inbound_date),
|
||||
KEY idx_rm_inbound_status (warehouse_key, status, inbound_date),
|
||||
KEY idx_rm_inbound_code (warehouse_key, product_code),
|
||||
KEY idx_rm_inbound_date (warehouse_key, inbound_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-退货入库主数据(核销状态)'`,
|
||||
|
||||
// 2. 现有库存每日快照(按商品编码)
|
||||
`CREATE TABLE IF NOT EXISTS return_monitor_stock (
|
||||
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
|
||||
stat_date DATE NOT NULL COMMENT '快照日期',
|
||||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
|
||||
stock_qty INT NOT NULL DEFAULT 0 COMMENT '现有库存件数',
|
||||
synced_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '同步时间',
|
||||
PRIMARY KEY (warehouse_key, stat_date, product_code),
|
||||
KEY idx_rm_stock_code (warehouse_key, product_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-现有库存每日快照'`,
|
||||
|
||||
// 3. 差异清单(部分出库,需人工确认)
|
||||
`CREATE TABLE IF NOT EXISTS return_monitor_discrepancy (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
|
||||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
|
||||
supplier VARCHAR(255) NULL COMMENT '供应商',
|
||||
inbound_total INT NOT NULL DEFAULT 0 COMMENT '该编码在库入库总件数',
|
||||
stock_qty INT NOT NULL DEFAULT 0 COMMENT '现有库存件数',
|
||||
diff_qty INT NOT NULL DEFAULT 0 COMMENT '推定已出库件数 = 在库入库 - 现有库存',
|
||||
fifo_plan_json MEDIUMTEXT NULL COMMENT 'FIFO 建议核销明细(JSON,仅前若干条用于展示)',
|
||||
status VARCHAR(16) NOT NULL DEFAULT '待确认' COMMENT '状态:待确认/已确认/已忽略',
|
||||
resolution VARCHAR(20) NULL COMMENT '处理方式:fifo/manual',
|
||||
first_seen_date DATE NOT NULL COMMENT '首次进入差异清单日期',
|
||||
last_seen_date DATE NOT NULL COMMENT '最近一次仍有差异的日期',
|
||||
resolved_by VARCHAR(100) NULL COMMENT '处理人',
|
||||
resolved_at DATETIME NULL COMMENT '处理时间',
|
||||
remark VARCHAR(500) NULL COMMENT '备注',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_rm_discrepancy_code (warehouse_key, product_code),
|
||||
KEY idx_rm_discrepancy_status (warehouse_key, status, last_seen_date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-部分出库差异清单'`,
|
||||
|
||||
// 4. 同步/比对日志
|
||||
`CREATE TABLE IF NOT EXISTS return_monitor_job_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
warehouse_key VARCHAR(40) NOT NULL DEFAULT 'henlin2' COMMENT '仓库标识',
|
||||
biz_date DATE NOT NULL COMMENT '业务日期',
|
||||
trigger_type VARCHAR(20) NOT NULL DEFAULT 'manual' COMMENT '触发方式:manual/cron/api',
|
||||
inbound_rows INT NOT NULL DEFAULT 0 COMMENT '本次同步的入库明细行数',
|
||||
stock_rows INT NOT NULL DEFAULT 0 COMMENT '本次同步的库存行数',
|
||||
cleared_rows INT NOT NULL DEFAULT 0 COMMENT '整批核销(已出库)行数',
|
||||
discrepancy_rows INT NOT NULL DEFAULT 0 COMMENT '差异编码数',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'success' COMMENT '状态:success/failed',
|
||||
error_message VARCHAR(1000) NULL COMMENT '错误信息',
|
||||
elapsed_ms INT NOT NULL DEFAULT 0 COMMENT '耗时(ms)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_rm_job_log_date (warehouse_key, biz_date, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='横林二仓客退-同步与比对日志'`,
|
||||
];
|
||||
|
||||
// 兼容早期建表:把 TEXT 的 fifo_plan_json 放宽为 MEDIUMTEXT
|
||||
const COLUMN_MIGRATIONS = [
|
||||
[
|
||||
'return_monitor_discrepancy',
|
||||
'fifo_plan_json',
|
||||
"ALTER TABLE return_monitor_discrepancy MODIFY COLUMN fifo_plan_json MEDIUMTEXT NULL COMMENT 'FIFO 建议核销明细(JSON,仅前若干条用于展示)'",
|
||||
'mediumtext',
|
||||
],
|
||||
];
|
||||
|
||||
async function applyColumnMigrations() {
|
||||
for (const [table, column, alterSql, expectedType] of COLUMN_MIGRATIONS) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT DATA_TYPE AS d FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1`,
|
||||
[table, column]
|
||||
);
|
||||
const current = String(rows?.[0]?.d || '').toLowerCase();
|
||||
if (current === expectedType) continue;
|
||||
await database.local.query(alterSql);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureReturnMonitorSchema() {
|
||||
for (const sql of SCHEMA_SQL) {
|
||||
await database.local.query(sql);
|
||||
}
|
||||
await applyColumnMigrations();
|
||||
}
|
||||
|
||||
module.exports = { ensureReturnMonitorSchema };
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,160 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 客退数据监控 —— 仓库注册表
|
||||
* --------------------------------------------------------------------------
|
||||
* 每个仓库一张「客退数据监控」看板,页面结构完全一致,差异只有三件事:
|
||||
* 1. 库存取自哪个仓(jst_inventoryInfo.wms_co_id);
|
||||
* 2. 「客退临时上架仓」的库存在哪个自定义库存位上;
|
||||
* 3. 退货单表(售后单 / 奇门退款单)里的销退仓名称(仅「最早入库时间」口径用)。
|
||||
*
|
||||
* 关于第 2 点——2026-09-17 用户给出了每个仓在聚水潭里的**准确仓位名**,并明确
|
||||
* 「看板数据取自该仓位」。聚水潭给每个仓库单独开「自定义库存位」来做客退上架仓,
|
||||
* **各仓用的位不一样**(2026-09-16 全库核对结论),所以两个字段要分开写:
|
||||
* · returnBinName = 用户口径的聚水潭仓位名,写进页面/导出的「数据来源」,便于核对;
|
||||
* · returnSlot = 副本里真正取数的那一列,必须与上面的仓位名一一对应。
|
||||
*
|
||||
* 仓位名 → 库存位 对照(2026-09-27 复测,条件 ISNULL(col,0) > 0):
|
||||
* 箱及仓位库存 · 简尚-崔桥工厂仓 · 崔桥工厂客退临时上架仓 customize_qty_1 349 编码 / 507 件
|
||||
* 箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓 customize_qty_1 75 编码 / 362 件
|
||||
* 箱及仓位库存 · 简尚-睢宁仓 · 睢宁客退上架仓 customize_qty_1 98 编码 / 243 件
|
||||
* 箱及仓位库存 · 简尚-山东青岗集仓 · 青岗集客退上架仓 customize_qty_2 403 编码 / 912 件
|
||||
* 箱及仓位库存 · 简尚-河南洛宁仓 · 河南洛宁客退上架仓 三位全为 0(无数据)
|
||||
*
|
||||
* ── 2026-09-27 重要更新:崔桥工厂仓的客退库存已经同步进副本了 ──
|
||||
* 09-25 首次切换时该仓客退位只有 2 编码 / 2 件(当时判定「未同步进副本」),
|
||||
* 09-26 起 IT 侧补上了同步:09-26 当天写入 317 编码 / 460 件,09-27 又补 31 编码 / 46 件,
|
||||
* 09-27 10:11 实测 349 编码 / 507 件(= 副本 c1 合计,页面与副本完全对得上)。
|
||||
* ⚠ 所以下面「崔桥必然近乎空白 / 只能靠导入」的旧结论已经作废,不要再拿它当依据。
|
||||
* 现在这张看板能正常出数,**不需要**用导入功能兜底。
|
||||
*
|
||||
* ⚠ 崔桥工厂仓(15038426)是 2026-09-25 用户指定替换横林二仓的。复测结论:
|
||||
* 副本里该仓客退位只有 2 编码 / 2 件,但退货单量很大(ERP 售后单 3,115 条 +
|
||||
* 奇门退款单 1,812 条,warehouse='简尚-崔桥工厂仓销退仓')——即「退货有,客退上架库存
|
||||
* 没同步进副本」。全库 218 张表里没有任何「销退仓 / 客退仓 / 上架仓」命名的独立仓
|
||||
* (jst_warehouseInfo 里 name 含「退」的为 0),销退仓只是退货单据里的 warehouse 文本值。
|
||||
* 所以崔桥这张看板在当前副本下**必然近乎空白**,要出数只能:① 让 IT 把该仓客退上架仓
|
||||
* 的库存同步进 jst_inventoryInfo 的自定义库存位;② 用页面「导入」上传聚水潭导出文件。
|
||||
*
|
||||
* ⚠ 副本里**没有任何仓位维度**(2026-09-17 复核:218 张表 / 5,496 个字段中,列名含
|
||||
* bin / box / position / location / shelf / slot 的为 0 个;jst_sku.bin、jst_sku.other_bin、
|
||||
* jst_returnAndRefundItem.box_id、jst_packInventoryInfo.bin 四个疑似字段 100% 为空)。
|
||||
* 所以「按仓位名取数」在副本上做不到,只能用该仓位绑定的自定义库存位近似;
|
||||
* 要逐箱还原(含真实的箱上架时间),必须导入聚水潭导出的文件。
|
||||
*
|
||||
* 河南洛宁仓(15539084)当天客退库存为 0:仓库存在、也有退货单,但三个自定义库存位
|
||||
* 全为 0(只有 qty 总库存 83,273 件)。看板照样建,只是空表——页面会提示
|
||||
* 「该仓当前没有客退在库数据」,等聚水潭侧同步进来后自动有内容。
|
||||
*/
|
||||
|
||||
// 客退临时上架仓在 jst_inventoryInfo 里的候选列
|
||||
const RETURN_SLOT_COLUMNS = ['customize_qty_1', 'customize_qty_2', 'customize_qty_3'];
|
||||
|
||||
const WAREHOUSE_LIST = [
|
||||
{
|
||||
key: 'cuiqiao',
|
||||
// 页面标题 / 侧边栏名称(2026-09-25 用户指定:原「横林二仓客退数据监控」改为崔桥工厂仓)
|
||||
title: '崔桥工厂仓客退数据监控',
|
||||
shortName: '崔桥工厂仓',
|
||||
// 仓库主表 jst_warehouseInfo.wms_co_id
|
||||
wmsCoId: 15038426,
|
||||
warehouseName: '简尚-崔桥工厂仓',
|
||||
// 退货单(售后单 / 奇门退款单)里的销退仓名称,仅 firstInbound 口径用到
|
||||
returnWarehouseDb: '简尚-崔桥工厂仓销退仓',
|
||||
// 聚水潭「箱及仓位库存」里对应的仓位名(2026-09-25 用户指定,页面/导出直接显示它)
|
||||
returnBinName: '崔桥工厂客退临时上架仓',
|
||||
// 客退临时上架仓占用的自定义库存位
|
||||
returnSlot: 'customize_qty_1',
|
||||
// 2026-09-27 复测:客退位已同步进副本 —— 349 编码 / 507 件(c1 合计),
|
||||
// 退货单口径:ERP 售后单 3,115 条 + 奇门退款单 1,812 条(warehouse='简尚-崔桥工厂仓销退仓')。
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'subei',
|
||||
title: '苏北客退数据监控',
|
||||
shortName: '苏北',
|
||||
wmsCoId: 15038347,
|
||||
warehouseName: '简尚-新苏北仓',
|
||||
returnWarehouseDb: '简尚-新苏北仓销退仓',
|
||||
// 2026-09-17 用户指定:苏北客退数据取自「新苏北客退上架仓」
|
||||
returnBinName: '新苏北客退上架仓',
|
||||
returnSlot: 'customize_qty_1',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'suining',
|
||||
title: '睢宁客退数据监控',
|
||||
shortName: '睢宁',
|
||||
wmsCoId: 15032603,
|
||||
warehouseName: '简尚-睢宁仓',
|
||||
returnWarehouseDb: '简尚-睢宁仓销退仓',
|
||||
// 2026-09-17 用户指定:睢宁客退数据取自「睢宁客退上架仓」
|
||||
returnBinName: '睢宁客退上架仓',
|
||||
returnSlot: 'customize_qty_1',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'shandong',
|
||||
title: '山东客退数据监控',
|
||||
shortName: '山东',
|
||||
wmsCoId: 15137523,
|
||||
warehouseName: '简尚-山东青岗集仓',
|
||||
returnWarehouseDb: '简尚-山东青岗集仓销退仓',
|
||||
// 2026-09-17 用户指定:山东客退数据取自「青岗集客退上架仓」
|
||||
returnBinName: '青岗集客退上架仓',
|
||||
// ★ 山东用的是第 2 个自定义库存位,与横林/苏北/睢宁不同
|
||||
returnSlot: 'customize_qty_2',
|
||||
hasReturnStock: true,
|
||||
},
|
||||
{
|
||||
key: 'henan',
|
||||
title: '河南客退数据监控',
|
||||
shortName: '河南',
|
||||
wmsCoId: 15539084,
|
||||
warehouseName: '简尚-河南洛宁仓',
|
||||
returnWarehouseDb: '简尚-河南洛宁仓销退仓',
|
||||
// 2026-09-17 用户指定:河南客退数据取自「河南洛宁客退上架仓」
|
||||
returnBinName: '河南洛宁客退上架仓',
|
||||
// 该仓三个自定义库存位全为 0,河南洛宁客退上架仓在副本里取不到数据
|
||||
returnSlot: 'customize_qty_1',
|
||||
// 当前该仓客退库存为 0(仓库与退货单都在,只是当天没有客退在库)
|
||||
hasReturnStock: false,
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_WAREHOUSE_KEY = 'cuiqiao';
|
||||
|
||||
const WAREHOUSE_MAP = new Map(WAREHOUSE_LIST.map((item) => [item.key, item]));
|
||||
|
||||
/** 归一化仓库标识:认 key(henlin2 / subei / ...),也容忍直接传 wms_co_id */
|
||||
function resolveWarehouseKey(value) {
|
||||
const raw = String(value === null || value === undefined ? '' : value).trim();
|
||||
if (!raw) return DEFAULT_WAREHOUSE_KEY;
|
||||
const byKey = WAREHOUSE_MAP.get(raw.toLowerCase());
|
||||
if (byKey) return byKey.key;
|
||||
const byId = WAREHOUSE_LIST.find((item) => String(item.wmsCoId) === raw);
|
||||
return byId ? byId.key : DEFAULT_WAREHOUSE_KEY;
|
||||
}
|
||||
|
||||
/** 取仓库配置(永远返回一个有效配置,非法值回落到默认仓) */
|
||||
function resolveWarehouse(value) {
|
||||
return WAREHOUSE_MAP.get(resolveWarehouseKey(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面/导出文案里的「数据来源」,例如
|
||||
* 「箱及仓位库存 · 简尚-新苏北仓 · 新苏北客退上架仓」
|
||||
* 仓位名逐仓不同(用户 2026-09-17 口径),必须读注册表,不能写死。
|
||||
*/
|
||||
function getWarehouseStockLabel(warehouse) {
|
||||
return `箱及仓位库存 · ${warehouse.warehouseName} · ${warehouse.returnBinName}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WAREHOUSE_LIST,
|
||||
RETURN_SLOT_COLUMNS,
|
||||
DEFAULT_WAREHOUSE_KEY,
|
||||
resolveWarehouse,
|
||||
resolveWarehouseKey,
|
||||
getWarehouseStockLabel,
|
||||
};
|
||||
@ -1,275 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 聚水潭「箱及仓位库存 - {仓库} - 客退临时上架仓」导出文件导入
|
||||
* --------------------------------------------------------------------------
|
||||
* 用途:副本里没有箱/仓位维度,取不到聚水潭的「创建时间」,件数口径也可能不同。
|
||||
* 导入后按商品编码覆盖「创建时间 / 件数 / 供应商」,页面与导出即与聚水潭一致。
|
||||
*
|
||||
* 按仓库隔离:每个看板(横林二仓 / 苏北 / 睢宁 / 山东 / 河南)各自上传自己仓库的导出文件,
|
||||
* 落在 warehouse_key 下,互不覆盖。调用方通过 warehouseKey 传入。
|
||||
*
|
||||
* 文件要求:xlsx / xls / csv,表头需含「商品编码」(或 sku / 商品编号 / 货号)。
|
||||
* 「创建时间」「数量/件数」「供应商」列名会自动模糊识别,不强制要求。
|
||||
* 同一商品编码出现多行(多个箱/仓位)时:件数求和,创建时间取最早。
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const XLSX = require('xlsx');
|
||||
const database = require('../config/database');
|
||||
|
||||
// 与 returnStockWarehouseRegistry 的默认仓保持一致(不直接 require 以免循环依赖)
|
||||
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
|
||||
|
||||
const MAX_ROWS = 200000;
|
||||
|
||||
const CODE_PATTERN = /(商品编码|商品编号|sku|货号|^编码$)/i;
|
||||
const TIME_PATTERN = /(创建时间|创建日期|上架时间|入库时间|入库日期|首次上架)/i;
|
||||
const QTY_PATTERN = /(件数|数量|库存数量|可用库存|库存)/i;
|
||||
const SUPPLIER_PATTERN = /(供应商|供货商)/i;
|
||||
|
||||
const EXCLUDE_CODE_PATTERN = /(仓位|库位|货位|箱号|仓库)/i;
|
||||
|
||||
function parseDateTimeValue(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
// Excel 序列号:按本地时钟解释,避免出现 +8 小时偏移
|
||||
const days = Math.floor(value);
|
||||
const frac = value - days;
|
||||
const base = Date.UTC(1899, 11, 30);
|
||||
const utc = new Date(base + days * 86400000 + Math.round(frac * 86400000));
|
||||
return new Date(
|
||||
utc.getUTCFullYear(), utc.getUTCMonth(), utc.getUTCDate(),
|
||||
utc.getUTCHours(), utc.getUTCMinutes(), utc.getUTCSeconds()
|
||||
);
|
||||
}
|
||||
|
||||
const text = String(value).trim();
|
||||
if (!text) return null;
|
||||
|
||||
const direct = new Date(text.replace(/\//g, '-').replace(/^(\d{4})-(\d{1,2})-(\d{1,2})/, (_, y, m, d) => `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}`));
|
||||
if (!Number.isNaN(direct.getTime())) return direct;
|
||||
|
||||
const nums = text.match(/\d+/g);
|
||||
if (!nums || nums.length < 3) return null;
|
||||
const [y, m, d, hh = '0', mm = '0', ss = '0'] = nums;
|
||||
const built = new Date(Number(y), Number(m) - 1, Number(d), Number(hh), Number(mm), Number(ss));
|
||||
return Number.isNaN(built.getTime()) ? null : built;
|
||||
}
|
||||
|
||||
function parseNumberValue(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
const text = String(value).replace(/[,,\s]/g, '');
|
||||
if (!text) return null;
|
||||
const parsed = Number(text);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function findHeaderRow(matrix) {
|
||||
for (let i = 0; i < Math.min(matrix.length, 20); i += 1) {
|
||||
const cells = (matrix[i] || []).map((cell) => String(cell === null || cell === undefined ? '' : cell).trim());
|
||||
const hit = cells.some((cell) => CODE_PATTERN.test(cell) && !EXCLUDE_CODE_PATTERN.test(cell));
|
||||
if (hit) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function resolveColumns(headerCells) {
|
||||
const index = {
|
||||
code: -1, time: -1, qty: -1, supplier: -1,
|
||||
};
|
||||
headerCells.forEach((raw, i) => {
|
||||
const cell = String(raw === null || raw === undefined ? '' : raw).trim();
|
||||
if (!cell) return;
|
||||
if (index.code < 0 && CODE_PATTERN.test(cell) && !EXCLUDE_CODE_PATTERN.test(cell)) index.code = i;
|
||||
if (index.time < 0 && TIME_PATTERN.test(cell)) index.time = i;
|
||||
if (index.qty < 0 && QTY_PATTERN.test(cell)) index.qty = i;
|
||||
if (index.supplier < 0 && SUPPLIER_PATTERN.test(cell)) index.supplier = i;
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析文件为「按商品编码聚合」的记录
|
||||
*/
|
||||
function parseImportBuffer(buffer) {
|
||||
const workbook = XLSX.read(buffer, { type: 'buffer', cellDates: true });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
if (!sheetName) {
|
||||
throw new Error('文件里没有可读取的工作表');
|
||||
}
|
||||
const matrix = XLSX.utils.sheet_to_json(workbook.Sheets[sheetName], { header: 1, defval: null, blankrows: false });
|
||||
if (!matrix.length) {
|
||||
throw new Error('文件是空的');
|
||||
}
|
||||
|
||||
const headerIndex = findHeaderRow(matrix);
|
||||
if (headerIndex < 0) {
|
||||
throw new Error('找不到表头,请确认文件里有一列叫「商品编码」(或 sku / 商品编号 / 货号)');
|
||||
}
|
||||
|
||||
const columns = resolveColumns(matrix[headerIndex] || []);
|
||||
if (columns.code < 0) {
|
||||
throw new Error('找不到「商品编码」列');
|
||||
}
|
||||
|
||||
const aggregated = new Map();
|
||||
let skipped = 0;
|
||||
let withCreatedAt = 0;
|
||||
let withQty = 0;
|
||||
|
||||
for (let i = headerIndex + 1; i < matrix.length && aggregated.size < MAX_ROWS; i += 1) {
|
||||
const row = matrix[i] || [];
|
||||
const code = String(row[columns.code] === null || row[columns.code] === undefined
|
||||
? '' : row[columns.code]).trim();
|
||||
if (!code) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const time = columns.time >= 0 ? parseDateTimeValue(row[columns.time]) : null;
|
||||
const qty = columns.qty >= 0 ? parseNumberValue(row[columns.qty]) : null;
|
||||
const supplier = columns.supplier >= 0
|
||||
? String(row[columns.supplier] === null || row[columns.supplier] === undefined ? '' : row[columns.supplier]).trim()
|
||||
: '';
|
||||
|
||||
const existing = aggregated.get(code);
|
||||
if (!existing) {
|
||||
aggregated.set(code, {
|
||||
productCode: code,
|
||||
createdAt: time,
|
||||
inboundQty: qty === null ? null : qty,
|
||||
supplier,
|
||||
});
|
||||
} else {
|
||||
if (time && (!existing.createdAt || time < existing.createdAt)) existing.createdAt = time;
|
||||
if (qty !== null) {
|
||||
existing.inboundQty = (existing.inboundQty === null ? 0 : existing.inboundQty) + qty;
|
||||
}
|
||||
if (!existing.supplier && supplier) existing.supplier = supplier;
|
||||
}
|
||||
}
|
||||
|
||||
const records = Array.from(aggregated.values());
|
||||
records.forEach((record) => {
|
||||
if (record.createdAt) withCreatedAt += 1;
|
||||
if (record.inboundQty !== null) withQty += 1;
|
||||
});
|
||||
|
||||
return {
|
||||
records,
|
||||
sheetName,
|
||||
headerIndex,
|
||||
columns,
|
||||
totalRows: matrix.length - headerIndex - 1,
|
||||
skipped,
|
||||
withCreatedAt,
|
||||
withQty,
|
||||
};
|
||||
}
|
||||
|
||||
function toMysqlDateTime(value) {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} `
|
||||
+ `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入导入数据(按仓库整批替换:以该仓最新一次导出为准)
|
||||
* 只删/写当前仓库的行,不会动其它看板的导入数据。
|
||||
*/
|
||||
async function importStockWarning({ buffer, fileName, importedBy, warehouseKey }) {
|
||||
const parsed = parseImportBuffer(buffer);
|
||||
if (!parsed.records.length) {
|
||||
throw new Error('没有解析到任何有效的商品编码行');
|
||||
}
|
||||
|
||||
const key = String(warehouseKey || DEFAULT_WAREHOUSE_KEY).trim() || DEFAULT_WAREHOUSE_KEY;
|
||||
const batchId = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
|
||||
await database.local.query(
|
||||
'DELETE FROM return_stock_warning_import WHERE warehouse_key = ?',
|
||||
[key]
|
||||
);
|
||||
|
||||
const chunkSize = 500;
|
||||
for (let i = 0; i < parsed.records.length; i += chunkSize) {
|
||||
const chunk = parsed.records.slice(i, i + chunkSize);
|
||||
const values = chunk.map((record) => ([
|
||||
key,
|
||||
record.productCode,
|
||||
toMysqlDateTime(record.createdAt),
|
||||
record.inboundQty,
|
||||
record.supplier || null,
|
||||
batchId,
|
||||
]));
|
||||
await database.local.query(
|
||||
`INSERT INTO return_stock_warning_import
|
||||
(warehouse_key, product_code, created_at, inbound_qty, supplier, batch_id)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
created_at = VALUES(created_at),
|
||||
inbound_qty = VALUES(inbound_qty),
|
||||
supplier = VALUES(supplier),
|
||||
batch_id = VALUES(batch_id)`,
|
||||
[values]
|
||||
);
|
||||
}
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO return_stock_warning_import_batch
|
||||
(batch_id, warehouse_key, file_name, total_rows, matched_rows, with_created_at, with_qty, skipped_rows, imported_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
batchId,
|
||||
key,
|
||||
String(fileName || '').slice(0, 255),
|
||||
parsed.totalRows,
|
||||
parsed.records.length,
|
||||
parsed.withCreatedAt,
|
||||
parsed.withQty,
|
||||
parsed.skipped,
|
||||
importedBy || null,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
batchId,
|
||||
warehouseKey: key,
|
||||
fileName: String(fileName || ''),
|
||||
sheetName: parsed.sheetName,
|
||||
totalRows: parsed.totalRows,
|
||||
matchedRows: parsed.records.length,
|
||||
withCreatedAt: parsed.withCreatedAt,
|
||||
withQty: parsed.withQty,
|
||||
skippedRows: parsed.skipped,
|
||||
detectedColumns: {
|
||||
code: parsed.columns.code,
|
||||
time: parsed.columns.time,
|
||||
qty: parsed.columns.qty,
|
||||
supplier: parsed.columns.supplier,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 清除当前仓库的导入,回到「系统推算」口径(不影响其它看板) */
|
||||
async function clearStockWarningImport(warehouseKey) {
|
||||
const key = String(warehouseKey || DEFAULT_WAREHOUSE_KEY).trim() || DEFAULT_WAREHOUSE_KEY;
|
||||
await database.local.query('DELETE FROM return_stock_warning_import WHERE warehouse_key = ?', [key]);
|
||||
await database.local.query('DELETE FROM return_stock_warning_import_batch WHERE warehouse_key = ?', [key]);
|
||||
return { cleared: true, warehouseKey: key };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
importStockWarning,
|
||||
clearStockWarningImport,
|
||||
parseImportBuffer,
|
||||
};
|
||||
@ -1,130 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 退货库存预警(客退数据监控)本地表
|
||||
* --------------------------------------------------------------------------
|
||||
* 为什么需要本地表:
|
||||
* 报表副本(JSReportReplica)只有仓库级库存表 jst_inventoryInfo,
|
||||
* 没有聚水潭「箱及仓位库存」的箱/仓位维度,因此:
|
||||
* - 「创建时间」= 聚水潭该箱/仓位记录的上架时间,副本里根本没有这个字段;
|
||||
* - 「件数」也可能与聚水潭口径不同(副本只能按 return_qty + 客退自定义库存位近似)。
|
||||
* 要跟聚水潭页面 100% 对齐,唯一办法是把聚水潭导出的「箱及仓位库存」导入进来,
|
||||
* 按商品编码覆盖创建时间(可选覆盖件数),并持久化。
|
||||
*
|
||||
* 按仓库隔离(2026-09-16 新增):
|
||||
* 现在有横林二仓 / 苏北 / 睢宁 / 山东 / 河南 五张看板,每张上传的是各自仓库的聚水潭导出文件,
|
||||
* 同一份文件里同一个商品编码可能同时出现在多个仓;如果不按仓隔离,后导入的会把先导入的覆盖掉。
|
||||
* 因此两张表都加了 warehouse_key,主键从 (product_code) 改成 (warehouse_key, product_code)。
|
||||
*/
|
||||
|
||||
const database = require('../config/database');
|
||||
|
||||
const DEFAULT_WAREHOUSE_KEY = 'henlin2';
|
||||
|
||||
const SCHEMA_SQL = [
|
||||
// 1. 按「仓库 + 商品编码」的导入覆盖值(以聚水潭导出为准)
|
||||
`CREATE TABLE IF NOT EXISTS return_stock_warning_import (
|
||||
warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}' COMMENT '仓库标识(henlin2/subei/suining/shandong/henan)',
|
||||
product_code VARCHAR(120) NOT NULL COMMENT '商品编码',
|
||||
created_at DATETIME NULL COMMENT '聚水潭创建时间(上架时间)',
|
||||
inbound_qty INT NULL COMMENT '聚水潭件数(导入,可为空=不覆盖)',
|
||||
supplier VARCHAR(255) NULL COMMENT '聚水潭供应商(导入,可为空)',
|
||||
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (warehouse_key, product_code),
|
||||
KEY idx_rswi_batch (batch_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货库存预警-聚水潭导入覆盖值(按仓库+商品编码)'`,
|
||||
|
||||
// 2. 导入批次日志
|
||||
`CREATE TABLE IF NOT EXISTS return_stock_warning_import_batch (
|
||||
batch_id VARCHAR(64) NOT NULL COMMENT '导入批次',
|
||||
warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}' COMMENT '仓库标识',
|
||||
file_name VARCHAR(255) NULL COMMENT '文件名',
|
||||
total_rows INT NOT NULL DEFAULT 0 COMMENT '解析出的行数',
|
||||
matched_rows INT NOT NULL DEFAULT 0 COMMENT '成功写入的编码数',
|
||||
with_created_at INT NOT NULL DEFAULT 0 COMMENT '含创建时间的行数',
|
||||
with_qty INT NOT NULL DEFAULT 0 COMMENT '含件数的行数',
|
||||
skipped_rows INT NOT NULL DEFAULT 0 COMMENT '无商品编码被跳过的行数',
|
||||
imported_by VARCHAR(100) NULL COMMENT '导入人',
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间',
|
||||
PRIMARY KEY (batch_id),
|
||||
KEY idx_rswibatch_time (imported_at),
|
||||
KEY idx_rswibatch_wh (warehouse_key, imported_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='退货库存预警-聚水潭导入批次日志'`,
|
||||
];
|
||||
|
||||
/** 某张表上是否存在某列 */
|
||||
async function hasColumn(table, column) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
||||
[table, column]
|
||||
);
|
||||
return Number(rows?.[0]?.cnt || 0) > 0;
|
||||
}
|
||||
|
||||
/** 某张表的主键列(按顺序) */
|
||||
async function getPrimaryKeyColumns(table) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT COLUMN_NAME
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'
|
||||
ORDER BY ORDINAL_POSITION`,
|
||||
[table]
|
||||
);
|
||||
return (rows || []).map((row) => String(row.COLUMN_NAME || '').toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* 老库升级:给已存在的两张表补 warehouse_key,并把导入表主键改成 (warehouse_key, product_code)。
|
||||
* 幂等,每次启动都跑一遍,已经是新结构时不做任何事。
|
||||
*/
|
||||
async function migrateReturnStockWarningSchema() {
|
||||
// 逐列补:两张表都要有 warehouse_key
|
||||
const targets = ['return_stock_warning_import', 'return_stock_warning_import_batch'];
|
||||
for (const table of targets) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (!(await hasColumn(table, 'warehouse_key'))) {
|
||||
// 老数据全部归到默认仓(横林二仓)——它本来就是为横林二仓建的
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await database.local.query(
|
||||
`ALTER TABLE ${table}
|
||||
ADD COLUMN warehouse_key VARCHAR(32) NOT NULL DEFAULT '${DEFAULT_WAREHOUSE_KEY}'
|
||||
COMMENT '仓库标识' AFTER ${table === 'return_stock_warning_import' ? 'product_code' : 'batch_id'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 主键改造:老结构是 (product_code),需要换成 (warehouse_key, product_code)
|
||||
const pk = await getPrimaryKeyColumns('return_stock_warning_import');
|
||||
if (pk.length === 1 && pk[0] === 'PRODUCT_CODE') {
|
||||
await database.local.query(
|
||||
'ALTER TABLE return_stock_warning_import DROP PRIMARY KEY, ADD PRIMARY KEY (warehouse_key, product_code)'
|
||||
);
|
||||
}
|
||||
|
||||
// 批次表的仓库索引
|
||||
const idxRows = await database.local.query(
|
||||
`SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'return_stock_warning_import_batch'
|
||||
AND INDEX_NAME = 'idx_rswibatch_wh'`
|
||||
);
|
||||
if (Number(idxRows?.[0]?.cnt || 0) === 0) {
|
||||
await database.local.query(
|
||||
'ALTER TABLE return_stock_warning_import_batch ADD KEY idx_rswibatch_wh (warehouse_key, imported_at)'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureReturnStockWarningSchema() {
|
||||
for (const sql of SCHEMA_SQL) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await database.local.query(sql);
|
||||
}
|
||||
await migrateReturnStockWarningSchema();
|
||||
}
|
||||
|
||||
module.exports = { ensureReturnStockWarningSchema, migrateReturnStockWarningSchema };
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,248 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓库库存动销分析 —— 业务库取数
|
||||
*
|
||||
* 一次查询覆盖全部仓库 × 全部动销等级:
|
||||
* 库存件数:rpt.jst_inventoryInfo 实时库存(按 wms_co_id + sku_id 汇总)
|
||||
* 销量: rpt.jst_OrderItem × rpt.jst_Orders,按 send_date,排除 Delete/Cancelled
|
||||
* (与「每日进销存」的发货口径一致)
|
||||
* 日销: 销量 / 统计窗口天数 → 分档得到动销等级
|
||||
*
|
||||
* 只统计「有库存或有销量」的 SKU:库存为 0 且窗口内无销量的 SKU 已下架,
|
||||
* 计入「销售=0」会虚增分母,故整体剔除。
|
||||
*/
|
||||
|
||||
const { SALES_GRADE_RULES } = require('../config/stockSalesAnalysis');
|
||||
|
||||
/** mssql 不支持数组参数,按位展开成 @p0,@p1... */
|
||||
function buildInClause(values, prefix) {
|
||||
const params = {};
|
||||
const clause = values
|
||||
.map((v, i) => {
|
||||
const key = `${prefix}${i}`;
|
||||
params[key] = v;
|
||||
return `@${key}`;
|
||||
})
|
||||
.join(',');
|
||||
return { clause, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* 日销分档表达式:从高档往低档依次命中。
|
||||
* 最后一档「销售=0」由 `salesQty <= 0` 兜底(销量为 NULL 视同 0)。
|
||||
*/
|
||||
function buildGradeCase(salesExpr = 'ISNULL(b.salesQty, 0)', daysParam = '@days') {
|
||||
const branches = SALES_GRADE_RULES
|
||||
.filter((rule) => rule.lower > 0)
|
||||
.map((rule) => {
|
||||
const daily = `(${salesExpr} / ${daysParam})`;
|
||||
const lower = `${daily} >= ${rule.lower}`;
|
||||
const upper = rule.upper == null ? null : `${daily} < ${rule.upper}`;
|
||||
const cond = upper ? `${lower} AND ${upper}` : lower;
|
||||
return `WHEN ${cond} THEN '${rule.key}'`;
|
||||
});
|
||||
|
||||
return `
|
||||
CASE
|
||||
${branches.join('\n ')}
|
||||
WHEN ${salesExpr} > 0 THEN 'slow2'
|
||||
ELSE 'zero'
|
||||
END`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 每个仓库 × 每个动销等级的 SKU 数、库存件数、销量。
|
||||
* @returns {Promise<Array<{wmsCoId:string, gradeKey:string, skuCount:number, stockQty:number, salesQty:number, dailySales:number}>>}
|
||||
*/
|
||||
async function fetchGradeRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
|
||||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||||
throw new TypeError('fetchGradeRows 的第一个参数必须是业务库实例');
|
||||
}
|
||||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||||
if (!shipFrom || !shipTo || !days) return [];
|
||||
|
||||
const invIn = buildInClause(wmsCoIds, 'w');
|
||||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||||
const gradeCase = buildGradeCase();
|
||||
|
||||
const sqlText = `
|
||||
WITH inv AS (
|
||||
SELECT i.wms_co_id AS wmsCoId,
|
||||
i.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
|
||||
FROM rpt.jst_inventoryInfo i
|
||||
WHERE i.wms_co_id IN (${invIn.clause})
|
||||
GROUP BY i.wms_co_id, i.sku_id
|
||||
),
|
||||
ship AS (
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
oi.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||||
FROM rpt.jst_OrderItem oi
|
||||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||||
AND o.send_date >= @shipFrom
|
||||
AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
GROUP BY o.wms_co_id, oi.sku_id
|
||||
),
|
||||
base AS (
|
||||
SELECT u.wmsCoId,
|
||||
u.skuId,
|
||||
SUM(u.stockQty) AS stockQty,
|
||||
SUM(u.salesQty) AS salesQty
|
||||
FROM (
|
||||
SELECT wmsCoId, skuId, stockQty, CAST(0 AS DECIMAL(18, 2)) AS salesQty FROM inv
|
||||
UNION ALL
|
||||
SELECT wmsCoId, skuId, CAST(0 AS DECIMAL(18, 2)) AS stockQty, salesQty FROM ship
|
||||
) u
|
||||
GROUP BY u.wmsCoId, u.skuId
|
||||
),
|
||||
graded AS (
|
||||
SELECT b.wmsCoId,
|
||||
b.stockQty,
|
||||
b.salesQty,
|
||||
${gradeCase} AS gradeKey
|
||||
FROM base b
|
||||
WHERE ABS(ISNULL(b.stockQty, 0)) > 0.0001
|
||||
OR ABS(ISNULL(b.salesQty, 0)) > 0.0001
|
||||
)
|
||||
SELECT g.wmsCoId,
|
||||
g.gradeKey,
|
||||
COUNT(*) AS skuCount,
|
||||
CAST(SUM(ISNULL(g.stockQty, 0)) AS DECIMAL(18, 2)) AS stockQty,
|
||||
CAST(SUM(ISNULL(g.salesQty, 0)) AS DECIMAL(18, 2)) AS salesQty,
|
||||
CAST(SUM(ISNULL(g.salesQty, 0) / @days) AS DECIMAL(18, 4)) AS dailySales
|
||||
FROM graded g
|
||||
GROUP BY g.wmsCoId, g.gradeKey`;
|
||||
|
||||
const rows = await cloudDb.query(sqlText, {
|
||||
...invIn.params,
|
||||
...shipIn.params,
|
||||
shipFrom,
|
||||
shipTo,
|
||||
days,
|
||||
}, { requestTimeout: 180000 });
|
||||
|
||||
return rows.map((r) => ({
|
||||
wmsCoId: String(r.wmsCoId),
|
||||
gradeKey: String(r.gradeKey),
|
||||
skuCount: Number(r.skuCount) || 0,
|
||||
stockQty: Number(r.stockQty) || 0,
|
||||
salesQty: Number(r.salesQty) || 0,
|
||||
dailySales: Number(r.dailySales) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 「销售=0」档的 SKU 级明细(导出用)。
|
||||
* 口径与 fetchGradeRows 完全一致:同一分档表达式命中 gradeKey='zero',
|
||||
* 即统计窗口内销量 <= 0 且「有库存或有销量」的 SKU。
|
||||
*
|
||||
* 系统仓位取自 rpt.jst_packInventoryInfo.bin(jst_sku.bin 全为空、库存表无仓位字段)。
|
||||
* 每个仓位独立一行,库存数量按该仓位的实际 qty 统计(各仓位行之和 = SKU 总库存):
|
||||
* - 有仓位数据的 SKU:按 (wms_co_id, sku_id, bin) 展开,qty=0 的仓位行剔除;
|
||||
* - 无仓位数据的 SKU(仓位表未覆盖的仓库):保留一行,仓位为空、库存取 SKU 总库存,避免丢数。
|
||||
* @returns {Promise<Array<{wmsCoId:string, skuId:string, skuName:string, binName:string, stockQty:number, salesQty:number}>>}
|
||||
*/
|
||||
async function fetchZeroSaleSkuRows(cloudDb, wmsCoIds, { shipFrom, shipTo, days }) {
|
||||
if (!cloudDb || typeof cloudDb.query !== 'function') {
|
||||
throw new TypeError('fetchZeroSaleSkuRows 的第一个参数必须是业务库实例');
|
||||
}
|
||||
if (!Array.isArray(wmsCoIds) || wmsCoIds.length === 0) return [];
|
||||
if (!shipFrom || !shipTo || !days) return [];
|
||||
|
||||
const invIn = buildInClause(wmsCoIds, 'w');
|
||||
const shipIn = buildInClause(wmsCoIds, 'v');
|
||||
const gradeCase = buildGradeCase();
|
||||
|
||||
const sqlText = `
|
||||
WITH bin AS (
|
||||
SELECT p.wms_co_id AS wmsCoId,
|
||||
p.sku_id AS skuId,
|
||||
LTRIM(RTRIM(p.bin)) AS b,
|
||||
SUM(CAST(ISNULL(p.qty, 0) AS DECIMAL(18, 2))) AS binQty
|
||||
FROM rpt.jst_packInventoryInfo p
|
||||
WHERE ISNULL(p.bin, '') <> ''
|
||||
AND ISNULL(p.qty, 0) <> 0
|
||||
GROUP BY p.wms_co_id, p.sku_id, LTRIM(RTRIM(p.bin))
|
||||
),
|
||||
inv AS (
|
||||
SELECT i.wms_co_id AS wmsCoId,
|
||||
i.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(i.qty, 0) AS DECIMAL(18, 2))) AS stockQty
|
||||
FROM rpt.jst_inventoryInfo i
|
||||
WHERE i.wms_co_id IN (${invIn.clause})
|
||||
GROUP BY i.wms_co_id, i.sku_id
|
||||
),
|
||||
ship AS (
|
||||
SELECT o.wms_co_id AS wmsCoId,
|
||||
oi.sku_id AS skuId,
|
||||
SUM(CAST(ISNULL(oi.qty, 0) AS DECIMAL(18, 2))) AS salesQty
|
||||
FROM rpt.jst_OrderItem oi
|
||||
JOIN rpt.jst_Orders o ON o.o_id = oi.o_id
|
||||
WHERE o.wms_co_id IN (${shipIn.clause})
|
||||
AND o.send_date >= @shipFrom
|
||||
AND o.send_date < @shipTo
|
||||
AND ISNULL(o.status, '') NOT IN ('Delete', 'Cancelled')
|
||||
GROUP BY o.wms_co_id, oi.sku_id
|
||||
),
|
||||
base AS (
|
||||
SELECT u.wmsCoId,
|
||||
u.skuId,
|
||||
SUM(u.stockQty) AS stockQty,
|
||||
SUM(u.salesQty) AS salesQty
|
||||
FROM (
|
||||
SELECT wmsCoId, skuId, stockQty, CAST(0 AS DECIMAL(18, 2)) AS salesQty FROM inv
|
||||
UNION ALL
|
||||
SELECT wmsCoId, skuId, CAST(0 AS DECIMAL(18, 2)) AS stockQty, salesQty FROM ship
|
||||
) u
|
||||
GROUP BY u.wmsCoId, u.skuId
|
||||
),
|
||||
graded AS (
|
||||
SELECT b.wmsCoId,
|
||||
b.skuId,
|
||||
b.stockQty,
|
||||
b.salesQty,
|
||||
${gradeCase} AS gradeKey
|
||||
FROM base b
|
||||
WHERE ABS(ISNULL(b.stockQty, 0)) > 0.0001
|
||||
OR ABS(ISNULL(b.salesQty, 0)) > 0.0001
|
||||
)
|
||||
SELECT g.wmsCoId,
|
||||
g.skuId,
|
||||
ISNULL(s.name, '') AS skuName,
|
||||
ISNULL(bn.b, '') AS binName,
|
||||
CAST(COALESCE(bn.binQty, g.stockQty) AS DECIMAL(18, 2)) AS stockQty,
|
||||
CAST(g.salesQty AS DECIMAL(18, 2)) AS salesQty
|
||||
FROM graded g
|
||||
LEFT JOIN rpt.jst_sku s ON s.sku_id = g.skuId
|
||||
LEFT JOIN bin bn ON bn.wmsCoId = g.wmsCoId AND bn.skuId = g.skuId
|
||||
WHERE g.gradeKey = 'zero'
|
||||
ORDER BY g.wmsCoId, g.stockQty DESC, g.skuId, bn.b`;
|
||||
|
||||
const rows = await cloudDb.query(sqlText, {
|
||||
...invIn.params,
|
||||
...shipIn.params,
|
||||
shipFrom,
|
||||
shipTo,
|
||||
days,
|
||||
}, { requestTimeout: 180000 });
|
||||
|
||||
return rows.map((r) => ({
|
||||
wmsCoId: String(r.wmsCoId),
|
||||
skuId: String(r.skuId ?? ''),
|
||||
skuName: r.skuName == null ? '' : String(r.skuName),
|
||||
binName: r.binName == null ? '' : String(r.binName),
|
||||
stockQty: Number(r.stockQty) || 0,
|
||||
salesQty: Number(r.salesQty) || 0,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildInClause,
|
||||
buildGradeCase,
|
||||
fetchGradeRows,
|
||||
fetchZeroSaleSkuRows,
|
||||
};
|
||||
@ -1,140 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「最晚退厂时间」取数(2026-09-28 新增)
|
||||
*
|
||||
* 业务口径:供应商不再合作时,工作平台会给该供应商一个「最晚退厂时间」,
|
||||
* 即它放在仓里的货最后必须拉走的日期,看板上跟「供应商是否合作」列并排展示。
|
||||
*
|
||||
* ── 数据在哪 ──────────────────────────────────────────────────────────────
|
||||
* 工作平台库。副本实例(218.93.9.21:1433)上共有 9 个库:
|
||||
* master / model / msdb / tempdb / SSISDB / JSReportReplica(副本,我们只读它)/
|
||||
* DataPlatformControl / WorkbenchDataShared / WorkbenchIdentity。
|
||||
* 「工作平台」= WorkbenchDataShared(配套身份库 WorkbenchIdentity,控制库 DataPlatformControl)。
|
||||
*
|
||||
* ── 当前为什么是空的(2026-09-28 已逐条取证,别再重复排查)───────────────
|
||||
* ① 副本账号 cangku 访问 WorkbenchDataShared / WorkbenchIdentity / DataPlatformControl
|
||||
* **一律被拒**:The server principal "cangku" is not able to access the database
|
||||
* "WorkbenchDataShared" under the current security context.
|
||||
* ② 系统设置(database_configs)里只有 cloud_database_public(JSReportReplica)与
|
||||
* product_information_database(PostgreSQL),没有工作平台库。
|
||||
* ③ 本机 .env 与生产 /opt/cangku/shared/backend.env 里的 JIAN_SUN_DB_HOST/USER/PASSWORD、
|
||||
* WORK_ORDER_* 全部为空(只留了端口/库名占位)。
|
||||
* ④ 副本库内部没有任何「退厂」字段:5,496 个字段全量搜过,COLUMN_NAME 含「退厂」的为 0;
|
||||
* 最接近的 fys_supplier.NoCoopTime(不合作时间)**不能拿来顶替** —— 它是停更快照
|
||||
* (DownloadTime 多在 2023~2024),崔桥仓 8 个不合作供应商里只有 1 个有值,
|
||||
* 而且那 1 条自身还标着 IsCoop='合作',自相矛盾。
|
||||
*
|
||||
* ── IT 开通后怎么接(只需改本文件)────────────────────────────────────────
|
||||
* 二选一,改完即生效,页面与导出都会自动出数:
|
||||
* A. 给副本账号授只读:让 IT 执行 `USE WorkbenchDataShared; GRANT SELECT TO [cangku];`
|
||||
* (或建同义词/视图),然后把下面 EXIT_DATE_QUERY 填成跨库查询,例如:
|
||||
* SELECT SupplierName AS supplierName, CONVERT(varchar(10), LatestExitDate, 120) AS exitDate
|
||||
* FROM WorkbenchDataShared.dbo.<供应商不合作表>
|
||||
* B. 让 IT 把「供应商不合作」表同步进 JSReportReplica(像 js_supplier / fys_supplier 那样),
|
||||
* 再把 EXIT_DATE_QUERY 指向副本里的表名。
|
||||
* 查询只需要返回两列:supplierName(供应商名,口径同 js_supplier.scmname / jst_sku.supplier_name,
|
||||
* 即简称)与 exitDate(最晚退厂日期,字符串 'YYYY-MM-DD' 或 datetime 均可)。
|
||||
*
|
||||
* 未接入时本模块恒返回 available=false,页面该列显示「待接入」,不会报错、不影响其他列。
|
||||
*/
|
||||
|
||||
const cloudDb = require('../config/cloudDatabase');
|
||||
|
||||
const SOURCE_LABEL = '工作平台(WorkbenchDataShared)· 供应商不合作';
|
||||
const UNAVAILABLE_MESSAGE = '工作平台库尚未授权,待 IT 开通后自动出数';
|
||||
|
||||
// ---- 接入点(IT 开通后配这两个环境变量即可,不必改代码)--------------------
|
||||
// SUPPLIER_EXIT_DATE_ENABLED=true
|
||||
// SUPPLIER_EXIT_DATE_SQL=<查询语句,需返回 supplierName / exitDate 两列>
|
||||
// 也可以在下面 CODE_* 常量里直接写死(env 优先)。
|
||||
const CODE_EXIT_DATE_ENABLED = false;
|
||||
const CODE_EXIT_DATE_QUERY = '';
|
||||
const ENABLED = process.env.SUPPLIER_EXIT_DATE_ENABLED === 'true' || CODE_EXIT_DATE_ENABLED;
|
||||
const EXIT_DATE_QUERY = process.env.SUPPLIER_EXIT_DATE_SQL || CODE_EXIT_DATE_QUERY;
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CACHE_TTL_MS = Number.parseInt(process.env.SUPPLIER_EXIT_DATE_CACHE_TTL_MS, 10) >= 0
|
||||
? Number.parseInt(process.env.SUPPLIER_EXIT_DATE_CACHE_TTL_MS, 10)
|
||||
: 30 * 60 * 1000;
|
||||
|
||||
let cache = null; // { at, payload }
|
||||
|
||||
const formatExitDate = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '';
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return '';
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(value.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
const text = String(value).trim();
|
||||
if (!text) return '';
|
||||
const match = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
|
||||
if (!match) return text;
|
||||
return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 取「供应商 → 最晚退厂时间」映射。
|
||||
* 返回 { available, sourceLabel, message, bySupplier: Map<string, string> }
|
||||
*/
|
||||
async function loadSupplierExitDates() {
|
||||
const describe = () => ({
|
||||
available: false,
|
||||
sourceLabel: SOURCE_LABEL,
|
||||
message: UNAVAILABLE_MESSAGE,
|
||||
bySupplier: new Map(),
|
||||
});
|
||||
|
||||
if (!ENABLED || !EXIT_DATE_QUERY) return describe();
|
||||
if (cache && Date.now() - cache.at < CACHE_TTL_MS) return cache.payload;
|
||||
|
||||
try {
|
||||
const rows = await cloudDb.query(EXIT_DATE_QUERY, {}, { requestTimeout: 120000 });
|
||||
const bySupplier = new Map();
|
||||
(rows || []).forEach((row) => {
|
||||
const name = String(row.supplierName || '').trim();
|
||||
const date = formatExitDate(row.exitDate);
|
||||
if (!name || !date) return;
|
||||
const prev = bySupplier.get(name);
|
||||
// 同一供应商多条时取最晚的日期
|
||||
if (!prev || date > prev) bySupplier.set(name, date);
|
||||
});
|
||||
const payload = {
|
||||
available: true,
|
||||
sourceLabel: SOURCE_LABEL,
|
||||
message: '',
|
||||
bySupplier,
|
||||
};
|
||||
cache = { at: Date.now(), payload };
|
||||
return payload;
|
||||
} catch (error) {
|
||||
// 取不到不算致命:该列留空、页面提示待接入,其余列照常
|
||||
console.warn('[SupplierExitDate] 最晚退厂时间取数失败:', error.message);
|
||||
const payload = {
|
||||
available: false,
|
||||
sourceLabel: SOURCE_LABEL,
|
||||
message: `工作平台库读取失败(${error.message}),该列暂空`,
|
||||
bySupplier: new Map(),
|
||||
};
|
||||
cache = { at: Date.now(), payload };
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_EXIT_DATES = Object.freeze({
|
||||
available: false,
|
||||
sourceLabel: SOURCE_LABEL,
|
||||
message: UNAVAILABLE_MESSAGE,
|
||||
bySupplier: new Map(),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
loadSupplierExitDates,
|
||||
formatExitDate,
|
||||
EMPTY_EXIT_DATES,
|
||||
EXIT_DATE_SOURCE_LABEL: SOURCE_LABEL,
|
||||
EXIT_DATE_UNAVAILABLE_MESSAGE: UNAVAILABLE_MESSAGE,
|
||||
};
|
||||
@ -1,318 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「仓储中心物流转运异常」每天定点推文本到钉钉群 —— 配置与状态存取
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 配置分两层存(与统一通知中心同一套做法,见 yydNotificationCenterSettings):
|
||||
* · 普通字段 → 本地库 `system_config`,key = warehouse_logistics_exception_notify_config
|
||||
* · 加签密钥 → resilientSecretStore(system_secrets + 用户目录备份文件)
|
||||
*
|
||||
* 通道是「钉钉群自定义机器人 webhook」(2026-10-22 业务定:不发 .xlsx 附件,
|
||||
* 群里直接看文本),所以只有两个凭据字段:
|
||||
* · webhookUrl 群机器人地址(含 access_token;完整回显,业务要能核对)
|
||||
* · webhookSecret 加签密钥(SEC 开头,只回掩码;安全设置选「关键词/IP」时留空)
|
||||
*
|
||||
* 推送时刻用页面上的 HH:mm(默认 09:10,核验 09:00 之后),**按北京时间解释**:
|
||||
* 生产容器是 UTC,绝不能拿容器本地时间比。
|
||||
*/
|
||||
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getResilientSecret,
|
||||
saveResilientSecret,
|
||||
} = require('./resilientSecretStore');
|
||||
|
||||
const CONFIG_KEY = 'warehouse_logistics_exception_notify_config';
|
||||
const SECRET_KEY = 'warehouse_logistics_exception_notify_webhook_secret';
|
||||
const STATE_KEY = 'warehouse_logistics_exception_notify_state';
|
||||
const DEFAULT_SEND_TIME = '09:10';
|
||||
// 明细条数:text 消息上限 20000 字节,30 条中文明细约 9~12KB,留足余量
|
||||
const DEFAULT_MAX_DETAIL_ROWS = 30;
|
||||
const MAX_DETAIL_ROWS_LIMIT = 50;
|
||||
// 页面地址(消息里给「查看全部」链接)。生产是带 8443 的域名,别写成裸域名
|
||||
const DEFAULT_PAGE_URL = process.env.WLE_PAGE_URL
|
||||
|| 'https://ck.yydd.ai:8443/planning/operation/warehouse-logistics-exception';
|
||||
const CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
let cachedConfig = null;
|
||||
let cachedAt = 0;
|
||||
|
||||
function normalizeText(value, maxLength = 512) {
|
||||
return String(value ?? '').trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function safeJsonParse(value, fallback = {}) {
|
||||
if (value && typeof value === 'object') return value;
|
||||
try { return JSON.parse(value || '{}'); } catch (_error) { return fallback; }
|
||||
}
|
||||
|
||||
function maskSecret(value) {
|
||||
const text = normalizeText(value);
|
||||
if (!text) return '';
|
||||
if (text.length <= 8) return `${text.slice(0, 2)}******`;
|
||||
return `${text.slice(0, 4)}${'*'.repeat(Math.min(10, text.length - 8))}${text.slice(-4)}`;
|
||||
}
|
||||
|
||||
/** HH:mm(00:00 ~ 23:59);非法输入退回默认 09:10,不抛错(避免把定时任务配挂) */
|
||||
function normalizeSendTime(value) {
|
||||
const text = normalizeText(value, 5);
|
||||
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(text)) return text;
|
||||
return DEFAULT_SEND_TIME;
|
||||
}
|
||||
|
||||
/** 明细条数:0 = 只发汇总不带明细;上限 50(再多多半会被钉钉截断) */
|
||||
function normalizeMaxDetailRows(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return DEFAULT_MAX_DETAIL_ROWS;
|
||||
return Math.max(0, Math.min(MAX_DETAIL_ROWS_LIMIT, Math.round(num)));
|
||||
}
|
||||
|
||||
function normalizePageUrl(value) {
|
||||
const text = normalizeText(value, 1024);
|
||||
if (!text) return DEFAULT_PAGE_URL;
|
||||
try {
|
||||
const url = new URL(text);
|
||||
if (!/^https?:$/.test(url.protocol)) return DEFAULT_PAGE_URL;
|
||||
return url.toString();
|
||||
} catch (_error) {
|
||||
return DEFAULT_PAGE_URL;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
if (typeof value === 'boolean') return value;
|
||||
const text = String(value).trim().toLowerCase();
|
||||
if (['1', 'true', 'yes', 'on'].includes(text)) return true;
|
||||
if (['0', 'false', 'no', 'off'].includes(text)) return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function splitSendTime(sendTime) {
|
||||
const [hour, minute] = normalizeSendTime(sendTime).split(':').map((part) => Number(part));
|
||||
return { hour, minute };
|
||||
}
|
||||
|
||||
async function upsertConfigRow(value) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[CONFIG_KEY]
|
||||
);
|
||||
const text = JSON.stringify(value);
|
||||
if (rows[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[text, 'json', CONFIG_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: CONFIG_KEY,
|
||||
config_value: text,
|
||||
config_type: 'json',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readConfigRow() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[CONFIG_KEY]
|
||||
);
|
||||
return safeJsonParse(rows[0]?.configValue, {});
|
||||
} catch (error) {
|
||||
console.warn('[WleNotify] 读取推送配置失败:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 运行期配置(含明文加签密钥)—— 只给服务端用,不要直接回给前端 */
|
||||
async function getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh = false } = {}) {
|
||||
if (!forceRefresh && cachedConfig && Date.now() - cachedAt < CACHE_TTL_MS) return { ...cachedConfig };
|
||||
|
||||
const stored = await readConfigRow();
|
||||
const secret = await getResilientSecret(SECRET_KEY, {
|
||||
legacyValues: [normalizeText(stored.webhookSecret || stored.webhook_secret, 512)],
|
||||
fallbackValues: [normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK_SECRET, 512)],
|
||||
});
|
||||
|
||||
cachedConfig = {
|
||||
enabled: normalizeBoolean(stored.enabled, false),
|
||||
webhookUrl: normalizeText(stored.webhookUrl || stored.webhook_url, 1024)
|
||||
|| normalizeText(process.env.WLE_NOTIFY_DINGTALK_WEBHOOK, 1024),
|
||||
webhookSecret: secret.value,
|
||||
webhookSecretSource: secret.source,
|
||||
sendTime: normalizeSendTime(stored.sendTime || stored.send_time),
|
||||
maxDetailRows: normalizeMaxDetailRows(stored.maxDetailRows ?? stored.max_detail_rows),
|
||||
atAll: normalizeBoolean(stored.atAll ?? stored.at_all, false),
|
||||
pageUrl: normalizePageUrl(stored.pageUrl || stored.page_url),
|
||||
source: Object.keys(stored).length > 0 ? 'database' : 'default',
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
|
||||
/** 加签密钥是**可选**的(安全设置用「关键词」或「IP 白名单」时不需要)*/
|
||||
function missingFields(config = {}) {
|
||||
const missing = [];
|
||||
if (!normalizeText(config.webhookUrl)) missing.push('Webhook 地址');
|
||||
return missing;
|
||||
}
|
||||
|
||||
function isWarehouseLogisticsExceptionNotifyReady(config = {}) {
|
||||
return missingFields(config).length === 0;
|
||||
}
|
||||
|
||||
function toAdminConfig(config = {}) {
|
||||
return {
|
||||
enabled: config.enabled === true,
|
||||
webhookUrl: config.webhookUrl || '',
|
||||
webhookSecretConfigured: Boolean(config.webhookSecret),
|
||||
webhookSecretPreview: maskSecret(config.webhookSecret),
|
||||
webhookSecretSource: config.webhookSecretSource || 'unconfigured',
|
||||
sendTime: normalizeSendTime(config.sendTime),
|
||||
maxDetailRows: normalizeMaxDetailRows(config.maxDetailRows),
|
||||
maxDetailRowsLimit: MAX_DETAIL_ROWS_LIMIT,
|
||||
atAll: config.atAll === true,
|
||||
pageUrl: normalizePageUrl(config.pageUrl),
|
||||
ready: isWarehouseLogisticsExceptionNotifyReady(config),
|
||||
missingFields: missingFields(config),
|
||||
source: config.source || 'default',
|
||||
};
|
||||
}
|
||||
|
||||
async function getWarehouseLogisticsExceptionNotifyAdminConfig() {
|
||||
const config = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
|
||||
return toAdminConfig(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置。加签密钥留空 = 保持不变(页面上只显示掩码,不要求每次重填);
|
||||
* 想清空加签密钥传 `clearWebhookSecret: true`(切回「关键词」安全设置时用)。
|
||||
* 只有 enabled=true 时才强制校验 webhook 齐全 —— 允许先存「关着但填了一半」的草稿。
|
||||
*/
|
||||
async function saveWarehouseLogisticsExceptionNotifyConfig(input = {}) {
|
||||
const current = await getWarehouseLogisticsExceptionNotifyRuntimeConfig({ forceRefresh: true });
|
||||
const nextSecret = normalizeText(input.webhookSecret || input.webhook_secret, 512);
|
||||
const clearSecret = input.clearWebhookSecret === true || input.clear_webhook_secret === true;
|
||||
|
||||
const config = {
|
||||
enabled: normalizeBoolean(input.enabled, current.enabled),
|
||||
webhookUrl: normalizeText(input.webhookUrl || input.webhook_url, 1024) || current.webhookUrl,
|
||||
webhookSecret: clearSecret ? '' : (nextSecret || current.webhookSecret),
|
||||
sendTime: input.sendTime === undefined && input.send_time === undefined
|
||||
? normalizeSendTime(current.sendTime)
|
||||
: normalizeSendTime(input.sendTime ?? input.send_time),
|
||||
maxDetailRows: input.maxDetailRows === undefined && input.max_detail_rows === undefined
|
||||
? normalizeMaxDetailRows(current.maxDetailRows)
|
||||
: normalizeMaxDetailRows(input.maxDetailRows ?? input.max_detail_rows),
|
||||
atAll: normalizeBoolean(input.atAll ?? input.at_all, current.atAll === true),
|
||||
pageUrl: input.pageUrl === undefined && input.page_url === undefined
|
||||
? normalizePageUrl(current.pageUrl)
|
||||
: normalizePageUrl(input.pageUrl ?? input.page_url),
|
||||
};
|
||||
|
||||
if (config.enabled) {
|
||||
const missing = missingFields(config);
|
||||
if (missing.length) {
|
||||
throw Object.assign(new Error(`开启推送前请先填完整:${missing.join(' / ')}`), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (config.webhookUrl) {
|
||||
// 这里只做「像不像」的浅校验;真正的连通性由「测试推送」按钮验证
|
||||
const { normalizeWebhookUrl } = require('./dingtalkRobotWebhookSender');
|
||||
try {
|
||||
config.webhookUrl = normalizeWebhookUrl(config.webhookUrl);
|
||||
} catch (error) {
|
||||
throw Object.assign(new Error(error.message), { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (nextSecret) await saveResilientSecret(SECRET_KEY, nextSecret);
|
||||
await upsertConfigRow({
|
||||
enabled: config.enabled,
|
||||
webhookUrl: config.webhookUrl,
|
||||
sendTime: config.sendTime,
|
||||
maxDetailRows: config.maxDetailRows,
|
||||
atAll: config.atAll,
|
||||
pageUrl: config.pageUrl,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
cachedConfig = {
|
||||
...config,
|
||||
webhookSecretSource: nextSecret ? 'database' : (clearSecret ? 'unconfigured' : (current.webhookSecretSource || 'database')),
|
||||
source: 'database',
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return toAdminConfig(cachedConfig);
|
||||
}
|
||||
|
||||
/** 上次推送结果(页面展示 + 定时任务用来判断「今天是不是已经推过」) */
|
||||
async function loadWarehouseLogisticsExceptionNotifyState() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value AS configValue FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[STATE_KEY]
|
||||
);
|
||||
const state = safeJsonParse(rows[0]?.configValue, {});
|
||||
return state && typeof state === 'object' ? state : {};
|
||||
} catch (error) {
|
||||
console.warn('[WleNotify] 读取推送状态失败:', error.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWarehouseLogisticsExceptionNotifyState(next = {}) {
|
||||
const value = JSON.stringify(next && typeof next === 'object' ? next : {});
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[STATE_KEY]
|
||||
);
|
||||
if (rows[0]) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[value, 'json', STATE_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.insert('system_config', {
|
||||
config_key: STATE_KEY,
|
||||
config_value: value,
|
||||
config_type: 'json',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[WleNotify] 保存推送状态失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearNotifyConfigCache() {
|
||||
cachedConfig = null;
|
||||
cachedAt = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_KEY,
|
||||
DEFAULT_MAX_DETAIL_ROWS,
|
||||
DEFAULT_PAGE_URL,
|
||||
DEFAULT_SEND_TIME,
|
||||
MAX_DETAIL_ROWS_LIMIT,
|
||||
SECRET_KEY,
|
||||
STATE_KEY,
|
||||
clearNotifyConfigCache,
|
||||
getWarehouseLogisticsExceptionNotifyAdminConfig,
|
||||
getWarehouseLogisticsExceptionNotifyRuntimeConfig,
|
||||
isWarehouseLogisticsExceptionNotifyReady,
|
||||
loadWarehouseLogisticsExceptionNotifyState,
|
||||
maskSecret,
|
||||
missingFields,
|
||||
normalizeMaxDetailRows,
|
||||
normalizePageUrl,
|
||||
normalizeSendTime,
|
||||
saveWarehouseLogisticsExceptionNotifyConfig,
|
||||
saveWarehouseLogisticsExceptionNotifyState,
|
||||
splitSendTime,
|
||||
toAdminConfig,
|
||||
};
|
||||
@ -1,388 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常(2026-10-21 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-21):
|
||||
* 在左侧栏加一栏「仓储中心物流转运异常」(放在「仓储中心 24 小时发货时效」下面),
|
||||
* 下属 12 个发货仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 /
|
||||
* 山东青岗集唯品仓 / 河南洛宁仓
|
||||
* 核验规则:**当日 09:00 核验前一日已发货订单的物流记录** ——
|
||||
* 昨天发了货,到今天早上 9 点物流还没有正常轨迹(没揽收 / 没物流单 / 快递报异常 /
|
||||
* 被退回 / 揽收后长时间无新轨迹),就是「物流转运异常」,按仓归口。
|
||||
*
|
||||
* ── 数据源 ────────────────────────────────────────────────────────────────
|
||||
* `rpt.jst_order_express_details`(聚水潭订单状态镜像,402 万行,视图)
|
||||
* 这是一张「订单 + 物流」合并的宽表,一单多包裹会有多行(按 logistic_id 区分)。
|
||||
* 本页用到的列:
|
||||
* send_time 发货时间(聚水潭回传)
|
||||
* logistic_status_name 物流状态:已签收 / 运输中 / 派送中 / 已揽收 /
|
||||
* 未揽收 / 无物流单 / 异常 / 拒收 / 其他
|
||||
* logistic_time 最后一条物流轨迹时间
|
||||
* last_logistic_info 最后一条轨迹描述(nvarchar 6000,SQL 里截 300 字)
|
||||
* query_time ETL 快照时间(每行最后一次被刷新的时刻)
|
||||
* ⚠️ 别用 `logistic_time_to_now_h` 判「停滞」:那是相对**查询时刻**算的,
|
||||
* 而本页的核验基准是**核验日的 09:00**(回看历史批次时会飘),统一在 Node 侧按
|
||||
* `logistic_time → deadline` 重算。
|
||||
*
|
||||
* ── 异常判定(2026-10-21 定版 · 二次收敛)────────────────────────────────
|
||||
* 业务口径:「统计的是**未揽收**和**已揽收、但没有下一步中转记录**的」,
|
||||
* 「有『运输』字样的,往后中转记录不考核」。
|
||||
* `NO_TRACKING` 无物流单 —— 发了货但没生成运单号(打单环节漏了)
|
||||
* `NOT_PICKED` 未揽收 —— 到核验时刻快递还没揽收
|
||||
* `PICKED_NO_TRANSIT` 已揽收未中转 —— 揽收后没有下一步中转/运输记录,卡在网点
|
||||
* `CARRIER_EXCEPTION` 快递报异常 —— 状态=异常(寄件人取消寄件、异常签收等)
|
||||
* `RETURNED` 退回 —— 状态=拒收,或「其他」(实测基本是「换单退回」)
|
||||
* 已签收 / 运输中 / 派送中 → 正常,不进列表。
|
||||
* 🔥 原来那套「揽收后 24h 无新轨迹 = 运输停滞」已**废弃**:
|
||||
* 干线与中转轨迹本来就不回传聚水潭,按 24h 判全是假异常。
|
||||
*
|
||||
* ── 仓库归属 ──────────────────────────────────────────────────────────────
|
||||
* 与「24 小时发货时效」同一套:按仓库名关键词**最长匹配**归主仓,子仓自动并入;
|
||||
* 「京仓-横林二仓京东云仓」业务指定排除(与发货时效页保持一致)。
|
||||
* 山东青岗集唯品仓的关键词必须比「山东青岗集」更长,否则会被青岗集仓抢走。
|
||||
*
|
||||
* ── 时区 ──────────────────────────────────────────────────────────────────
|
||||
* 🔥 生产容器是 `Etc/UTC`,DB 的 `GETDATE()` 是北京时间 —— 详见
|
||||
* warehouseShipTimelinessRegistry.beijingNow() 的说明。
|
||||
* 本模块一律用 `beijingNow()` 取「今天」,**不要**直接 `new Date()`。
|
||||
*
|
||||
* ── 批次 ──────────────────────────────────────────────────────────────────
|
||||
* 「核验批次」= 核验日 + 发货日(核验日的前一天)。
|
||||
* 默认:现在已过 09:00 → 今天这批(核验昨天发货);未到 09:00 → 昨天那批。
|
||||
* 可回看近 30 天。
|
||||
* ⚠️ 历史批次是**按当前物流快照重算**的 —— 昨天是异常、今天已经签收的单,
|
||||
* 回看时会显示成正常。页面会明确标注,别拿它当「当时的留档」。
|
||||
*/
|
||||
|
||||
const BEIJING_OFFSET_MINUTES = 480;
|
||||
|
||||
// ── 12 个发货仓(业务给定,顺序即展示顺序)────────────────────────────────
|
||||
const CENTERS = Object.freeze([
|
||||
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
|
||||
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
|
||||
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
|
||||
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
|
||||
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
|
||||
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
|
||||
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
|
||||
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
|
||||
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] },
|
||||
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌'] },
|
||||
// 🔥 关键词要比「山东青岗集」长,否则「简尚-山东青岗集仓唯品仓」会被青岗集仓抢走
|
||||
{ key: 'SD_QGJ_VIP', name: '山东青岗集唯品仓', keywords: ['山东青岗集仓唯品', '山东青岗集唯品'] },
|
||||
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] },
|
||||
]);
|
||||
|
||||
// 业务指定排除:京仓(平台仓)不算自营发货仓的考核范围(与发货时效页一致)
|
||||
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze(['京仓-横林二仓京东云仓']);
|
||||
|
||||
// ── 不参与考核的承运商(业务 2026-10-21 指定)──────────────────────────────
|
||||
// 判据 = 状态表 `logistic_company_name` **包含**关键词(不区分大小写)。
|
||||
// 🔥 「武进安能」**不能**泛化成「安能」:12 仓另有「常州安能新」3383 单/30 天,
|
||||
// 那是要正常考核的承运商,泛化会把整片正常单误剔。
|
||||
// 🔥 「中铁」「壹米滴答」这类专线物流**轨迹不回传聚水潭**,实测近 30 天
|
||||
// 中铁物流 104 单 100% 报「异常」、壹米滴答 33 单 100% 报「异常」、
|
||||
// 唯品顺丰 2510 单 2500 单报「异常」(99.6%)—— 全是假异常,必须剔。
|
||||
// 「自提」「线下」当前 12 仓实测 0 单(历史/其它仓才有),保留判定防止以后出现。
|
||||
// 🔥 「京喜京东」(含 `京喜京东快递` / `京喜京东快运`)是京喜自营那条线,
|
||||
// 实测山东青岗集仓的京喜单揽收后轨迹全停在站点、不回传聚水潭,
|
||||
// 近 30 天 62 单「已揽收未中转」全出自这里 —— 业务 2026-10-21 指定剔除。
|
||||
// 只按「京喜京东」前缀剔:`京东自营快递亿家达` / `崔桥京东特惠` / `曹县京东快运`
|
||||
// 是另外的账号,正常回传轨迹,不能连带剔掉。
|
||||
// ⚠️ 2026-10-22 业务一度要求「凡是带京东快递、京喜的都踢出」,随即**撤回该要求** ——
|
||||
// 所以这里维持只剔「京喜京东」,别把关键词放大成「京东」。
|
||||
const EXCLUDED_CARRIERS = Object.freeze([
|
||||
// 🔥 顺丰系**全部剔除**(业务 2026-10-22 定版):
|
||||
// 实测 12 仓近 30 天顺丰系共 7 个账号 —— `唯品顺丰` / `顺丰干配`(洛宁、山东、苏北大小件)/
|
||||
// `山东顺丰大件` / `景瓴十方顺丰大件-莒南`,轨迹都不按常规回传聚水潭
|
||||
// (唯品顺丰 99.6% 报异常、顺丰干配 52% 报未揽收、山东顺丰大件 67% 报未揽收)。
|
||||
// 所以这里直接用「顺丰」一个关键词全覆盖,**不再逐个列账号**。
|
||||
// 保留 `唯品顺丰` 在更前面:命中时 reason 更具体,页脚能看出是唯品那条线。
|
||||
'唯品顺丰',
|
||||
'顺丰',
|
||||
'武进安能',
|
||||
'中铁',
|
||||
'壹米滴答',
|
||||
'自提',
|
||||
'线下',
|
||||
'商家配送',
|
||||
// 京东系只剔「京喜京东」(京喜自营那条线,揽收后轨迹停在站点不回传)。
|
||||
// ⚠️ 别放大成「京东」:`京东快递` / `京东自营快递亿家达` / `京东自营快递朴美` /
|
||||
// `崔桥京东特惠` / `曹县京东快运` 等 16 个账号业务确认仍要正常考核
|
||||
// (2026-10-22 曾短暂要求全剔,随即撤回)。
|
||||
'京喜京东',
|
||||
]);
|
||||
|
||||
// ── 有条件剔除:同一承运商,只在某些仓算正常(业务 2026-10-21 追加)────────
|
||||
// 「睢宁中通」是全公司唯一的中通面单账号,开在睢宁网点。
|
||||
// · 睢宁仓自己用 → 正常揽收、轨迹完整,**照常考核**;
|
||||
// · 山东品牌仓 / 河南洛宁仓 / 崔桥工厂仓 异地用它打单 → 揽收不畅,
|
||||
// 近 30 天非睢宁仓 490 单里 163 单「未揽收」(33%),全是账号异地揽收造成的假异常。
|
||||
// 所以:`keepCenterKeys` 里的仓照常考核,其它仓命中即剔除。
|
||||
const CONDITIONAL_EXCLUDED_CARRIERS = Object.freeze([
|
||||
{
|
||||
keyword: '睢宁中通',
|
||||
keepCenterKeys: ['SN'],
|
||||
reason: '睢宁中通(非睢宁仓异地揽收)',
|
||||
},
|
||||
]);
|
||||
|
||||
// ── 已终止的订单不算转运异常(业务 2026-10-21 指定)──────────────────────
|
||||
// ① 订单状态:状态表 `order_status_name` 实测只有「已发货 / 已取消」两个值,
|
||||
// 「已取消」在近 30 天 12 仓里 8 单,其中 6 单会误报未揽收/无物流单。
|
||||
const EXCLUDED_ORDER_STATUS_KEYWORDS = Object.freeze(['取消', '作废']);
|
||||
|
||||
// ② 订单标签:`order_labels`(JSON 数组字符串)
|
||||
// · 「发货后取消」= 客户已申请退款取消,货在路上但不用再跟物流
|
||||
// · 「作废」= 单据作废
|
||||
// · 「拦截」= 买家/客服要求拦截(实测标签为「留言备注拦截」)
|
||||
// ⚠️ 别把「取消」直接当关键词匹配标签:会连带「取消预售」等无关标签。
|
||||
const EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze([
|
||||
'发货后取消',
|
||||
'作废',
|
||||
'拦截',
|
||||
]);
|
||||
|
||||
// ── 刷单 / 信誉单不算转运异常(业务 2026-10-21 追加)──────────────────────
|
||||
// 判据 = 状态表 `order_labels` 里的**订单类型标签**(不是状态、不是备注):
|
||||
// · 含「刷单」 = 刷单待处理 → 假发货,本来就不会真揽收
|
||||
// · 含「转空包」= 信誉单(信誉订单仓那批单都带这个标签,发空包不走真货)
|
||||
// 实测(近 30 天 12 仓):带「刷单」411 单,其中 148 单报未揽收 —— 全是假异常;
|
||||
// 带「转空包」0 单(都在「简尚-信誉订单仓」,那本来不在 12 个考核仓里)。
|
||||
// ⚠️ 别用「特殊单」当判据:它和补差价/配件单混在一起,剔了会漏掉真异常。
|
||||
const EXCLUDED_ORDER_TYPE_LABELS = Object.freeze([
|
||||
{ keyword: '刷单', label: '刷单' },
|
||||
{ keyword: '转空包', label: '信誉单' },
|
||||
]);
|
||||
|
||||
// 关键词按长度倒序 —— 「最长匹配优先」,品牌仓/唯品仓不会被主仓抢走
|
||||
const SORTED_KEYWORDS = CENTERS
|
||||
.flatMap((center) => center.keywords.map((kw) => ({ kw, center })))
|
||||
.sort((a, b) => b.kw.length - a.kw.length);
|
||||
|
||||
// ── 核验时刻 ──────────────────────────────────────────────────────────────
|
||||
const VERIFY_HOUR = 9;
|
||||
const LOOKBACK_DAYS = 30;
|
||||
|
||||
// ── 异常类型(2026-10-21 二次定版:只看「揽收前」的两个卡点)───────────────
|
||||
// 业务原话:「这里的物流异常统计的是未揽收和已揽收、但是没有下一步中转记录的」,
|
||||
// 以及「有『运输』字样的,往后中转记录不考核,不属于物流异常」。
|
||||
// 所以:运输中 / 派送中 / 已签收 **一律正常**,不再用「24h 无新轨迹」判停滞
|
||||
// (干线/中转轨迹本来就不回传,按 24h 判只会刷屏)。
|
||||
// label 必须**自带结论**(业务要求「写上是什么异常」),不要只写「物流异常」这种笼统词。
|
||||
const EXCEPTION_TYPES = Object.freeze([
|
||||
{
|
||||
key: 'NO_TRACKING',
|
||||
label: '无物流单',
|
||||
tone: 'red',
|
||||
desc: '已发货但连运单号都没有 —— 打单/推单环节就没生成物流单',
|
||||
},
|
||||
{
|
||||
key: 'NOT_PICKED',
|
||||
label: '未揽收',
|
||||
tone: 'orange',
|
||||
desc: '有运单号,到核验时刻快递还没上门揽收',
|
||||
},
|
||||
{
|
||||
key: 'PICKED_NO_TRANSIT',
|
||||
label: '已揽收未中转',
|
||||
tone: 'gold',
|
||||
desc: '快递已揽收,但之后没有下一步中转/运输记录 —— 卡在网点没发出去',
|
||||
},
|
||||
{
|
||||
key: 'CARRIER_EXCEPTION',
|
||||
label: '快递报异常',
|
||||
tone: 'red',
|
||||
desc: '快递回传「异常」(寄件人取消寄件、异常签收等),需要人工介入',
|
||||
},
|
||||
{
|
||||
key: 'RETURNED',
|
||||
label: '退回',
|
||||
tone: 'volcano',
|
||||
desc: '拒收,或包裹已「换单退回」',
|
||||
},
|
||||
]);
|
||||
|
||||
// 「已经动起来了」的轨迹关键词:状态停在「已揽收」但最后一条轨迹里已经出现
|
||||
// 运输/中转/发往等字样 —— 说明有下一步中转记录,按业务口径**不算异常**。
|
||||
const TRANSIT_INFO_KEYWORDS = Object.freeze(['运输', '中转', '发往', '已发出', '分拨', '干线']);
|
||||
|
||||
const TYPE_MAP = new Map(EXCEPTION_TYPES.map((t) => [t.key, t]));
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「按北京时间读出来」的当前时刻(容器可能跑在 UTC)。
|
||||
* @param {Date} [base]
|
||||
* @returns {Date} 本地 getter 读出来正好是北京时间的 Date
|
||||
*/
|
||||
function beijingNow(base = new Date()) {
|
||||
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
|
||||
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
|
||||
}
|
||||
|
||||
function formatDate(date) {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
/** 'YYYY-MM-DD' → 当日 00:00 的 Date(本地,不做时区换算) */
|
||||
function parseDate(text) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(String(text || '').trim());
|
||||
if (!m) return null;
|
||||
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), 0, 0, 0);
|
||||
return Number.isNaN(d.getTime()) ? null : d;
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + days, 0, 0, 0);
|
||||
}
|
||||
|
||||
/** 仓库名 → 主仓;被排除或无法归属返回 null */
|
||||
function resolveCenter(warehouseName) {
|
||||
if (!warehouseName) return null;
|
||||
const name = String(warehouseName);
|
||||
for (const kw of EXCLUDED_WAREHOUSE_KEYWORDS) {
|
||||
if (name.includes(kw)) return null;
|
||||
}
|
||||
for (const item of SORTED_KEYWORDS) {
|
||||
if (name.includes(item.kw)) return item.center;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 承运商是否不参与考核。
|
||||
* @param {string} companyName 状态表 `logistic_company_name`
|
||||
* @param {string} [centerKey] 该行所属仓(CONDITIONAL_EXCLUDED_CARRIERS 用);
|
||||
* 不传时**保守不剔** —— 宁可少剔,不能把本仓正常单误剔
|
||||
* @returns {string|null} 命中原因/关键词;不剔除返回 null
|
||||
*/
|
||||
function matchExcludedCarrier(companyName, centerKey) {
|
||||
const name = String(companyName || '');
|
||||
if (!name) return null;
|
||||
|
||||
// ① 无条件剔除
|
||||
for (const kw of EXCLUDED_CARRIERS) {
|
||||
if (name.includes(kw)) return kw;
|
||||
}
|
||||
|
||||
// ② 有条件剔除:只在该承运商「本仓」正常时才留(如睢宁中通 → 睢宁仓)
|
||||
for (const rule of CONDITIONAL_EXCLUDED_CARRIERS) {
|
||||
if (!name.includes(rule.keyword)) continue;
|
||||
if (!centerKey) return null;
|
||||
if (rule.keepCenterKeys.includes(centerKey)) return null;
|
||||
return rule.reason;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单是否已终止(已取消 / 作废 / 拦截),终止的不用跟物流。
|
||||
* @param {string} orderStatus 状态表 `order_status_name`
|
||||
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
|
||||
* @returns {string|null} 命中原因(人话),不剔除返回 null
|
||||
*/
|
||||
function matchVoidedOrder(orderStatus, orderLabels) {
|
||||
const status = String(orderStatus || '');
|
||||
for (const kw of EXCLUDED_ORDER_STATUS_KEYWORDS) {
|
||||
if (status.includes(kw)) return `订单${status}`;
|
||||
}
|
||||
const labels = String(orderLabels || '');
|
||||
if (!labels) return null;
|
||||
for (const kw of EXCLUDED_ORDER_LABEL_KEYWORDS) {
|
||||
if (labels.includes(kw)) return `标签含「${kw}」`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷单 / 信誉单 → 不是真实发货,不用跟物流。
|
||||
* @param {string} orderLabels 状态表 `order_labels`(JSON 数组字符串)
|
||||
* @returns {string|null} 命中的人话原因;不剔除返回 null
|
||||
*/
|
||||
function matchNonRealOrder(orderLabels) {
|
||||
const labels = String(orderLabels || '');
|
||||
if (!labels) return null;
|
||||
for (const item of EXCLUDED_ORDER_TYPE_LABELS) {
|
||||
if (labels.includes(item.keyword)) {
|
||||
return item.label === '刷单' ? '刷单' : '信誉单(转空包)';
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「核验批次」。
|
||||
*
|
||||
* @param {string} [dateInput] 核验日 'YYYY-MM-DD';不传按当前时刻自动判定
|
||||
* @param {Date} [nowBase] 当前时刻(真实时刻,内部会转北京时间)
|
||||
* @returns {object} 批次描述
|
||||
*/
|
||||
function resolveBatch(dateInput, nowBase = new Date()) {
|
||||
const now = beijingNow(nowBase);
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
|
||||
|
||||
let verifyDay = null;
|
||||
if (dateInput) {
|
||||
const parsed = parseDate(dateInput);
|
||||
if (parsed) verifyDay = parsed;
|
||||
}
|
||||
if (!verifyDay) {
|
||||
// 未到 09:00 时,今天的批次还没跑 → 默认看昨天那批(核验前日发货)
|
||||
const beforeCheckpoint = now.getHours() < VERIFY_HOUR;
|
||||
verifyDay = beforeCheckpoint ? addDays(today, -1) : today;
|
||||
}
|
||||
// 护栏:不能晚于今天,也不能早于回看下限
|
||||
if (verifyDay > today) verifyDay = today;
|
||||
const earliest = addDays(today, -LOOKBACK_DAYS + 1);
|
||||
if (verifyDay < earliest) verifyDay = earliest;
|
||||
|
||||
const shipDay = addDays(verifyDay, -1);
|
||||
const verifyDate = formatDate(verifyDay);
|
||||
const shipDate = formatDate(shipDay);
|
||||
const deadline = `${verifyDate} ${pad2(VERIFY_HOUR)}:00:00`;
|
||||
const nowText = `${formatDate(now)} ${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
|
||||
|
||||
return {
|
||||
verifyDate,
|
||||
shipDate,
|
||||
deadline,
|
||||
windowStart: `${shipDate} 00:00:00`,
|
||||
windowEnd: `${verifyDate} 00:00:00`,
|
||||
label: `${verifyDate.slice(5)} 09:00 核验 · ${shipDate.slice(5)} 发货`,
|
||||
started: nowText >= deadline,
|
||||
isToday: verifyDate === formatDate(today),
|
||||
lookback: verifyDate !== formatDate(today),
|
||||
lookbackDays: Math.round((today - verifyDay) / 86400000),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CENTERS,
|
||||
EXCLUDED_WAREHOUSE_KEYWORDS,
|
||||
EXCLUDED_CARRIERS,
|
||||
CONDITIONAL_EXCLUDED_CARRIERS,
|
||||
EXCLUDED_ORDER_STATUS_KEYWORDS,
|
||||
EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||||
EXCLUDED_ORDER_TYPE_LABELS,
|
||||
EXCEPTION_TYPES,
|
||||
TYPE_MAP,
|
||||
TRANSIT_INFO_KEYWORDS,
|
||||
VERIFY_HOUR,
|
||||
LOOKBACK_DAYS,
|
||||
resolveCenter,
|
||||
matchExcludedCarrier,
|
||||
matchVoidedOrder,
|
||||
matchNonRealOrder,
|
||||
resolveBatch,
|
||||
beijingNow,
|
||||
formatDate,
|
||||
parseDate,
|
||||
addDays,
|
||||
pad2,
|
||||
};
|
||||
@ -1,493 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心物流转运异常 —— 服务层
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 口径见 warehouseLogisticsExceptionRegistry.js 文件头。这里只讲实现要点:
|
||||
*
|
||||
* ① 一趟查询把「核验日前一天发货」的单全部取回来(窄列 + 轨迹截 300 字),
|
||||
* Node 侧按仓库名归主仓、按物流状态判异常。实测:5400 行冷启动 ~20s、
|
||||
* 热缓存 ~4s(视图谓词下推一般,走 querySlow 给足超时)。
|
||||
* ② 一单多包裹会有多行(按 logistic_id 区分)——
|
||||
* · 明细按 (内部单号 + 运单号) 去重,仓库能看到是哪个包裹出的问题;
|
||||
* · 汇总按**内部单号**去重,否则 3 个包裹的单会被算成 3 单,异常率虚高。
|
||||
* ③ 只判「揽收前」的两个卡点(未揽收 / 已揽收未中转),见 classify()。
|
||||
* 运输中、派送中一律不考核,不再算 24h 停滞。
|
||||
* ④ 结果缓存 5 分钟:页面 10 分钟自动刷一次,点「刷新」带 force=1 绕过。
|
||||
*
|
||||
* ── 剔除(业务 2026-10-21 定版)──────────────────────────────────────────────
|
||||
* 剔掉的单**既不算异常、也不算发货分母**(否则异常率会被稀释/虚高):
|
||||
* a. 承运商 ∈ EXCLUDED_CARRIERS(顺丰系全部 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 /
|
||||
* 商家配送 / 京喜京东)—— 轨迹不回传,留着全是假异常(顺丰系各账号 52%~99.6% 假异常,
|
||||
* 中铁、壹米滴答近 30 天 100%)。「武进安能」必须精确,别泛化成「安能」。
|
||||
* a2. 有条件剔除 CONDITIONAL_EXCLUDED_CARRIERS:**「睢宁中通」只在睢宁仓考核**,
|
||||
* 其它仓异地用它打单的剔(近 30 天非睢宁仓 490 单里 163 单未揽收,全是账号异地揽收)。
|
||||
* b. 订单已终止:order_status_name 含「取消/作废」,或 order_labels 含
|
||||
* 「发货后取消 / 作废 / 拦截」—— 单据都终止了,不用再跟物流。
|
||||
* c. 刷单(标签含「刷单」)/ 信誉单(标签含「转空包」)—— 单据本身不是真实发货。
|
||||
* 各类剔除量通过 `exclusions.*` 原样返回,页面页脚展示,方便业务核对口径。
|
||||
*/
|
||||
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const registry = require('./warehouseLogisticsExceptionRegistry');
|
||||
|
||||
const DEFAULT_DETAIL_LIMIT = 500;
|
||||
// 列表上限 2000 条;导出单独放开到 5000(异常包裹一天最多几百,留足余量)
|
||||
const MAX_DETAIL_LIMIT = 2000;
|
||||
const EXPORT_DETAIL_LIMIT = 5000;
|
||||
|
||||
// 快照超过这么久没刷新 → 页面黄告警(这张表正常每小时刷一轮)
|
||||
const SNAPSHOT_STALE_MINUTES = 120;
|
||||
|
||||
const CACHE_TTL_MS = Number(process.env.CANGKU_WLE_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||
// key = `${verifyDate}|${limit}`。用 Map 而不是单槽缓存:
|
||||
// 页面按 1000 条取、导出按 5000 条取,单槽会互相踢掉对方 —— 导出一次,
|
||||
// 页面下次自动刷新就得重查库(20~40s)。多键缓存把这个问题消掉。
|
||||
const CACHE_MAX_ENTRIES = 8;
|
||||
const resultCache = new Map();
|
||||
|
||||
function clearLogisticsExceptionCache() {
|
||||
resultCache.clear();
|
||||
}
|
||||
|
||||
/** datetime / Date → 'YYYY-MM-DD HH:mm:ss'(东八区墙钟,不做时区换算) */
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return '';
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}`
|
||||
+ ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`;
|
||||
}
|
||||
const s = String(value);
|
||||
if (!s) return '';
|
||||
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
|
||||
}
|
||||
|
||||
/** 两个时间文本相差多少分钟(to - from) */
|
||||
function diffMinutes(fromText, toText) {
|
||||
const a = formatDateTime(fromText);
|
||||
const b = formatDateTime(toText);
|
||||
if (!a || !b) return null;
|
||||
const pa = new Date(a.replace(' ', 'T')).getTime();
|
||||
const pb = new Date(b.replace(' ', 'T')).getTime();
|
||||
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return null;
|
||||
return Math.round((pb - pa) / 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按物流状态判异常类型(口径 2026-10-21 二次定版)。
|
||||
*
|
||||
* 只有两种「真异常」,其余状态一律不考核:
|
||||
* · 没揽收 —— 状态停在「未揽收」(或压根没物流单)
|
||||
* · 揽收了但没下一步中转 —— 状态停在「已揽收」,最后轨迹里也没有运输/中转字样
|
||||
*
|
||||
* 🔥 业务原话:「有『运输』字样的,往后中转记录不考核」——
|
||||
* 所以 `运输中` / `派送中` **直接算正常**;不再用「距核验时刻 ≥24h 无新轨迹」
|
||||
* 判停滞(干线、中转轨迹本就不回传聚水潭,按 24h 判全是假异常)。
|
||||
*
|
||||
* @returns {string|null} EXCEPTION_TYPES[].key;正常返回 null
|
||||
*/
|
||||
function classify(row) {
|
||||
const status = String(row.logistic_status_name || '').trim();
|
||||
const trackingNo = String(row.logistic_id || '').trim();
|
||||
|
||||
// 状态为空:有运单号但没轨迹 = 未揽收;连运单号都没有 = 无物流单
|
||||
if (!status) return trackingNo ? 'NOT_PICKED' : 'NO_TRACKING';
|
||||
|
||||
if (status === '已签收' || status === '签收') return null;
|
||||
if (status.includes('无物流')) return 'NO_TRACKING';
|
||||
if (status.includes('未揽收')) return 'NOT_PICKED';
|
||||
if (status.includes('拒收')) return 'RETURNED';
|
||||
// 「其他」实测全是「包裹已换单退回」,和拒收同性质
|
||||
if (status === '其他') return 'RETURNED';
|
||||
if (status.includes('异常')) return 'CARRIER_EXCEPTION';
|
||||
|
||||
if (status.includes('已揽收')) {
|
||||
// 揽收之后如果最后一条轨迹已经出现运输/中转/发往字样,就算「有下一步中转」
|
||||
const info = String(row.last_info || row.last_logistic_info || '');
|
||||
const moved = registry.TRANSIT_INFO_KEYWORDS.some((kw) => info.includes(kw));
|
||||
return moved ? null : 'PICKED_NO_TRANSIT';
|
||||
}
|
||||
|
||||
// 运输中 / 派送中(以及任何带「运输」字样的状态)= 已经动起来了,不考核
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 拼 SQL 的仓库名 LIKE 条件(关键词取自 CENTERS,避免手写两套名单) */
|
||||
function buildWarehouseFilters() {
|
||||
const keywords = [];
|
||||
for (const center of registry.CENTERS) {
|
||||
for (const kw of center.keywords) {
|
||||
if (!keywords.includes(kw)) keywords.push(kw);
|
||||
}
|
||||
}
|
||||
const params = {};
|
||||
const clauses = keywords.map((kw, i) => {
|
||||
params[`w${i}`] = `%${kw}%`;
|
||||
return `e.warehouse_name LIKE @w${i}`;
|
||||
});
|
||||
return { clauses, params, keywords };
|
||||
}
|
||||
|
||||
async function loadShippedPackages(batch) {
|
||||
const { clauses, params } = buildWarehouseFilters();
|
||||
const rows = await cloudDatabase.querySlow(
|
||||
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
||||
e.logistic_id, e.logistic_company_name, e.logistic_status_name,
|
||||
e.order_status_name, e.order_labels,
|
||||
e.send_time, e.logistic_time, e.query_time,
|
||||
LEFT(ISNULL(e.last_logistic_info, ''), 300) AS last_info
|
||||
FROM dbo.jst_order_express_details e
|
||||
WHERE e.send_time >= @shipStart AND e.send_time < @shipEnd
|
||||
AND (${clauses.join(' OR ')})`,
|
||||
{ ...params, shipStart: batch.windowStart, shipEnd: batch.windowEnd },
|
||||
// 视图谓词下推一般,冷启动 20s;普通池 60s 池级超时 + 重试会把耗时翻倍
|
||||
{ connectRequestTimeoutMs: 300000 }
|
||||
);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
function emptySummary() {
|
||||
return {
|
||||
shippedOrders: 0,
|
||||
packageCount: 0,
|
||||
exceptionOrders: 0,
|
||||
exceptionPackages: 0,
|
||||
exceptionRate: 0,
|
||||
centerCount: 0,
|
||||
byType: {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 剔除口径(前端页脚直接展示,别在页面里再写一份)。
|
||||
* 两处返回(正常 / 未到核验时刻)共用,避免口径漂移。
|
||||
*/
|
||||
function exclusionMeta() {
|
||||
return {
|
||||
carriers: registry.EXCLUDED_CARRIERS,
|
||||
// 有条件剔除:「睢宁中通」只在睢宁仓考核,其它仓异地打单的剔
|
||||
conditionalCarriers: registry.CONDITIONAL_EXCLUDED_CARRIERS.map((r) => ({
|
||||
keyword: r.keyword,
|
||||
keepCenters: r.keepCenterKeys.map((key) => {
|
||||
const center = registry.CENTERS.find((c) => c.key === key);
|
||||
return center ? center.name : key;
|
||||
}),
|
||||
reason: r.reason,
|
||||
})),
|
||||
orderStatusKeywords: registry.EXCLUDED_ORDER_STATUS_KEYWORDS,
|
||||
orderLabelKeywords: registry.EXCLUDED_ORDER_LABEL_KEYWORDS,
|
||||
// 刷单 / 信誉单的标签判据
|
||||
orderTypeLabels: registry.EXCLUDED_ORDER_TYPE_LABELS.map((t) => t.label),
|
||||
};
|
||||
}
|
||||
|
||||
async function getLogisticsExceptions(options = {}) {
|
||||
const batch = registry.resolveBatch(options.date, options.now instanceof Date ? options.now : undefined);
|
||||
// 明细上限:列表默认 2000;导出可显式放宽到 EXPORT_DETAIL_LIMIT(见 getLogisticsExceptionExportRows)
|
||||
const limitCap = Math.min(
|
||||
Math.max(Number(options.maxLimit) || MAX_DETAIL_LIMIT, 1),
|
||||
EXPORT_DETAIL_LIMIT
|
||||
);
|
||||
const limit = Math.min(
|
||||
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||
limitCap
|
||||
);
|
||||
const force = options.force === true || options.force === '1' || options.force === 1;
|
||||
|
||||
const generatedAt = formatDateTime(registry.beijingNow());
|
||||
|
||||
// 未到 09:00:本批次还没跑,不拿当前快照冒充「09:00 的结论」
|
||||
if (!batch.started) {
|
||||
return {
|
||||
batch,
|
||||
notStarted: true,
|
||||
resolution: '09:00',
|
||||
summary: emptySummary(),
|
||||
centers: registry.CENTERS.map((c) => ({
|
||||
key: c.key, name: c.name, shippedOrders: 0, exceptionOrders: 0, exceptionRate: 0, byType: {},
|
||||
})),
|
||||
types: registry.EXCEPTION_TYPES,
|
||||
exclusions: {
|
||||
...exclusionMeta(),
|
||||
carrierPackages: 0,
|
||||
carrierOrders: 0,
|
||||
carrierReasons: {},
|
||||
voidedPackages: 0,
|
||||
voidedOrders: 0,
|
||||
voidedReasons: {},
|
||||
nonRealPackages: 0,
|
||||
nonRealOrders: 0,
|
||||
nonRealReasons: {},
|
||||
},
|
||||
orders: [],
|
||||
detailLimit: limit,
|
||||
truncated: false,
|
||||
freshness: { snapshotAt: '', ageMinutes: null, stale: false, coversDeadline: null, hint: '' },
|
||||
generatedAt,
|
||||
cached: false,
|
||||
cacheAgeSec: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = `${batch.verifyDate}|${limit}`;
|
||||
const cachedEntry = resultCache.get(cacheKey);
|
||||
if (!force && cachedEntry && Date.now() - cachedEntry.at < CACHE_TTL_MS) {
|
||||
return {
|
||||
...cachedEntry.data,
|
||||
cached: true,
|
||||
cacheAgeSec: Math.round((Date.now() - cachedEntry.at) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
const rows = await loadShippedPackages(batch);
|
||||
|
||||
// ── 归属主仓 + 去重 ──────────────────────────────────────────────────
|
||||
const centerStat = new Map();
|
||||
for (const center of registry.CENTERS) {
|
||||
centerStat.set(center.key, {
|
||||
key: center.key,
|
||||
name: center.name,
|
||||
shippedOrders: new Set(),
|
||||
exceptionOrders: new Set(),
|
||||
byType: {},
|
||||
packages: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const seenPackage = new Set();
|
||||
const exceptions = [];
|
||||
let packageCount = 0;
|
||||
const shippedOrderIds = new Set();
|
||||
let snapshotAt = '';
|
||||
|
||||
// 剔除统计(业务 2026-10-21:不考核的承运商 + 已取消/作废/拦截 + 刷单/信誉单)
|
||||
const skippedByCarrier = { packages: 0, orders: new Set(), reasons: {} };
|
||||
const skippedByVoid = { packages: 0, orders: new Set(), reasons: {} };
|
||||
const skippedByType = { packages: 0, orders: new Set(), reasons: {} };
|
||||
|
||||
for (const row of rows) {
|
||||
const queryTime = formatDateTime(row.query_time);
|
||||
if (queryTime && queryTime > snapshotAt) snapshotAt = queryTime;
|
||||
|
||||
const center = registry.resolveCenter(row.warehouse_name);
|
||||
if (!center) continue;
|
||||
|
||||
const oId = String(row.inner_order_id || '');
|
||||
const trackingNo = String(row.logistic_id || '');
|
||||
const packageKey = `${oId}|${trackingNo || row.send_time || ''}`;
|
||||
if (seenPackage.has(packageKey)) continue;
|
||||
seenPackage.add(packageKey);
|
||||
|
||||
// ① 不参与考核的承运商(唯品顺丰 / 武进安能 / 中铁 / 壹米滴答 / 自提 / 线下 / 京喜京东)
|
||||
// 这些要么轨迹不回传聚水潭、要么根本没有快递轨迹,留着只会刷假异常。
|
||||
// 「睢宁中通」是有条件的:睢宁仓照常考核,其它仓异地打单的剔(传 center.key 区分)。
|
||||
const carrierHit = registry.matchExcludedCarrier(row.logistic_company_name, center.key);
|
||||
if (carrierHit) {
|
||||
skippedByCarrier.packages += 1;
|
||||
skippedByCarrier.orders.add(oId);
|
||||
skippedByCarrier.reasons[carrierHit] = (skippedByCarrier.reasons[carrierHit] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ② 已取消 / 作废 / 拦截的订单 —— 单据已终止,不用再跟物流
|
||||
const voidReason = registry.matchVoidedOrder(row.order_status_name, row.order_labels);
|
||||
if (voidReason) {
|
||||
skippedByVoid.packages += 1;
|
||||
skippedByVoid.orders.add(oId);
|
||||
skippedByVoid.reasons[voidReason] = (skippedByVoid.reasons[voidReason] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ③ 刷单 / 信誉单 —— 订单本身不是真实发货,揽不揽收不代表仓库的物流能力
|
||||
const nonRealReason = registry.matchNonRealOrder(row.order_labels);
|
||||
if (nonRealReason) {
|
||||
skippedByType.packages += 1;
|
||||
skippedByType.orders.add(oId);
|
||||
skippedByType.reasons[nonRealReason] = (skippedByType.reasons[nonRealReason] || 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = centerStat.get(center.key);
|
||||
stat.shippedOrders.add(oId);
|
||||
stat.packages += 1;
|
||||
packageCount += 1;
|
||||
shippedOrderIds.add(oId);
|
||||
|
||||
const type = classify(row);
|
||||
if (!type) continue;
|
||||
|
||||
const sendAt = formatDateTime(row.send_time);
|
||||
const logisticAt = formatDateTime(row.logistic_time);
|
||||
const pendingMinutes = diffMinutes(sendAt, batch.deadline);
|
||||
const staleMinutes = diffMinutes(logisticAt, batch.deadline);
|
||||
const typeMeta = registry.TYPE_MAP.get(type) || { label: type, tone: 'default' };
|
||||
|
||||
stat.exceptionOrders.add(oId);
|
||||
stat.byType[type] = (stat.byType[type] || 0) + 1;
|
||||
|
||||
exceptions.push({
|
||||
oId,
|
||||
soId: String(row.so_id || ''),
|
||||
shopName: String(row.shop_name || ''),
|
||||
warehouseName: String(row.warehouse_name || ''),
|
||||
centerKey: center.key,
|
||||
centerName: center.name,
|
||||
carrier: String(row.logistic_company_name || ''),
|
||||
trackingNo,
|
||||
logisticStatus: String(row.logistic_status_name || ''),
|
||||
exceptionType: type,
|
||||
exceptionLabel: typeMeta.label,
|
||||
exceptionDesc: typeMeta.desc || '',
|
||||
tone: typeMeta.tone,
|
||||
sendTime: sendAt,
|
||||
lastLogisticTime: logisticAt,
|
||||
lastLogisticInfo: String(row.last_info || '').trim(),
|
||||
// 发货 → 核验时刻(小时,1 位小数):仓库看到「发了多久还没动静」
|
||||
pendingHours: pendingMinutes === null ? null : Math.round((pendingMinutes / 60) * 10) / 10,
|
||||
// 最后一条轨迹 → 核验时刻(小时):负数表示这条轨迹是核验之后才出现的
|
||||
staleHours: staleMinutes === null ? null : Math.round((staleMinutes / 60) * 10) / 10,
|
||||
});
|
||||
}
|
||||
|
||||
// 异常单最久未处理的排前面
|
||||
exceptions.sort((a, b) => (b.pendingHours || 0) - (a.pendingHours || 0));
|
||||
|
||||
// ── 汇总 ────────────────────────────────────────────────────────────
|
||||
const summary = emptySummary();
|
||||
const allExceptionOrders = new Set();
|
||||
summary.shippedOrders = shippedOrderIds.size;
|
||||
summary.packageCount = packageCount;
|
||||
|
||||
const centers = registry.CENTERS.map((center) => {
|
||||
const stat = centerStat.get(center.key);
|
||||
const byType = { ...stat.byType };
|
||||
for (const oid of stat.exceptionOrders) allExceptionOrders.add(oid);
|
||||
return {
|
||||
key: center.key,
|
||||
name: center.name,
|
||||
shippedOrders: stat.shippedOrders.size,
|
||||
exceptionOrders: stat.exceptionOrders.size,
|
||||
exceptionRate: stat.shippedOrders.size
|
||||
? Math.round((stat.exceptionOrders.size / stat.shippedOrders.size) * 1000) / 10
|
||||
: 0,
|
||||
byType,
|
||||
};
|
||||
});
|
||||
|
||||
summary.exceptionOrders = allExceptionOrders.size;
|
||||
summary.exceptionPackages = exceptions.length;
|
||||
summary.exceptionRate = summary.shippedOrders
|
||||
? Math.round((summary.exceptionOrders / summary.shippedOrders) * 1000) / 10
|
||||
: 0;
|
||||
summary.centerCount = centers.filter((c) => c.exceptionOrders > 0).length;
|
||||
for (const item of exceptions) {
|
||||
summary.byType[item.exceptionType] = (summary.byType[item.exceptionType] || 0) + 1;
|
||||
}
|
||||
|
||||
// ── 新鲜度 ──────────────────────────────────────────────────────────
|
||||
const ageMinutes = snapshotAt ? diffMinutes(snapshotAt, generatedAt) : null;
|
||||
const stale = ageMinutes !== null && ageMinutes > SNAPSHOT_STALE_MINUTES;
|
||||
const coversDeadline = snapshotAt ? snapshotAt >= batch.deadline : null;
|
||||
let hint = '';
|
||||
if (coversDeadline === false) {
|
||||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)},没覆盖本轮 ${batch.deadline.slice(11, 16)} 核验时刻 ——`
|
||||
+ ' 之后才揽收/更新的轨迹没进来,异常量会偏大,请以上面「数据更新于」为准,或等上游同步后刷新。';
|
||||
} else if (stale) {
|
||||
hint = `物流快照最新只到 ${snapshotAt.slice(11, 16)}(已 ${Math.round((ageMinutes / 60) * 10) / 10} 小时未刷新),`
|
||||
+ '状态可能滞后,请以聚水潭实时物流为准。';
|
||||
}
|
||||
|
||||
const payload = {
|
||||
batch,
|
||||
notStarted: false,
|
||||
resolution: '09:00',
|
||||
summary,
|
||||
centers,
|
||||
// 异常类型字典(前端图例/筛选器直接用,避免两边各写一套文案)
|
||||
types: registry.EXCEPTION_TYPES,
|
||||
// 业务指定的剔除口径(前端原样展示,别在页面里硬编码一份)
|
||||
exclusions: {
|
||||
...exclusionMeta(),
|
||||
// 本批实际剔掉了多少(包裹 / 订单 / 原因明细)
|
||||
carrierPackages: skippedByCarrier.packages,
|
||||
carrierOrders: skippedByCarrier.orders.size,
|
||||
carrierReasons: skippedByCarrier.reasons,
|
||||
voidedPackages: skippedByVoid.packages,
|
||||
voidedOrders: skippedByVoid.orders.size,
|
||||
voidedReasons: skippedByVoid.reasons,
|
||||
nonRealPackages: skippedByType.packages,
|
||||
nonRealOrders: skippedByType.orders.size,
|
||||
nonRealReasons: skippedByType.reasons,
|
||||
},
|
||||
orders: exceptions.slice(0, limit),
|
||||
detailLimit: limit,
|
||||
truncated: exceptions.length > limit,
|
||||
freshness: {
|
||||
snapshotAt,
|
||||
ageMinutes,
|
||||
stale,
|
||||
coversDeadline,
|
||||
hint,
|
||||
},
|
||||
generatedAt,
|
||||
cached: false,
|
||||
cacheAgeSec: 0,
|
||||
};
|
||||
|
||||
resultCache.set(cacheKey, { at: Date.now(), data: payload });
|
||||
// 超过上限时淘汰最旧的一条(条目很少,排序开销可忽略)
|
||||
if (resultCache.size > CACHE_MAX_ENTRIES) {
|
||||
let oldestKey = '';
|
||||
let oldestAt = Infinity;
|
||||
for (const [key, entry] of resultCache.entries()) {
|
||||
if (entry.at < oldestAt) { oldestAt = entry.at; oldestKey = key; }
|
||||
}
|
||||
if (oldestKey) resultCache.delete(oldestKey);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出用:返回**不分页**的异常明细(口径与页面完全一致,只是把明细上限放开)。
|
||||
*
|
||||
* 页面列表默认只取 500 条(够看),导出必须拿全 —— 否则业务拿到的 Excel
|
||||
* 和数据对不上,是最容易被投诉的点。这里复用 `getLogisticsExceptions`,
|
||||
* 不另写一套查询,避免「导出和页面两套口径」。
|
||||
*
|
||||
* @returns {Promise<{rows:Array,total:number,truncated:boolean,batch:object}>}
|
||||
*/
|
||||
async function getLogisticsExceptionExportRows(options = {}) {
|
||||
const data = await getLogisticsExceptions({
|
||||
...options,
|
||||
limit: EXPORT_DETAIL_LIMIT,
|
||||
maxLimit: EXPORT_DETAIL_LIMIT,
|
||||
});
|
||||
const rows = Array.isArray(data.orders) ? data.orders : [];
|
||||
const total = Number(data.summary?.exceptionPackages) || rows.length;
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
// 异常包裹数超过上限时如实告知,页面会提示「结果已截断」
|
||||
truncated: rows.length < total,
|
||||
detailLimit: EXPORT_DETAIL_LIMIT,
|
||||
batch: data.batch,
|
||||
notStarted: data.notStarted,
|
||||
summary: data.summary,
|
||||
centers: data.centers,
|
||||
types: data.types,
|
||||
exclusions: data.exclusions,
|
||||
freshness: data.freshness,
|
||||
generatedAt: data.generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getLogisticsExceptions,
|
||||
getLogisticsExceptionExportRows,
|
||||
clearLogisticsExceptionCache,
|
||||
classify,
|
||||
formatDateTime,
|
||||
diffMinutes,
|
||||
};
|
||||
@ -1,584 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-20):
|
||||
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品包装项目仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单审核时间**分轮核算。
|
||||
* 今天 07:00 前审核的订单 → 13:00 核验;**截止今天 16:00 前审核**的单(含今天之前未发的)→ 18:00 核验;
|
||||
* 昨天及更早审核、至今未发的(历史未发单)两个轮次都算。
|
||||
* (2026-10-20 三次调整:
|
||||
* 二次 —— 核验时刻由 12:00/16:00 改为 13:00/18:00;
|
||||
* 三次 —— ① **窗口按「审核时间」判**(不是创建时间):业务指出的 9818476
|
||||
* (创建 10-18、审核 10-20 13:06)这类下午才审的单,不能进 13:00 轮;
|
||||
* ② **取消「满 24 小时才算超时」的门槛**:到核验时刻未发即超时;
|
||||
* ③ **历史未发单一律算**,不卡「今天 00:00 起」。
|
||||
* 业务原话:「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到
|
||||
* 16 点审核的订单 18:00 核验;然后再看历史未发的订单」。)
|
||||
* 展示口径:**只显示超时未发**(业务确认)。
|
||||
*
|
||||
* 业务确认的口径(2026-10-20):
|
||||
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东自营仓/运营C专用仓 等)
|
||||
* 例外:「京仓-横林二仓京东云仓」业务指定**排除**,不计入任何主仓(见下)
|
||||
* ② 崔桥工厂半成品仓 —— 系统无同名仓,**接入「简尚-崔桥工厂半成品包装项目仓」**(15718196)
|
||||
* ③ 展示结构 —— **只显示超时未发**
|
||||
* ④ 明细列 —— 要同时给「内部单号」(o_id) 与「线上订单号」(so_id)
|
||||
*
|
||||
* ── 数据源 ────────────────────────────────────────────────────────────────
|
||||
* 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头)
|
||||
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||
* 字段口径(2026-10-20 四次修正,全部以聚水潭「销售出库单」页面为准):
|
||||
* · 创建时间:**销售出库单的 `created`**(出库单创建时间)
|
||||
* ⚠️ 不是 `order_date`(平台下单时间);
|
||||
* ⚠️ 更不是 `modified` —— 实测数仓每天凌晨把整批订单的 modified 刷成
|
||||
* 02:2x / 04:3x(样本 9765107 等 7 单全是 10-20 04:39~04:40,
|
||||
* 实际下单在 10-13~10-18),拿它当创建时间会让所有单的时间被抹平;
|
||||
* ⚠️ 也不是 `created/createTime/updateTime`(数仓 5 分钟批量灌库时间戳)。
|
||||
* 待出库的单在出库单表里还没有记录 → 回退 `order_date` 并在接口里标注来源。
|
||||
* · 已出库:销售出库单表能查到该 o_id(有 `io_date`),**或**订单 `send_date`
|
||||
* 非空,**或**订单状态已是 `Sent` / `Delivering`。
|
||||
* · 待出库:以上三条都不满足。
|
||||
* · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓)
|
||||
* ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属,
|
||||
* 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。
|
||||
*
|
||||
* ── 仓库归属(按名称包含匹配,最长匹配优先)────────────────────────────────
|
||||
* `rpt.jst_warehouseInfo` 实测(2026-10-20):
|
||||
* 横林二仓 15033302 + 15301814/15835893/16040225/16132681
|
||||
* ⚠️ 15197138「京仓-横林二仓京东云仓」**业务指定排除**(见 EXCLUDED_WAREHOUSE_KEYWORDS)
|
||||
* 横林三仓 15043561
|
||||
* 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**)
|
||||
* 崔桥工厂仓 15038426 + 15301815/16040410
|
||||
* 崔桥工厂品牌仓 15870495
|
||||
* 崔桥工厂半成品包装项目仓 15718196(原名「简尚-崔桥工厂半成品包装项目仓」)
|
||||
* 新苏北仓 15038347 + 15315031/15301812
|
||||
* 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911
|
||||
* 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076
|
||||
* 山东品牌仓 15182705
|
||||
* 河南洛宁仓 15539084
|
||||
* ⟹ 仓库 ID → 主仓 的映射**在启动时从库表读取并缓存**(不用手写 ID 常量,
|
||||
* 避免上游新增子仓后要改代码;新增子仓只要名字里带主仓关键词就会自动归入)。
|
||||
* 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。
|
||||
*
|
||||
* ── 时效判定 ──────────────────────────────────────────────────────────────
|
||||
* 两个时刻**必须区分**(2026-10-20 二次调整后最容易搞混的地方):
|
||||
* `verifyHour` —— 几点核验(13:00 / 18:00)。判定「发了没发」「超时多久」都用它。
|
||||
* `windowEndHour` —— 订单窗口上界(07:00 / 16:00)。只核验该时刻**前审核**的单。
|
||||
* 取当前已生效且最晚的那一轮;未到 13:00 时无核验轮次:
|
||||
* 13:00 → 核验「今天 00:00~07:00 审核」的订单 + 历史未发
|
||||
* 18:00 → 核验「截止今天 16:00 前审核」的订单 + 今天之前审核仍未发的(历史未发)
|
||||
* ⚠️ 不是「今天 00:00~16:00」这一个自然日切片,历史未发也在内;
|
||||
* 山东两仓窗口上界 15:00、河南洛宁 13:00(核验时刻仍是 18:00)。
|
||||
* 判定(2026-10-20 三次修正 —— **没有「满 24 小时」门槛**):
|
||||
* · 超时未出库 = 到核验时刻仍未发货
|
||||
* (出库单表查不到 + `send_time` 为空 + 状态不是 Sent/Delivering)
|
||||
* 🔥 第七版错在加了「创建满 24h 才算超时」,导致 13:00 轮永远
|
||||
* 看不到当天 7 点那批(才等了 6 小时),业务直接打回。
|
||||
* · 已出库 = 出库单表有记录 / send_date 非空 / 状态 Sent、Delivering
|
||||
* · 拆单父单(is_split=1)、被合并单(is_merge=1)**一律排除** ——
|
||||
* 它们自己永不发货,货由子单/主单发出,算进来就是冤枉仓库。
|
||||
* 业务只要「超时未出库」,所以列表只返回待出库的超时单。
|
||||
*
|
||||
* ── 时区 ──────────────────────────────────────────────────────────────────
|
||||
* 订单时间列存的是**东八区本地时间**(实测 `order_date` 与 `created` 相差十几秒,
|
||||
* 均为北京时间)。因此 SQL 里统一用 `CAST(GETDATE() AS date)` 取「今天」,
|
||||
* 服务器与数据库都在东八区;**不要**在 Node 侧用 UTC 日期做判断。
|
||||
*/
|
||||
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
|
||||
// ── 主仓定义(业务给定的 11 个)──────────────────────────────────────────
|
||||
// keywords:该主仓在 rpt.jst_warehouseInfo.name 里应包含的关键词(任一命中即归属)
|
||||
// 顺序即展示顺序;`match` 用最长关键词优先,所以「横林二仓品牌仓」不会被「横林二仓」抢走。
|
||||
const CENTERS = Object.freeze([
|
||||
{ key: 'HL2', name: '横林二仓', keywords: ['横林二仓'] },
|
||||
{ key: 'HL3', name: '横林三仓', keywords: ['横林三仓'] },
|
||||
{ key: 'HL2_BRAND', name: '横林二仓品牌仓', keywords: ['横林二仓品牌仓'] },
|
||||
{ key: 'CQ', name: '崔桥工厂仓', keywords: ['崔桥工厂仓'] },
|
||||
{ key: 'CQ_BRAND', name: '崔桥工厂品牌仓', keywords: ['崔桥工厂品牌仓'] },
|
||||
// 2026-10-20 二次调整:原先「崔桥工厂半成品仓」在仓库表里没有同名仓,只能留空占位;
|
||||
// 业务指定用「简尚-崔桥工厂半成品包装项目仓」(wms_co_id=15718196, Id=198) 替代接入。
|
||||
// ⚠️ 该 wms_co_id 同时也是配件仓(MKH 配件库存来源),但这里是**订单发货仓**维度,
|
||||
// 统计的是「从该仓发出的订单」,与配件库存口径无关,不要混。
|
||||
{ key: 'CQ_WIP', name: '崔桥工厂半成品包装项目仓', keywords: ['崔桥工厂半成品'] },
|
||||
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
|
||||
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
|
||||
// 🔥 山东两个仓 18:00 轮的窗口更早(15:00 前,不是 16:00)——
|
||||
// 业务 2026-10-20:「山东青岗集仓和山东品牌仓,这 2 个仓库时间不是 16:00 前,
|
||||
// 是 15:00 前订单」。核验时刻仍是 18:00,只是卡单的范围窄一小时。
|
||||
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'], windowEndHourByRound: { AFTERNOON: 15 } },
|
||||
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'], windowEndHourByRound: { AFTERNOON: 15 } },
|
||||
// 🔥 河南洛宁仓更早:18:00 轮只看 13:00 前创建的单(业务同一条指令)
|
||||
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'], windowEndHourByRound: { AFTERNOON: 13 } },
|
||||
]);
|
||||
|
||||
// 各主仓在某一轮的窗口上界小时。
|
||||
// 返回 null 表示「用轮次默认值」(ROUNDS[].windowEndHour)。
|
||||
// ⚠️ 13:00 轮没有例外,全部按 07:00;只有 18:00 轮有三处更早的上界。
|
||||
function centerWindowEndHour(centerKey, roundKey) {
|
||||
const center = CENTERS.find((c) => c.key === centerKey);
|
||||
const override = center && center.windowEndHourByRound
|
||||
? center.windowEndHourByRound[roundKey]
|
||||
: undefined;
|
||||
return Number.isFinite(override) ? override : null;
|
||||
}
|
||||
|
||||
/** 该主仓在这一轮的窗口上界('YYYY-MM-DD HH:mm:00'),没有例外就用轮次默认 */
|
||||
function centerWindowEnd(centerKey, round) {
|
||||
const hour = centerWindowEndHour(centerKey, round.key);
|
||||
if (hour === null) return round.windowEnd;
|
||||
return `${round.date} ${String(hour).padStart(2, '0')}:00:00`;
|
||||
}
|
||||
|
||||
// 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段,
|
||||
// 靠关键词长度倒序匹配即可(见 resolveCenter)。
|
||||
const SORTED_KEYWORDS = CENTERS
|
||||
.filter((c) => c.keywords.length)
|
||||
.flatMap((c) => c.keywords.map((kw) => ({ kw, center: c })))
|
||||
.sort((a, b) => b.kw.length - a.kw.length);
|
||||
|
||||
// ── 排除仓(业务指定,2026-10-20)──────────────────────────────────────────
|
||||
// 「京仓-横林二仓京东云仓」(wms_co_id=15197138, Id=129) 虽然名字里带「横林二仓」,
|
||||
// 但属于京东云仓代运营,**不纳入横林二仓的时效考核**(业务明确要求排除)。
|
||||
// ⚠️ 用完整片段「横林二仓京东云仓」而不是「京东云仓」:
|
||||
// 仓库表里还有「京仓-崔桥工厂京东云仓」(15283032)、
|
||||
// 「京仓-山东青岗集京东云仓」(15283033),写短了会把它们一起排掉;
|
||||
// 也不会误伤「简尚-横林二仓京东仓」(16040225) 与「京仓-横林二仓京东自营仓」(15835893)。
|
||||
const EXCLUDED_WAREHOUSE_KEYWORDS = Object.freeze([
|
||||
'横林二仓京东云仓',
|
||||
]);
|
||||
|
||||
// 长关键词优先,避免以后加了「XX京东云仓」被短词抢先
|
||||
const SORTED_EXCLUSIONS = [...EXCLUDED_WAREHOUSE_KEYWORDS].sort((a, b) => b.length - a.length);
|
||||
|
||||
// ── 时效轮次(业务给定,2026-10-20 二次调整)──────────────────────────────
|
||||
// verifyHour :当天几点开始核验(13:00 / 18:00)
|
||||
// windowEndHour :只核验「**审核时间**不晚于该时刻」的订单(07:00 / 16:00)
|
||||
// ⚠️ 两者**不是同一个东西**,别再用一个字段兼着当:
|
||||
// 旧实现拿 12:00 同时当核验时刻和窗口上界,导致「12:00 轮」实际把
|
||||
// 07:00~12:00 创建的单也算进来了,与页面上写的「核验 07:00 前」不一致。
|
||||
// ⚠️ 也不能在 windowEnd 之后再叠「满 N 小时」门槛 —— 13:00 轮里 07:00 审核的单
|
||||
// 只等了 6 小时,加门槛就永远不出现在超时列表里(第七版被打回的原因)。
|
||||
const ROUNDS = Object.freeze([
|
||||
{ key: 'NOON', label: '13:00 轮', verifyHour: 13, windowEndHour: 7 },
|
||||
{ key: 'AFTERNOON', label: '18:00 轮', verifyHour: 18, windowEndHour: 16 },
|
||||
]);
|
||||
|
||||
// ── 订单窗口下界(含历史的天数,**业务可调**)────────────────────────────
|
||||
// 业务口径(2026-10-20 三次纠偏):「截止某时刻创建、至今仍未发货」—— 不按自然日切,
|
||||
// 昨天、前天乃至更早挂着没发的单都要算进来(滚动累积的待发池)。
|
||||
//
|
||||
// ⚠️ 但不能无限往前:rpt 下全是视图、没有索引,扫得越远越慢。2026-10-20 实测
|
||||
// (jst_Orders + jst_qmcust 并行,含已发单的窗口全量):
|
||||
// 近 7 天 → 约 5s 近 30 天 → 约 45s
|
||||
// 近 90 天 → 约 130s 近 365 天 → 约 500s(基本等于页面卡死)
|
||||
// 所以默认取 30 天:覆盖「今日 + 近一个月累积未发」,兼顾口径与可用性。
|
||||
//
|
||||
// 同时实测 28 个目标仓的**全部未发单**:共 1,228 条,其中近 7 天 257、
|
||||
// 近 30 天 496、近 90 天 792、更早 436(最早 2025-10-24)。
|
||||
// ⟹ 30 天能看到约 40%;要看更久的老单,把下面数字改大,代价是首屏更慢。
|
||||
const HISTORY_LOOKBACK_DAYS = 30;
|
||||
|
||||
// 只排除取消/合并单;Question 仍计入(与 mkhBomService 待发货口径一致)
|
||||
//
|
||||
// 🔥 Split(拆单):**必须排除**。拆单是「父单被拆成子单」的中间态,父单本身
|
||||
// 不会发货(`send_date` 永远为空),子单才是真正进仓发货的那一单。
|
||||
// 2026-10-20 实测:不加这个条件,一年前的 Split 父单全被算成「超时未发」,
|
||||
// 全库未发 Split 单占比极高 —— 会让页面变成上万条的垃圾列表。
|
||||
// ⚠️ 与 mkhBomService 的差异:那边是「预警今天要发的货」,Split 父单不带真实
|
||||
// 发货需求,被算进去也无害;这边是「考核仓库时效」,误算就要背锅,必须排除。
|
||||
const EXCLUDED_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split')`;
|
||||
|
||||
// ── 「已发货 / 待发货」状态口径(2026-10-20 四次修正,业务反馈数据错误)────
|
||||
// 🔥 之前只排除了 Cancelled/Merged/Split,靠 `send_date IS NULL` 判「未发」,
|
||||
// 实测**大量已发货的单被算成超时未发**(业务一眼就看出来了)。原因有三个:
|
||||
// ① `send_date` 在部分店铺/平台不回写,订单状态已经是 Sent/Delivering,
|
||||
// send_date 却还是 NULL(样本 9838341:Delivering,send_date 空);
|
||||
// ② 拆单父单 `status` 不是 'Split' 而是 'Delivering'(父单随子单状态走),
|
||||
// `is_split=1` 才是真标记(样本 9838537 就是这样);
|
||||
// ③ 合并单 `is_merge=1` 同理,被并入主单,自己永不发货。
|
||||
// 订单状态实测分布(2026-10-20,近 7 天,两表合计):
|
||||
// Sent 61,833 / Cancelled 22,292 / Split 4,927 / Delivering 2,567 /
|
||||
// Question 1,500 / Merged 682 / WaitOuterSent 30 / WaitConfirm 12
|
||||
// ⟹ 判定「已发货」= status ∈ (Sent, Delivering) **或** send_date 非空
|
||||
// **或** 销售出库单表能查到该 o_id(见 OUTBOUND_TABLES)
|
||||
const SHIPPED_STATUS = Object.freeze(['Sent', 'Delivering']);
|
||||
|
||||
// 待出库候选:把「已发货」状态一并排除。判定顺序是
|
||||
// ① SQL 先粗筛(下面这个条件 + send_date IS NULL + 非拆单/合并)
|
||||
// ② Node 侧再查销售出库单表精确剔除(有出库记录 = 其实已出库)
|
||||
// ③ Node 侧再把 Question(疑问单)分流到「业务挂起」,**不计超时**
|
||||
// —— 注意 Question 必须留在候选里,否则统计不出挂起单数;
|
||||
// 它只是不进 orders(超时明细),走 heldCount / heldOrders。
|
||||
const PENDING_STATUS_SQL = `ISNULL(o.status, N'') NOT IN (N'Cancelled', N'Merged', N'Split', N'Sent', N'Delivering')`;
|
||||
|
||||
// 拆单父单 / 被合并单:自己**永不发货**,货由子单或主单发出。
|
||||
// 只排 status 不够(父单状态会跟着子单变成 Delivering),必须看标记位。
|
||||
// ⚠️ is_split / is_merge 是 bit 且可能为 NULL —— 必须 ISNULL 兜底,
|
||||
// 否则 `o.is_split = 0` 会把 NULL 行全部丢掉(实测会少掉大半候选)。
|
||||
const NOT_SPLIT_MERGED_SQL = `ISNULL(o.is_split, 0) = 0 AND ISNULL(o.is_merge, 0) = 0`;
|
||||
|
||||
// ── 销售出库单表(聚水潭「销售出库单」页面的数据来源)──────────────────────
|
||||
// 业务明确要求(2026-10-20):「已出库 / 待出库」与「创建时间」都以聚水潭
|
||||
// **销售出库单**页面为准,不再用订单表的 send_date 猜。
|
||||
//
|
||||
// 两张表与订单表同构、店铺互斥:
|
||||
// rpt.jst_salesOutboundInfo ← jst_Orders(京东/拼多多/抖音/得物/唯品会),103.7 万行
|
||||
// rpt.jst_qmcust_salesoutbound ← jst_qmcust_orderlist(天猫/淘宝/1688),39.8 万行
|
||||
// 字段口径(2026-10-20 实测确认):
|
||||
// · `created` —— **出库单创建时间**(业务在聚水潭出库单页面看到的那个),
|
||||
// 时点分布自然(8~23 点均匀,凌晨 4 点有一波补录),可用;
|
||||
// · `io_date` —— **出库时间**(真正发货那一刻);
|
||||
// · `modified` —— ⚠️ 会被数仓每天凌晨批量刷新(实测全被刷成 02:2x / 04:3x),
|
||||
// 与订单表的 modified 一样**不可当作创建时间**;
|
||||
// · **两张表都只含「已出库」的单**:103.7 万行里 io_date 为 0 条空值。
|
||||
// ⟹ 「待出库」无法从出库单表直接查,只能靠「查不到 = 还没出库」反推。
|
||||
const OUTBOUND_TABLES = Object.freeze({
|
||||
jst_Orders: 'jst_salesOutboundInfo',
|
||||
jst_qmcust_orderlist: 'jst_qmcust_salesoutbound',
|
||||
});
|
||||
|
||||
// ── 聚水潭「订单实时状态」表(2026-10-20 五次修正:按审核时间核算)──────────
|
||||
// 🔥 前面三版的根因都在这里:`jst_Orders.status` / `jst_qmcust_orderlist.status`
|
||||
// 是**下单那一刻的快照**,之后聚水潭侧取消、售后、审核都不会回写。
|
||||
// 实测(2026-10-20):业务反馈「聚水潭查不到」的 6 单,订单表里
|
||||
// status 全是 `WaitConfirm`,而在本表里真实状态是 **已取消**(发货前售后);
|
||||
// 另有 1 单真实状态是 **异常**。上一版把它们当成「待出库超时」报了出来。
|
||||
//
|
||||
// `rpt.jst_order_express_details`(402 万行,query_time 准实时,最近一次今天 11:55)
|
||||
// 是聚水潭订单状态的准实时镜像,字段语义(近 30 天实测覆盖率):
|
||||
// · `order_status_name` —— 聚水潭真实订单状态
|
||||
// 已发货 269,991(send_time 100%)|已取消 64,440|异常 6,669
|
||||
// 发货中 2,448(first_oa 99.96%、send_time 0)|已支付待审核 833
|
||||
// · `first_oa_confirm_time` —— **订单审核时间**(首次 OA 确认)= 业务要的起算点
|
||||
// · `last_oa_confirm_time` —— 最后一次审核(重新审核会刷新)
|
||||
// · `send_time` —— **发货时间**(已发货单 100% 有值,未发单必为空)
|
||||
// · `warehouse_name` —— 发货仓名称(可直接套 CENTERS 关键词匹配)
|
||||
// · `logistic_status_name` —— 物流状态(无物流单 / 未揽收 / 运输中 …)
|
||||
// ⚠️ 本表**没有金额**,金额要回订单表按 `o_id = inner_order_id` 取。
|
||||
const ORDER_STATE_TABLE = 'jst_order_express_details';
|
||||
|
||||
// 「发货中」= 已审核、仓库还没发货 —— **唯一该考核仓库的状态**
|
||||
const SHIPPING_STATUS = '发货中';
|
||||
// 「已支付待审核」= 还没审单,仓库根本没接到 → 不计超时(单列为「待审核」)
|
||||
const UNAUDITED_STATUS = '已支付待审核';
|
||||
// 「异常」= 订单异常(缺货/地址/风控等),有审核时间的单列「异常待处理」,不计仓库超时
|
||||
const ABNORMAL_STATUS = '异常';
|
||||
// 不进考核也不展示的状态:已发货(已发)、已取消(业务侧取消/发货前售后)、
|
||||
// 等供销商|外仓发货(不是本仓发)
|
||||
// 🔥 「已取消」(业务口头叫**作废单**)必须排除(2026-10-20 业务定:「作废订单不用考核」):
|
||||
// 这类单是客户付款后取消 / 发货前售后(标签带「发货前售后」),仓库根本不会发货。
|
||||
// 2026-10-20 实测:近 30 天已取消 64,440 单,量级远大于真正该考核的「发货中」2,448 单,
|
||||
// 漏掉这个排除,整个页面会被作废单淹没。
|
||||
const IGNORED_STATES = Object.freeze(['已发货', '已取消', '等供销商|外仓发货']);
|
||||
|
||||
// ── 起算点:聚水潭「创建时间」──────────────────────────────────────────────
|
||||
// 业务 2026-10-20 最终定版:「起算点是聚水潭的创建时间,不是审核时间」。
|
||||
//
|
||||
// ⚠️ 状态表 `jst_order_express_details` **没有**聚水潭创建时间,必须回订单表补:
|
||||
// · `createdAt`(本表那个 datetime2)—— ❌ **不是**订单创建时间。
|
||||
// 实测比付款晚 1.5~3.5 小时(261019-… pay 21:05:16 / createdAt 次日 00:23:51),
|
||||
// 是**同步落库时间**,用了会把起算点整体推后几小时。
|
||||
// · `pay_time` —— ❌ 付款时间,业务明确说「这一单的创建还是按照付款时间来的」,
|
||||
// 要换掉。
|
||||
// · ✅ `jst_Orders.created` / `jst_qmcust_orderlist.created` —— **聚水潭创建时间**,
|
||||
// 稳定比下单/付款晚 15~30 秒(抽样 00:07:23 → 00:07:41),
|
||||
// 这才是聚水潭订单列表里「创建时间」那一列。
|
||||
//
|
||||
// 性能(2026-10-20 实测 9,676 个候选):
|
||||
// 只投影 `o_id, created` 两列、分批 200 → jst_Orders **14.5s** + qmcust **2.9s**。
|
||||
// 🔥 千万别顺手把 pay_amount / labels 一起取出来:同样 IN 400 个只要 **38.7s**。
|
||||
const CREATED_SOURCE_TABLES = Object.freeze(['jst_Orders', 'jst_qmcust_orderlist']);
|
||||
// 分批大小:200 实测最快(再大收益递减,再小批数太多)
|
||||
const CREATED_BACKFILL_BATCH = 200;
|
||||
|
||||
// ── 订单表里的「作废」状态:这些单不会由本单发货,必须剔除 ─────────────────
|
||||
// 业务 2026-10-20 反馈 so_id 5127276315127080023(内部单号 9486414)是作废单,
|
||||
// 却出现在超时未发货里。四张表对照后的真相:
|
||||
// · 状态表 jst_order_express_details:order_status_name=**发货中**、send_time 空
|
||||
// ⟹ 只信状态表的话,这单就是「超时未发」—— 我们就是这么误判的;
|
||||
// · 订单表 jst_qmcust_orderlist:status=**Merged**、send_date=2026-09-22 06:54:49;
|
||||
// · 销售出库单:两张表**都有**出库记录(io_date 2026-09-22 10:59:55)。
|
||||
// ⟹ 根因:状态表的 `send_time` 对**被合并单 / 拆单父单不回写**,
|
||||
// 单子早随主单发走了,状态表还停在「发货中」。
|
||||
// ⟹ 所以除了状态表的 send_time,还**必须**回订单表看 status 与合并/拆单标记位,
|
||||
// 以及查销售出库单(业务最早的口径:有出库单 = 已出库)。
|
||||
// 🔥 2026-10-22 补 `Question` —— **但不能塞进这个数组**:
|
||||
// 这个数组在「状态分类之前」就判定,会把状态表「异常」的单(订单表常是 Question)
|
||||
// 从「异常待处理」清单里一起吞掉(实测 heldCount 472 → 10,少掉 462 单)。
|
||||
// ⟹ Question 单独在 `state === '发货中'` 之后判,见 VOID_QUESTION_STATUS。
|
||||
const VOID_ORDER_STATUS = Object.freeze(['Merged', 'Split', 'Cancelled']);
|
||||
|
||||
// 订单表 status=Question(聚水潭后台显示「已作废/问题单」)—— 业务 2026-10-22 确认:
|
||||
// so_id 3655421011854634(9823980) / 3658479011191285(9880810) 在后台都已作废,
|
||||
// 而状态表 order_status_name 仍停在「发货中」+ send_time 空、售后「暂无售后」
|
||||
// ⟹ 上游状态没同步,只信状态表判不出来。仅在「发货中」分支生效,不影响异常清单。
|
||||
const VOID_QUESTION_STATUS = 'Question';
|
||||
|
||||
// 订单表 `labels` 里出现这些关键词,等价于「客户发货前退款/取消」= 作废,不考核仓库。
|
||||
// 实测 9823980 的 labels = 「发货前售后,大单待确认,店铺-京东,晚发赔,禁止拆分」;
|
||||
// 已取消单(order_status_name='已取消')几乎全都带这个标签,可作交叉验证。
|
||||
// ⚠️ `labels` 是长文本列,回订单表补它会让 backfill 变慢(见 service 里的投影说明),
|
||||
// 所以只投影一个 `CASE WHEN ... THEN 1 ELSE 0 END` 布尔列,不把原文传回来。
|
||||
const VOID_ORDER_LABEL_KEYWORDS = Object.freeze(['发货前售后']);
|
||||
|
||||
// 状态表 `aftersale_status_name` 的「已作废」—— 售后维度最权威的作废标记。
|
||||
// 目前带这个值的单在订单表基本都是 Sent(已被其它判据剔除),这里是防御性兜底:
|
||||
// 万一哪天出现「发货中 + 已作废 + 订单表也不是 Sent」,不至于再被算成超时。
|
||||
const AFTERSALE_VOID_STATUS = '已作废';
|
||||
|
||||
// 24 小时发货时效 —— ⚠️ 仅用于**页面命名/路由**,**不参与判定**。
|
||||
// 2026-10-20 三次调整后判定改成「到 13:00 / 18:00 核验时刻仍未发即超时」,
|
||||
// 没有滚动 24 小时窗口(见 SHIP_TIMELINESS_HOURS 的历史误用记录)。
|
||||
const SHIP_TIMELINESS_HOURS = 24;
|
||||
|
||||
const WAREHOUSE_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
let warehouseCache = { at: 0, rows: [] };
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
// 🔥 北京时间(UTC+8)偏移,分钟
|
||||
const BEIJING_OFFSET_MINUTES = 480;
|
||||
|
||||
/**
|
||||
* 取「按北京时间读出来」的当前时刻。
|
||||
*
|
||||
* 🔥 为什么不能直接 `new Date()`(2026-10-21 生产实测踩坑):
|
||||
* 生产容器 `/etc/timezone = Etc/UTC`(date = UTC),而 MSSQL `GETDATE()` 返回**北京时间**,
|
||||
* mssql 驱动 `useUTC:false` 又把 DB 值构造成「墙钟当 UTC」的 Date。
|
||||
* 于是「服务器 now」比「库里的时间」慢 8 小时 —— **北京时间每天 00:00~08:00,
|
||||
* 服务端取到的「今天」是昨天**,轮次窗口 / 核验时刻整体错位
|
||||
* (实测 08:04 跑 13:00 轮得 overdueUnshipped=3895,本地北京时区同一轮只有 6 单)。
|
||||
*
|
||||
* 做法:把真实时刻平移成一个「本地 getter 读出来正好是北京时间」的 Date,
|
||||
* 这样 `getHours()/getDate()/formatDateTime()` 与库里的字符串可以直接比较。
|
||||
* 本机已是东八区时平移量为 0,行为不变(测试无需改)。
|
||||
*
|
||||
* @param {Date} [base] 真实时刻,默认 `new Date()`
|
||||
* @returns {Date} 本地 getter = 北京时间的 Date
|
||||
*/
|
||||
function beijingNow(base = new Date()) {
|
||||
const shift = BEIJING_OFFSET_MINUTES + base.getTimezoneOffset();
|
||||
return shift === 0 ? base : new Date(base.getTime() + shift * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用「关键词最长匹配」把仓库名归到主仓。
|
||||
*
|
||||
* ⚠️ **先判排除、再判归属**:被排除的仓(如「京仓-横林二仓京东云仓」)
|
||||
* 即使名字里带主仓关键词,也直接返回 null —— 它的订单不计入任何主仓。
|
||||
*
|
||||
* @returns {object|null} CENTERS 里的元素;无法归属或被排除返回 null
|
||||
*/
|
||||
function resolveCenter(warehouseName) {
|
||||
if (!warehouseName) return null;
|
||||
const name = String(warehouseName);
|
||||
for (const kw of SORTED_EXCLUSIONS) {
|
||||
if (name.includes(kw)) return null;
|
||||
}
|
||||
for (const item of SORTED_KEYWORDS) {
|
||||
if (name.includes(item.kw)) return item.center;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取仓库表并建立「主仓 → wms_co_id 列表」映射。
|
||||
* 缓存 1 小时;仓库表变更不频繁,且新增子仓会自动被关键词捞到。
|
||||
*/
|
||||
async function loadCenterWarehouseMap(force = false) {
|
||||
const fresh = Date.now() - warehouseCache.at < WAREHOUSE_CACHE_TTL_MS;
|
||||
if (!force && fresh && warehouseCache.rows.length) {
|
||||
return warehouseCache.rows;
|
||||
}
|
||||
// ⚠️ cloudDatabase.query() 直接返回 recordset 数组,不是 {recordset} 对象
|
||||
const rows = (await cloudDatabase.query(
|
||||
`SELECT Id, name, wms_co_id
|
||||
FROM dbo.jst_warehouseInfo`,
|
||||
{},
|
||||
{ requestTimeout: 60000 }
|
||||
)) || [];
|
||||
const byCenter = new Map(CENTERS.map((c) => [c.key, []]));
|
||||
const unmatched = [];
|
||||
const excluded = [];
|
||||
for (const row of rows) {
|
||||
const name = String(row.name || '');
|
||||
// 被业务排除的仓单独记录,方便日后核对「是不是排多了/排少了」
|
||||
if (SORTED_EXCLUSIONS.some((kw) => name.includes(kw))) {
|
||||
excluded.push({ wmsCoId: Number(row.wms_co_id), name });
|
||||
continue;
|
||||
}
|
||||
const center = resolveCenter(name);
|
||||
if (center && byCenter.has(center.key)) {
|
||||
byCenter.get(center.key).push({
|
||||
wmsCoId: Number(row.wms_co_id),
|
||||
warehouseId: Number(row.Id),
|
||||
name,
|
||||
});
|
||||
} else {
|
||||
unmatched.push({ wmsCoId: Number(row.wms_co_id), name });
|
||||
}
|
||||
}
|
||||
const list = CENTERS.map((c) => ({
|
||||
key: c.key,
|
||||
name: c.name,
|
||||
placeholder: Boolean(c.placeholder),
|
||||
warehouses: byCenter.get(c.key) || [],
|
||||
}));
|
||||
warehouseCache = {
|
||||
at: Date.now(),
|
||||
rows: list,
|
||||
unmatchedCount: unmatched.length,
|
||||
excluded,
|
||||
};
|
||||
return list;
|
||||
}
|
||||
|
||||
function clearWarehouseCache() {
|
||||
warehouseCache = { at: 0, rows: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算「当前已生效」的核验轮次。
|
||||
*
|
||||
* 13:00 之前 → **未到核验时间,返回 null**。
|
||||
* 业务明确要求(2026-10-20 确认):未到核验时刻不做核验,
|
||||
* 前端显示「未到核验时间」,**不要回填昨天的数据** ——
|
||||
* 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。
|
||||
* 13:00~17:59 → 用 13:00 轮(今天 00:00~07:00 **审核** + 历史未发)
|
||||
* 18:00 之后 → 用 18:00 轮(截止 16:00 前**审核** + 今天之前未发的历史单)
|
||||
*
|
||||
* 🔥 「含历史」是 2026-10-20 业务第三次纠偏:
|
||||
* 原实现把下界卡在「今天 00:00」,只统计当天新单,把昨天及更早挂着没发的
|
||||
* 单全漏掉了。业务口径是「……然后再看历史未发的订单」——
|
||||
* 这是一个滚动累积的待发池,不按自然日切。
|
||||
*
|
||||
* @param {Date} now 北京时间(默认 `beijingNow()`,容器是 UTC 时也安全)
|
||||
* @returns {object|null} null 表示当前不在任何核验时段内
|
||||
*/
|
||||
function resolveActiveRound(now = beijingNow()) {
|
||||
const hour = now.getHours();
|
||||
const minute = now.getMinutes();
|
||||
const minutes = hour * 60 + minute;
|
||||
let active = null;
|
||||
for (const round of ROUNDS) {
|
||||
if (minutes >= round.verifyHour * 60) active = round;
|
||||
}
|
||||
|
||||
// 13:00 前:首轮核验还没开始,不做判定
|
||||
if (!active) return null;
|
||||
|
||||
const y = now.getFullYear();
|
||||
const m = pad2(now.getMonth() + 1);
|
||||
const d = pad2(now.getDate());
|
||||
// 护栏起点(不是业务下界,见 HISTORY_LOOKBACK_DAYS)
|
||||
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
|
||||
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
|
||||
return {
|
||||
...active,
|
||||
// deadline = 核验时刻:判「发了没发 / 超时多久」都用它
|
||||
deadline: `${y}-${m}-${d} ${pad2(active.verifyHour)}:00:00`,
|
||||
windowStart: floorStr,
|
||||
// windowEnd = 订单窗口上界:SQL 的取数上界,**不是** deadline
|
||||
windowEnd: `${y}-${m}-${d} ${pad2(active.windowEndHour)}:00:00`,
|
||||
// 含历史未发单 —— 前端据此把「统计窗口 00:00~07:00」改成「截止 07:00 前(含历史)」
|
||||
includeHistory: true,
|
||||
date: `${y}-${m}-${d}`,
|
||||
settled: false,
|
||||
isPreviousDay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
|
||||
*/
|
||||
function describeRounds(now = beijingNow()) {
|
||||
const y = now.getFullYear();
|
||||
const m = pad2(now.getMonth() + 1);
|
||||
const d = pad2(now.getDate());
|
||||
const date = `${y}-${m}-${d}`;
|
||||
const minutes = now.getHours() * 60 + now.getMinutes();
|
||||
const floor = new Date(y, now.getMonth(), now.getDate() - HISTORY_LOOKBACK_DAYS);
|
||||
const floorStr = `${floor.getFullYear()}-${pad2(floor.getMonth() + 1)}-${pad2(floor.getDate())} 00:00:00`;
|
||||
return ROUNDS.map((r) => ({
|
||||
key: r.key,
|
||||
label: r.label,
|
||||
date,
|
||||
verifyHour: r.verifyHour,
|
||||
deadline: `${date} ${pad2(r.verifyHour)}:00:00`,
|
||||
windowStart: floorStr,
|
||||
windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`,
|
||||
includeHistory: true,
|
||||
active: minutes >= r.verifyHour * 60,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 下一轮的核验时刻。
|
||||
*
|
||||
* ⚠️ 2026-10-20 第九版起**不再使用**:轮次归属本身就是按审核时间卡的,
|
||||
* 「审核晚于 13:00 的单」根本进不了 13:00 轮,会自然落到 18:00 轮,
|
||||
* 不需要额外顺延。函数保留是因为「按下一轮核验时刻算」这个业务表述
|
||||
* 未来若用于别的口径(例如按创建时间分轮),逻辑还是这一套。
|
||||
*
|
||||
* @param {object} round resolveActiveRound / describeRounds 的返回项(需带 date/key)
|
||||
* @returns {string} 'YYYY-MM-DD HH:mm:00'
|
||||
*/
|
||||
function nextRoundDeadline(round) {
|
||||
const idx = ROUNDS.findIndex((r) => r.key === round.key);
|
||||
if (idx >= 0 && idx + 1 < ROUNDS.length) {
|
||||
return `${round.date} ${pad2(ROUNDS[idx + 1].verifyHour)}:00:00`;
|
||||
}
|
||||
// 当天最后一轮 → 次日首轮
|
||||
const [y, m, d] = String(round.date).split('-').map(Number);
|
||||
const next = new Date(y, (m || 1) - 1, (d || 1) + 1);
|
||||
return `${next.getFullYear()}-${pad2(next.getMonth() + 1)}-${pad2(next.getDate())}`
|
||||
+ ` ${pad2(ROUNDS[0].verifyHour)}:00:00`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CENTERS,
|
||||
ROUNDS,
|
||||
HISTORY_LOOKBACK_DAYS,
|
||||
EXCLUDED_WAREHOUSE_KEYWORDS,
|
||||
resolveCenter,
|
||||
centerWindowEndHour,
|
||||
centerWindowEnd,
|
||||
nextRoundDeadline,
|
||||
loadCenterWarehouseMap,
|
||||
clearWarehouseCache,
|
||||
getWarehouseCache: () => warehouseCache,
|
||||
resolveActiveRound,
|
||||
describeRounds,
|
||||
beijingNow,
|
||||
BEIJING_OFFSET_MINUTES,
|
||||
EXCLUDED_STATUS_SQL,
|
||||
SHIPPED_STATUS,
|
||||
PENDING_STATUS_SQL,
|
||||
NOT_SPLIT_MERGED_SQL,
|
||||
OUTBOUND_TABLES,
|
||||
ORDER_STATE_TABLE,
|
||||
SHIPPING_STATUS,
|
||||
UNAUDITED_STATUS,
|
||||
ABNORMAL_STATUS,
|
||||
IGNORED_STATES,
|
||||
CREATED_SOURCE_TABLES,
|
||||
CREATED_BACKFILL_BATCH,
|
||||
VOID_ORDER_STATUS,
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
SHIP_TIMELINESS_HOURS,
|
||||
toNumber,
|
||||
};
|
||||
@ -1,802 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效 —— 服务层
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* 口径(2026-10-20 第九次修正,业务最终定版)
|
||||
* ═══════════════════════════════════════════════════════════════════════════
|
||||
* 业务原话(按时间顺序,最后一条为准):
|
||||
* 17:12「当日 7 点前订单(7 点前订单**按照审核时间算**),13:00 核验未发就算超时」
|
||||
* 17:5x「当天 6:30 到 7 点审核的订单 13:00 核验;当天 7 点到 16 点审核的订单 18:00 核验;
|
||||
* 然后再看历史未发的订单」
|
||||
*
|
||||
* ⟹ 三条铁律:
|
||||
* ① **轮次归属看「审核时间」**(`last_oa_confirm_time`,聚水潭显示值):
|
||||
* 今天 00:00~07:00 审核 → 13:00 轮
|
||||
* **截止今天 16:00 前审核**的单 → 18:00 轮
|
||||
* (业务 2026-10-20 补正:18:00 核验的是 16:00 前**所有**审核过的单,
|
||||
* 不是「今天 00:00~16:00」这一个自然日切片 —— 含今天之前审核、
|
||||
* 至今没发的历史未发单,也含 7 点前审核到 13:00 还没发出的;
|
||||
* 山东两仓 15:00、河南洛宁 13:00 是仓级例外,见 registry)
|
||||
* ⚠️ **不看创建时间**:业务亲自指出的 9818476(创建 10-18 12:49 / 审核 10-20 13:06)
|
||||
* 和 9823980(创建 10-18 20:40 / 审核 10-20 09:15)都是下午/上午才审的,
|
||||
* 按创建时间会被错算进 13:00 轮 —— 第八版就是这么错的。
|
||||
* ② **历史未发的单一律算**:昨天及更早审核、至今没发货的,两个轮次都要看到
|
||||
* (它们早就该发了,不该等今天的分段窗口)。
|
||||
* ③ **没有「满 24 小时」门槛**:到核验时刻仍未发就是超时,哪怕只等了 2 小时。
|
||||
* 第七版加了 24h 门槛,13:00 轮永远看不到当天那批,被业务打回。
|
||||
* ④ **自提 / 中铁 / 壹米滴答 三个快递不考核**(业务 2026-10-20 补充):
|
||||
* 按 `logistic_company_name` 包含匹配(库里是「中铁物流」「壹米滴答专线」),
|
||||
* 命中直接跳过,不进出口径任何一处数字,剔除量记在 `summary.carrierSkipped`。
|
||||
*
|
||||
* 已等时长 = **审核时间 → 本轮核验时刻**(13:00 / 18:00)。
|
||||
* 🔥 审核时间取 last_oa_confirm_time(**最后一次**审核)——
|
||||
* 这就是聚水潭订单列表「审核时间」那一列显示的值,业务拿它对账。
|
||||
* 之前取首次审核,业务抽查 3 单(5127368691073091434 等)立刻发现不一致:
|
||||
* 首次 10-18 13:15 vs 聚水潭 10-20 13:38。**别再改成 first_oa**。
|
||||
* 因为窗口也按审核时间卡,「审核 13:06 的单」只会进 18:00 轮,
|
||||
* 已等自然用 18:00 算,不会出现「0 分」。
|
||||
* 明细列表并列「审核时间 / 发货时间」—— 业务 2026-10-20 要求**去掉创建时间**,
|
||||
* 改成看各订单的发货时间(未发的显示「未发货」),对账时直接和聚水潭发货时间比。
|
||||
*
|
||||
* 🔥 「已发 / 未发」还是以聚水潭「销售订单」的发货状态为准(= 状态表 send_time)。
|
||||
* **不要**改用运单号(logistic_id)判已发:实测同一店铺里,
|
||||
* 「已发货」的单独有运单号 + 未揽收,「发货中」的单也有运单号 + 未揽收 —— 判不出来。
|
||||
* 运单号只用来在明细里标「已打单 · 未揽收」,给业务一个「仓库干到哪一步」的线索。
|
||||
*
|
||||
* 数据源:`rpt.jst_order_express_details`(聚水潭订单状态准实时镜像,402 万行)
|
||||
*
|
||||
* 🔥 时区(2026-10-21 生产实测踩坑,别再裸用 `new Date()`):
|
||||
* · 生产容器 `/etc/timezone = Etc/UTC`,而 MSSQL `GETDATE()` 返回**北京时间**,
|
||||
* mssql 驱动 `useUTC:false` 把 DB 值构造成「墙钟当 UTC」的 Date ⇒ 比真实 now 快 8 小时。
|
||||
* · 后果:**北京时间每天 00:00~08:00,服务端取到的「今天」是昨天**,
|
||||
* 轮次日期 / 核验窗口整体错位(实测 08:04 跑 13:00 轮得 3895 单,本地仅 6 单)。
|
||||
* · 统一用 `registry.beijingNow()` 取「现在」(本机已是东八区时平移量为 0,行为不变)。
|
||||
* **不要**去改容器 TZ —— 会波及全部既有定时任务。
|
||||
*
|
||||
* · 窗口归属 / 时长基准 = `last_oa_confirm_time`(最后一次审核 = 聚水潭显示值;
|
||||
* 缺失回退 first_oa / 创建时间 / 付款时间),来源写进 `benchmarkSource`。
|
||||
* · 创建时间(**仅供展示对账**) = `jst_Orders.created` / `jst_qmcust_orderlist.created`
|
||||
* 🔥 状态表里**没有**这一列,必须回订单表按 `o_id = inner_order_id` 补:
|
||||
* · `createdAt`(状态表那个 datetime2)= 同步落库时间,晚 1.5~3.5h,**不能用**
|
||||
* · `created` 才是聚水潭订单列表里的「创建时间」(比下单晚 15~30 秒)
|
||||
* · 已发货 = `send_time` 非空(已发货单 100% 有值)
|
||||
* · 待发货 = `send_time` 为空 且 `order_status_name = 发货中`
|
||||
* · 仓库 = `warehouse_name`(直接套 CENTERS 关键词归属主仓)
|
||||
*
|
||||
* ── 为什么弃用 jst_Orders.status(前三版全栽在这)──────────────────────────
|
||||
* 订单表的 `status` 是**下单那一刻的快照**,之后聚水潭侧取消、发货前售后、
|
||||
* 审核通过都不会回写。实测(2026-10-20)业务反馈「聚水潭查不到」的 6 单:
|
||||
* 订单表 status 全是 `WaitConfirm`(看起来像待发货),
|
||||
* 状态表真实状态是 **已取消**(标签带「发货前售后」),另 1 单是 **异常**。
|
||||
* ⟹ 所以「状态 / 是否已发」一律以状态表为准,**订单表只用来补创建时间**。
|
||||
*
|
||||
* 时间列语义(已逐一实测,别再猜):
|
||||
* · `order_date` / `pay_date` —— 平台下单 / 付款时间(客户那边的时间)
|
||||
* · `created`(订单表) —— ✅ **聚水潭创建时间**(展示用,比下单晚 15~30 秒)
|
||||
* · `createTime`/`updateTime` —— 数仓灌库时间(带毫秒、批量、滞后约 5 分钟)
|
||||
* · `modified` —— ⚠️ 最后修改时间,每天凌晨被批量刷新,不可用
|
||||
* · `first_oa_confirm_time` —— 首次审单时间(审核时间缺失时的第二顺位回退)
|
||||
* · `last_oa_confirm_time` —— ✅ **最新审核时间(本版考核基准)**
|
||||
* · `send_time` —— **发货时间**
|
||||
* · `query_time` —— 本表快照时间(判数据新鲜度)
|
||||
*
|
||||
* ── 三态分流(互斥)─────────────────────────────────────────────────────
|
||||
* 超时未发货 = 发货中 + send_time 空 + 审核时间已过本轮窗口上界(07:00/16:00)
|
||||
* → orders(考核仓库)
|
||||
* 不计考核 = 已支付待审核(没审单)/ 异常(订单异常) → heldOrders
|
||||
* 不展示 = 已发货 / 已取消(作废)/ 等供销商|外仓发货 → 直接丢弃
|
||||
* 🔥 作废单(已取消)不考核:近 30 天 64,440 单,是「发货中」2,448 单的 26 倍,
|
||||
* 不排除的话整页都是客户取消掉的单。
|
||||
*
|
||||
* ── 核验轮次(业务给定)──────────────────────────────────────────────────
|
||||
* `verifyHour` —— 几点核验(13:00 / 18:00),判「超时多久」用它
|
||||
* `windowEndHour` —— 只看该时刻**前审核**的单(07:00 / 16:00,仓级例外见 registry)
|
||||
* ⚠️ 两者不是同一个东西,别合成一个字段
|
||||
* ⚠️ 也**不要**再引入「满 N 小时才算超时」的门槛(第七版踩过,会被业务打回)
|
||||
* ⚠️ 窗口**要**卡审核时间(第九版定版)。第六版曾因「卡审核时间 13:00 轮只剩 2 单」
|
||||
* 退回创建时间,结果 9818476 / 9823980 这类下午才审的单被错算进 13:00 轮 →
|
||||
* 业务亲自纠正:「7 点前订单**按照审核时间算**」。审核时间少是因为 13:00 轮本来就
|
||||
* 只该有早上那批,不是数据不对。
|
||||
*
|
||||
* ── 性能(2026-10-20 实测,四个坑都踩过)──────────────────────────────────
|
||||
* ① **不要 SELECT 宽列**。同一 WHERE 只取 2 列 0.7s,取 15 列(含
|
||||
* `order_labels` / `buyer_message` / `remark`)**54s** —— 视图没法做
|
||||
* 覆盖扫描,多取一列就是几万次基表回表。本表只投影 10 个窄列。
|
||||
* ② **不要回订单表补金额**。`o_id IN (400 个)` 打两张订单宽视图,单条实测
|
||||
* **38.7s**。发货时效不需要金额,直接不做。
|
||||
* (但补 `created` **可以**:只取两列时 9,676 个候选只要 14.5s + 2.9s。)
|
||||
* ③ **不要额外查新鲜度**。`MAX(send_time)` 又是一次 402 万行扫描(21.1s)。
|
||||
* 快照时间直接从主查询的 `query_time` 在 Node 里取 MAX。
|
||||
* ④ `SELECT *` 更是灾难 —— 单 form 单号查一次 **5 分钟**不返回。
|
||||
* ⑤ 走 `querySlow`,普通池 60s 池级超时 + 自动重试会把 9s 拖成 120s。
|
||||
*
|
||||
* ── 时区 ─────────────────────────────────────────────────────────────────
|
||||
* 所有时间列都是**东八区本地时间**;服务与库都在东八区。
|
||||
* **不要**在 Node 侧用 UTC 日期判断。
|
||||
*/
|
||||
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const registry = require('./warehouseShipTimelinessRegistry');
|
||||
|
||||
const {
|
||||
ORDER_STATE_TABLE,
|
||||
SHIPPING_STATUS,
|
||||
UNAUDITED_STATUS,
|
||||
ABNORMAL_STATUS,
|
||||
IGNORED_STATES,
|
||||
CREATED_SOURCE_TABLES,
|
||||
CREATED_BACKFILL_BATCH,
|
||||
VOID_ORDER_STATUS,
|
||||
VOID_QUESTION_STATUS,
|
||||
VOID_ORDER_LABEL_KEYWORDS,
|
||||
AFTERSALE_VOID_STATUS,
|
||||
resolveCenter,
|
||||
} = registry;
|
||||
|
||||
// `labels` 里命中任一关键词即视为作废(SQL 侧用 LIKE 拼,避免把长文本传回 Node)
|
||||
const VOID_LABEL_SQL = VOID_ORDER_LABEL_KEYWORDS
|
||||
.map((k) => `labels LIKE N'%${k}%'`)
|
||||
.join(' OR ');
|
||||
|
||||
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
|
||||
const DEFAULT_DETAIL_LIMIT = 500;
|
||||
const MAX_DETAIL_LIMIT = 2000;
|
||||
|
||||
// 状态表快照超过这个分钟数没刷新 → 页面提示「数据滞后」。
|
||||
// 正常 ETL 每小时至少刷一轮(每小时约 1000 行有变化),90 分钟足够容错。
|
||||
const STATE_STALE_MINUTES = 90;
|
||||
|
||||
/**
|
||||
* 不参与发货时效考核的快递(业务 2026-10-20 指定):
|
||||
* 自提 —— 客户自己来拉,仓库不负责派送
|
||||
* 中铁 —— 中铁物流(大件专线)
|
||||
* 壹米滴答 —— 专线物流(大件)
|
||||
* 命中即可,用**物流公司名**包含匹配;库里的实际值是「中铁物流」「壹米滴答专线」这种。
|
||||
* 只影响「超时未发」判定,不影响「已发」判定。
|
||||
*/
|
||||
const EXCLUDED_CARRIERS = ['自提', '中铁', '壹米滴答'];
|
||||
|
||||
/**
|
||||
* 订单表(= 聚水潭「销售订单」镜像)里「已发货」的状态值。
|
||||
*
|
||||
* 🔥 业务 2026-10-20 要求「已发/未发参考聚水潭销售订单页面」——
|
||||
* 销售订单页面显示「已发货」= status `Sent`。`send_date` 很多店铺不回写
|
||||
* (实测 103 单里 103 单都是空),所以必须把 status 也当依据。
|
||||
* ⚠️ **不要**把 `Delivering`(发货中)算成已发 —— 那是「已审核待出库」,
|
||||
* 正是本页要考核的对象;算进来整页就空了。
|
||||
*/
|
||||
const ORDER_SHIPPED_STATUS = ['Sent'];
|
||||
|
||||
/**
|
||||
* 结果缓存。
|
||||
*
|
||||
* 这张视图单次查询实测 10~60 秒(取决于缓存温度与库负载),页面又是
|
||||
* `AUTO_REFRESH_MS` 定时轮询 —— 不缓存的话每轮轮询都会重压一次库,
|
||||
* 页面也会一直转圈。缓存 key 含「轮次 + 日期 + limit」,
|
||||
* 业务点「刷新」时前端带 `force=1` 绕过缓存强制重查。
|
||||
*/
|
||||
const CACHE_TTL_MS = Number(process.env.CANGKU_WST_CACHE_TTL_MS) || 5 * 60 * 1000;
|
||||
let resultCache = { key: '', at: 0, data: null };
|
||||
|
||||
/** 清缓存(切轮次 / 手工排查时用) */
|
||||
function clearShipTimelinessCache() {
|
||||
resultCache = { key: '', at: 0, data: null };
|
||||
}
|
||||
|
||||
/** 把 datetime / Date / 字符串统一成 'YYYY-MM-DD HH:mm:ss'(东八区,不做时区换算) */
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
if (value instanceof Date) {
|
||||
if (Number.isNaN(value.getTime())) return '';
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())}`
|
||||
+ ` ${p(value.getHours())}:${p(value.getMinutes())}:${p(value.getSeconds())}`;
|
||||
}
|
||||
const s = String(value);
|
||||
if (!s) return '';
|
||||
return s.replace('T', ' ').replace(/\.\d+Z?$/, '').slice(0, 19);
|
||||
}
|
||||
|
||||
/** 两个时间文本相差多少分钟(to - from);任一为空返回 0 */
|
||||
function diffMinutes(fromText, toText) {
|
||||
const a = formatDateTime(fromText);
|
||||
const b = formatDateTime(toText);
|
||||
if (!a || !b) return 0;
|
||||
const pa = new Date(a.replace(' ', 'T')).getTime();
|
||||
const pb = new Date(b.replace(' ', 'T')).getTime();
|
||||
if (!Number.isFinite(pa) || !Number.isFinite(pb)) return 0;
|
||||
return Math.floor((pb - pa) / 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「聚水潭订单状态」里所有未发货的单(发货中 / 待审核 / 异常)。
|
||||
*
|
||||
* 为什么不用 wms_co_id 过滤:本表只有 `warehouse_name`,没有仓 ID。
|
||||
* 近 30 天三类合计约 1 万行,一次取回在 Node 侧按仓库名归属主仓,代价可接受。
|
||||
*
|
||||
* ⚠️ 只投影 11 个**窄列**:`order_labels`/`buyer_message`/`remark` 这类长文本
|
||||
* 会把 0.7s 的查询拖到 54s(视图无法覆盖扫描,每行都要回基表)。
|
||||
* ⚠️ `send_time IS NULL` 是关键:已发货单在这里 100% 有值。
|
||||
*
|
||||
* 🔥 2026-10-20 第八版:**不再把「创建 ≤ 核验−24h」下推到 SQL**。
|
||||
* 本版口径是「到核验时刻未发即超时」,`send_time IS NULL` 就是候选;
|
||||
* 窗口上界改在 Node 侧按**审核时间**判。SQL 里那两个时间列只是用来
|
||||
* 判断窗口归属,不再参与「等了多少小时」的门槛。
|
||||
*/
|
||||
async function loadPendingStates(round) {
|
||||
const rows = await cloudDatabase.querySlow(
|
||||
// 🔥 时间列直接取原始 datetime,**不要**在 SQL 里 CONVERT 成字符串:
|
||||
// CONVERT 会作用到扫描到的每一行(谓词下推不到视图里),实测把点查拖到 40s+;
|
||||
// mssql 连接已配 useUTC:false,Node 侧 formatDateTime 用本地 getter 还原
|
||||
// 就是库里存的北京时间,不会偏移 8 小时。
|
||||
`SELECT e.so_id, e.inner_order_id, e.shop_name, e.warehouse_name,
|
||||
e.order_status_name, e.aftersale_status_name, e.logistic_status_name,
|
||||
e.logistic_id, e.logistic_company_name,
|
||||
e.pay_time, e.first_oa_confirm_time, e.last_oa_confirm_time,
|
||||
e.send_time, e.query_time
|
||||
FROM dbo.${ORDER_STATE_TABLE} e
|
||||
WHERE e.send_time IS NULL
|
||||
AND e.pay_time >= @windowStart
|
||||
AND e.order_status_name IN (@shipStatus, @st1, @st2)`,
|
||||
{
|
||||
shipStatus: SHIPPING_STATUS,
|
||||
st1: UNAUDITED_STATUS,
|
||||
st2: ABNORMAL_STATUS,
|
||||
windowStart: round.windowStart,
|
||||
},
|
||||
// 视图谓词下推一般,给足超时;不走普通池(60s 池级超时 + 自动重试会把 9s 拖成 120s)
|
||||
{ connectRequestTimeoutMs: 300000 }
|
||||
);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
/** 并发跑任务,最多 `limit` 个同时在飞(不把连接池打满) */
|
||||
async function mapLimit(items, limit, worker) {
|
||||
const out = [];
|
||||
let cursor = 0;
|
||||
const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
for (;;) {
|
||||
const i = cursor;
|
||||
cursor += 1;
|
||||
if (i >= items.length) return;
|
||||
out[i] = await worker(items[i], i);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回订单表补「起算点 + 作废事实」。
|
||||
*
|
||||
* 状态表 `jst_order_express_details` 里没有聚水潭创建时间(`createdAt` 是同步落库
|
||||
* 时间,比付款晚 1.5~3.5 小时),只能回两张订单表按 `o_id = inner_order_id` 取
|
||||
* `created`(稳定比下单晚 15~30 秒,即聚水潭订单列表里的「创建时间」)。
|
||||
*
|
||||
* 🔥 同一趟把**作废事实**也取回来(业务 2026-10-20 反馈作废单被算成超时):
|
||||
* 状态表的 `send_time` 对**被合并单 / 拆单父单不回写** —— 单子早随主单发走了,
|
||||
* 状态表却还是「发货中 + send_time 空」。所以必须再看订单表的
|
||||
* `status` / `send_date` / `is_merge` / `is_split`(见 registry.VOID_ORDER_STATUS)。
|
||||
*
|
||||
* ⚠️ 只投影这 6 个**窄列**。实测 9,676 个候选:窄列 14.5s + 2.9s,
|
||||
* 一旦连 pay_amount / labels 一起取,单批 400 个就要 38.7s。
|
||||
*
|
||||
* @param {number[]} oids 内部单号
|
||||
* @returns {Promise<Map<number, {createdAt:string, status:string, sendDate:string, void:boolean, shipped:boolean}>>}
|
||||
*/
|
||||
async function backfillOrderFacts(oids) {
|
||||
const map = new Map();
|
||||
if (!oids.length) return map;
|
||||
|
||||
let remaining = oids;
|
||||
for (const table of CREATED_SOURCE_TABLES) {
|
||||
if (!remaining.length) break;
|
||||
const batches = [];
|
||||
for (let i = 0; i < remaining.length; i += CREATED_BACKFILL_BATCH) {
|
||||
batches.push(remaining.slice(i, i + CREATED_BACKFILL_BATCH));
|
||||
}
|
||||
|
||||
const results = await mapLimit(batches, 4, async (batch) => {
|
||||
try {
|
||||
return await cloudDatabase.querySlow(
|
||||
`SELECT o_id, created, status, send_date, is_merge, is_split,
|
||||
CASE WHEN ${VOID_LABEL_SQL} THEN 1 ELSE 0 END AS presaleVoid
|
||||
FROM dbo.${table} WHERE o_id IN (${batch.join(',')})`,
|
||||
{},
|
||||
{ connectRequestTimeoutMs: 300000 }
|
||||
);
|
||||
} catch (e) {
|
||||
// 补不到不致命:调用方会回退到付款时间,不整页失败
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
for (const rows of results) {
|
||||
for (const r of rows || []) {
|
||||
const id = Number(r.o_id);
|
||||
if (!Number.isFinite(id)) continue;
|
||||
const status = String(r.status || '');
|
||||
const sendDate = formatDateTime(r.send_date);
|
||||
map.set(id, {
|
||||
createdAt: formatDateTime(r.created),
|
||||
status,
|
||||
sendDate,
|
||||
// 作废(硬):被合并 / 被拆分 / 已取消 —— 这单不会自己发货,状态分类前就剔除
|
||||
void: VOID_ORDER_STATUS.includes(status)
|
||||
|| Number(r.is_merge) === 1
|
||||
|| Number(r.is_split) === 1,
|
||||
// 作废(软):问题单 Question / 标签含「发货前售后」。
|
||||
// ⚠️ 只能作用于「发货中」分支(见主循环),否则状态表「异常」的单
|
||||
// 在订单表常也是 Question,会被一起吞掉 —— 实测 heldCount 472 → 10。
|
||||
question: status === VOID_QUESTION_STATUS,
|
||||
presaleVoid: Number(r.presaleVoid) === 1,
|
||||
// 已发:订单表有发货时间,**或**销售订单状态已是「已发货」(Sent)。
|
||||
// 业务 2026-10-20 口径:以聚水潭「销售订单」页面显示的发货状态为准。
|
||||
shipped: !!sendDate || ORDER_SHIPPED_STATUS.includes(status),
|
||||
});
|
||||
}
|
||||
}
|
||||
remaining = remaining.filter((n) => !map.has(n));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function emptySummary() {
|
||||
return {
|
||||
// 超时未发货(考核仓库的部分)
|
||||
overdueUnshipped: 0,
|
||||
overdueCenterCount: 0,
|
||||
oldestDays: 0,
|
||||
// 不计考核(待审核 + 异常)—— 单独统计,不算仓库责任
|
||||
heldCount: 0,
|
||||
// 已剔除的作废 / 已发单(页面只作说明,不进考核)
|
||||
voidCount: 0,
|
||||
// 已剔除的「不考核快递」单量(自提 / 中铁 / 壹米滴答)
|
||||
carrierSkipped: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEmptyCenters(centerList) {
|
||||
return centerList.map((c) => ({
|
||||
key: c.key,
|
||||
name: c.name,
|
||||
placeholder: c.placeholder,
|
||||
warehouseCount: c.warehouses.length,
|
||||
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
|
||||
overdueUnshipped: 0,
|
||||
oldestDays: 0,
|
||||
heldCount: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async function getShipTimeliness(options = {}) {
|
||||
// 🔥 必须按北京时间取「现在」:生产容器是 UTC,而库里时间是北京时间,
|
||||
// 裸 new Date() 会让北京时间 00:00~08:00 取到前一天(2026-10-21 实测)
|
||||
const now = options.now instanceof Date ? options.now : registry.beijingNow();
|
||||
const limit = Math.min(
|
||||
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||
MAX_DETAIL_LIMIT
|
||||
);
|
||||
|
||||
// ① 主仓清单(用于展示「含子仓 N 个」;归属判定走 warehouse_name 关键词)
|
||||
const centerList = await registry.loadCenterWarehouseMap();
|
||||
const pad2 = (n) => String(n).padStart(2, '0');
|
||||
|
||||
// ② 决定本轮截止时刻
|
||||
const rounds = registry.describeRounds(now);
|
||||
let round = null;
|
||||
if (options.round) {
|
||||
const target = registry.ROUNDS.find((r) => r.key === String(options.round).toUpperCase());
|
||||
if (!target) {
|
||||
const err = new Error(`未知的核验轮次: ${options.round}`);
|
||||
err.statusCode = 400;
|
||||
throw err;
|
||||
}
|
||||
round = rounds.find((r) => r.key === target.key);
|
||||
if (!round) {
|
||||
const err = new Error(`无法构造核验窗口: ${options.round}`);
|
||||
err.statusCode = 500;
|
||||
throw err;
|
||||
}
|
||||
} else {
|
||||
round = registry.resolveActiveRound(now);
|
||||
}
|
||||
|
||||
// 🔥 13:00 前没有轮次生效(业务口径):直接返回「未到核验时间」,不查库
|
||||
if (!round) {
|
||||
const nextRound = rounds.find((r) => !r.active) || rounds[0];
|
||||
return {
|
||||
generatedAt: formatDateTime(now),
|
||||
date: `${now.getFullYear()}-${pad2(now.getMonth() + 1)}-${pad2(now.getDate())}`,
|
||||
round: null,
|
||||
notStarted: true,
|
||||
isPreviousDay: false,
|
||||
rounds,
|
||||
nextRoundAt: nextRound ? nextRound.deadline : '',
|
||||
nextWindowEnd: nextRound ? nextRound.windowEnd : '',
|
||||
deadline: '',
|
||||
window: null,
|
||||
summary: emptySummary(),
|
||||
centers: buildEmptyCenters(centerList),
|
||||
orders: [],
|
||||
heldOrders: [],
|
||||
truncated: false,
|
||||
detailLimit: limit,
|
||||
detailDegraded: false,
|
||||
freshness: null,
|
||||
note: `今日 ${pad2(rounds[0].verifyHour)}:00 首轮核验开始后才展示超时数据`,
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 取「未发货」全量(发货中 / 待审核 / 异常),Node 侧判主仓与超时
|
||||
// 先看缓存:同一轮次 + 同一天 + 同一 limit,5 分钟内直接复用
|
||||
const force = options.force === true
|
||||
|| ['1', 'true', 'yes'].includes(String(options.force || '').toLowerCase());
|
||||
const cacheKey = `${round.key}|${round.date}|${limit}`;
|
||||
if (!force && resultCache.data && resultCache.key === cacheKey
|
||||
&& Date.now() - resultCache.at < CACHE_TTL_MS) {
|
||||
return {
|
||||
...resultCache.data,
|
||||
cached: true,
|
||||
cacheAgeSec: Math.round((Date.now() - resultCache.at) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
let stateRows = [];
|
||||
try {
|
||||
stateRows = await loadPendingStates(round);
|
||||
} catch (e) {
|
||||
const err = new Error(`聚水潭订单状态表查询失败:${e.message}`);
|
||||
err.statusCode = 502;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ③-b 补起算点「聚水潭创建时间」+ 作废事实(状态表两样都没有,回订单表取)
|
||||
const oids = [...new Set(
|
||||
stateRows.map((r) => Number(r.inner_order_id)).filter((n) => Number.isFinite(n))
|
||||
)];
|
||||
const orderFacts = await backfillOrderFacts(oids);
|
||||
// 被剔除的作废 / 已发单计数(只用于页面说明,不进考核)
|
||||
let voidCount = 0;
|
||||
// 因「不考核快递」被跳过的单量(自提 / 中铁 / 壹米滴答),写进 summary 便于对账
|
||||
let carrierSkipped = 0;
|
||||
|
||||
// ④ 分桶
|
||||
const acc = new Map(centerList.map((c) => [c.key, {
|
||||
pendingCount: 0,
|
||||
oldestDays: 0,
|
||||
heldCount: 0,
|
||||
}]));
|
||||
|
||||
const pendingRows = [];
|
||||
const heldRows = [];
|
||||
const seenSo = new Set();
|
||||
let snapshotAt = '';
|
||||
|
||||
for (const row of stateRows) {
|
||||
// 快照时间:状态表最后一次刷新(顺带算,不再单独扫一遍 402 万行表)
|
||||
const qt = formatDateTime(row.query_time);
|
||||
if (qt > snapshotAt) snapshotAt = qt;
|
||||
|
||||
const center = resolveCenter(row.warehouse_name);
|
||||
if (!center) continue; // 非 11 主仓(代发仓/未指定仓/POP 仓…)
|
||||
const bucket = acc.get(center.key);
|
||||
if (!bucket) continue;
|
||||
|
||||
const state = String(row.order_status_name || '');
|
||||
// 🔥 作废单不考核 —— 业务 2026-10-20 明确「作废订单不用考核」。
|
||||
// 状态表的 send_time 对**被合并单 / 拆单父单不回写**(案例 5127276315127080023:
|
||||
// 状态表「发货中 + send_time 空」,订单表却是 Merged 且 send_date 有值、
|
||||
// 出库单也早就生成了),所以必须回订单表再判一次,光信状态表会被冤枉。
|
||||
const fact = orderFacts.get(Number(row.inner_order_id));
|
||||
if (fact && (fact.void || fact.shipped)) {
|
||||
voidCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// SQL 里已经用状态白名单排除,这里再兜一次,防止以后有人改白名单漏掉
|
||||
if (IGNORED_STATES.includes(state)) continue;
|
||||
|
||||
// 🔥 不考核的快递(业务 2026-10-20):自提 / 中铁 / 壹米滴答 ——
|
||||
// 走这三种运输方式的单不按仓储发货时效考核(自提客户自己来拉、
|
||||
// 中铁和壹米滴答是专线/大件物流,揽收与在途都不受仓库控制)。
|
||||
// 用**物流公司名**模糊匹配(库里的名字是「中铁物流」「壹米滴答专线」这种)。
|
||||
const carrier = String(row.logistic_company_name || '').trim();
|
||||
const hitCarrier = EXCLUDED_CARRIERS.find((k) => carrier.includes(k));
|
||||
if (hitCarrier) {
|
||||
carrierSkipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 窗口归属 = **审核时间**(业务 2026-10-20 最终定版,两次纠偏后的结论)────
|
||||
// 业务原话:「当日 7 点前订单(7 点前订单**按照审核时间算**)……」
|
||||
// 实测案例(业务亲自指出的两单):
|
||||
// · 9818476:创建 10-18 12:49,但**审核 10-20 13:06** → 不是 7 点前的单,13:00 轮不能出现
|
||||
// · 9823980:创建 10-18 20:40,**审核 10-20 09:15** → 同理,归 18:00 轮
|
||||
// ⟹ 「7 点前/16 点前」卡的是**审核时间**:审核 ≤ 07:00 归 13:00 轮,
|
||||
// 审核 ≤ 16:00 归 18:00 轮(山东两仓 15:00、河南洛宁 13:00,见 registry)。
|
||||
// ⚠️ 别看创建时间:客户凌晨下单、仓库上午才审的单,按创建时间会提前算超时。
|
||||
// ⚠️ 审核时间取**最后一次**(last_oa),见下面 458 行开始的说明。
|
||||
const createdAt = fact ? fact.createdAt : '';
|
||||
const payAt = formatDateTime(row.pay_time);
|
||||
const auditFirst = formatDateTime(row.first_oa_confirm_time);
|
||||
const auditLast = formatDateTime(row.last_oa_confirm_time);
|
||||
// 🔥 取**最后一次**审核(= 聚水潭订单列表里「审核时间」那一列显示的值)。
|
||||
// 业务 2026-10-20 20:0x 抽查 3 单指出「审核时间跟系统里的不一致」,
|
||||
// 根因就是我们之前取错了列(first_oa = 首次审核):
|
||||
// 5127368691073091434 首次 10-18 13:15 / 聚水潭显示 10-20 13:38
|
||||
// 261019-670795046400872 首次 10-19 08:48 / 聚水潭显示 10-20 09:31
|
||||
// 261019-558387771763179 首次 10-19 11:21 / 聚水潭显示 10-20 09:11
|
||||
// ⟹ 页面必须跟聚水潭对齐,否则业务一对账就说「跑错了」。
|
||||
const auditAt = auditLast || auditFirst;
|
||||
const windowAt = auditAt || createdAt || payAt;
|
||||
const benchmarkAt = windowAt;
|
||||
const benchmarkSource = auditAt
|
||||
? (auditLast ? 'last_audit' : 'first_audit')
|
||||
: (createdAt ? 'created' : 'pay');
|
||||
|
||||
// 同一 so_id 可能因拆包出现多行(实测有),按 so_id + 窗口时间 去重
|
||||
const dedupeKey = `${row.so_id}|${windowAt}`;
|
||||
if (seenSo.has(dedupeKey)) continue;
|
||||
seenSo.add(dedupeKey);
|
||||
|
||||
// ── 本轮核验范围(业务 2026-10-20 最终定版)────────────────────────────
|
||||
// ① **今天的单按审核时间分段**:
|
||||
// 今天 00:00 ~ 07:00 审核 → 13:00 轮核验
|
||||
// **截止今天 16:00 前审核**的单 → 18:00 轮核验
|
||||
// (= 16:00 前所有审核过的单,含 7 点前没发出的;不是「07:00~16:00」那一段。
|
||||
// 山东两仓 15:00、河南洛宁 13:00 为仓级例外)
|
||||
// ⟹ 今天 09:15 审核的单(9823980)、13:06 审核的单(9818476)
|
||||
// **不进 13:00 轮**,只在 18:00 轮出现(业务亲自指出的两单)
|
||||
// ② **历史未发的单一律算**(昨天及更早审核、至今没发货)—— 它们已经超时,
|
||||
// 两个轮次都该看到,不受「今天 00:00 起」的限制。
|
||||
// ③ 30 天护栏只为查询性能,不是业务边界。
|
||||
const windowEndAt = registry.centerWindowEnd(center.key, round);
|
||||
const todayStart = `${round.date} 00:00:00`;
|
||||
if (windowAt && windowAt < round.windowStart) continue; // 超出 30 天护栏
|
||||
if (windowAt && windowAt >= todayStart && windowAt > windowEndAt) continue;
|
||||
|
||||
const base = {
|
||||
row,
|
||||
center,
|
||||
state,
|
||||
createdAt,
|
||||
payAt,
|
||||
auditAt,
|
||||
windowAt,
|
||||
benchmarkAt,
|
||||
benchmarkSource,
|
||||
warehouseName: String(row.warehouse_name || ''),
|
||||
};
|
||||
|
||||
// ── 不计考核:没审单(已支付待审核)或订单异常,都不该算仓库超时 ──────
|
||||
if (state === UNAUDITED_STATUS || state === ABNORMAL_STATUS) {
|
||||
bucket.heldCount += 1;
|
||||
heldRows.push({
|
||||
...base,
|
||||
holdReason: state === UNAUDITED_STATUS ? '待审核' : '订单异常',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (state !== SHIPPING_STATUS) continue; // 其余状态(已发货/已取消)不展示
|
||||
|
||||
// 🔥 2026-10-22:状态表说「发货中」,但订单表/售后维度已有作废证据 → 不考核。
|
||||
// ⚠️ 必须放在「发货中」之后:订单表 Question 的单里有一大批在状态表是「异常」,
|
||||
// 若在状态分类前判,会把 462 单「异常待处理」从 heldOrders 里吞掉。
|
||||
// 三类证据(业务 2026-10-22 确认「聚水潭后台已作废却仍算超时」):
|
||||
// · 状态表 aftersale_status_name =「已作废」(售后维度最权威)
|
||||
// · 订单表 status = Question(聚水潭后台显示「已作废/问题单」)—— 9823980 / 9880810
|
||||
// · 订单表 labels 含「发货前售后」(客户发货前退款/取消)—— 9823980 也带
|
||||
if (String(row.aftersale_status_name || '').trim() === AFTERSALE_VOID_STATUS
|
||||
|| (fact && (fact.question || fact.presaleVoid))) {
|
||||
voidCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── 超时判定:**到核验时刻仍未发货就是超时**(不再有「满 24 小时」门槛)
|
||||
if (!benchmarkAt) {
|
||||
bucket.heldCount += 1;
|
||||
heldRows.push({ ...base, holdReason: '缺审核时间' });
|
||||
continue;
|
||||
}
|
||||
// waited = 审核时间 → 本轮核验时刻(13:00 / 18:00)
|
||||
// ⚠️ 因为窗口本身按审核时间卡(审核 ≤ 07:00 / 16:00),benchmark 必然 ≤ deadline,
|
||||
// 这里不需要再处理「审核晚于核验」的情况 —— 那种单会自然落到下一轮:
|
||||
// 审核 13:06 的单进不了 13:00 轮(07:00 上界),只在 18:00 轮出现,已等 = 18:00 − 13:06。
|
||||
const waited = Math.max(0, diffMinutes(benchmarkAt, round.deadline));
|
||||
|
||||
bucket.pendingCount += 1;
|
||||
const days = Math.floor(waited / 1440);
|
||||
if (days > bucket.oldestDays) bucket.oldestDays = days;
|
||||
pendingRows.push({ ...base, waited });
|
||||
}
|
||||
|
||||
// ⑤ 排序:最久的排前面(业务一眼看最急的)
|
||||
pendingRows.sort((a, b) => {
|
||||
if (a.windowAt < b.windowAt) return -1;
|
||||
if (a.windowAt > b.windowAt) return 1;
|
||||
return 0;
|
||||
});
|
||||
heldRows.sort((a, b) => {
|
||||
const x = a.windowAt || '';
|
||||
const y = b.windowAt || '';
|
||||
if (x < y) return -1;
|
||||
if (x > y) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const detailRows = pendingRows.slice(0, limit);
|
||||
const heldDetail = heldRows.slice(0, limit);
|
||||
|
||||
const orders = detailRows.map((x) => {
|
||||
const r = x.row;
|
||||
return {
|
||||
oId: String(r.inner_order_id || ''),
|
||||
soId: String(r.so_id || ''),
|
||||
shopName: String(r.shop_name || ''),
|
||||
// 创建时间 = 聚水潭订单创建时间(业务 2026-10-20 已从明细列去掉,仅保留字段做回退基准)
|
||||
createdAt: x.createdAt,
|
||||
// 发货时间:本列表只取 send_time IS NULL 的单,所以这里恒为空 → 前端显示「未发货」
|
||||
sendTime: formatDateTime(r.send_time),
|
||||
// 付款时间(并列展示,方便对账)
|
||||
payTime: x.payAt,
|
||||
// 审核时间 = **考核基准**:决定进 13:00 轮还是 18:00 轮,已等时长也从它算
|
||||
auditTime: x.auditAt,
|
||||
// 基准时间实际取值(审核缺失时回退创建/付款)+ 来源标记
|
||||
benchmarkTime: x.benchmarkAt,
|
||||
benchmarkSource: x.benchmarkSource,
|
||||
state: x.state,
|
||||
logisticStatus: String(r.logistic_status_name || ''),
|
||||
// 运单号 / 快递公司:有值说明仓库**已打单**(但还没揽收/回传发货时间)。
|
||||
// 业务 2026-10-20 报「聚水潭显示已发货、页面显示未发货」,靠这两列能一眼看出卡在哪一步。
|
||||
logisticId: String(r.logistic_id || '').trim(),
|
||||
logisticCompany: String(r.logistic_company_name || '').trim(),
|
||||
centerKey: x.center.key,
|
||||
centerName: x.center.name,
|
||||
warehouseName: x.warehouseName,
|
||||
// 已等时长(小时,保留 1 位)与分钟(排序都用分钟)
|
||||
waitedHours: Math.round((x.waited / 60) * 10) / 10,
|
||||
overdueMinutes: x.waited,
|
||||
};
|
||||
});
|
||||
|
||||
const heldOrders = heldDetail.map((x) => {
|
||||
const r = x.row;
|
||||
const from = x.benchmarkAt || '';
|
||||
return {
|
||||
oId: String(r.inner_order_id || ''),
|
||||
soId: String(r.so_id || ''),
|
||||
shopName: String(r.shop_name || ''),
|
||||
createdAt: x.createdAt,
|
||||
sendTime: formatDateTime(r.send_time),
|
||||
payTime: x.payAt,
|
||||
auditTime: x.auditAt,
|
||||
benchmarkTime: x.benchmarkAt,
|
||||
benchmarkSource: x.benchmarkSource,
|
||||
state: x.state,
|
||||
holdReason: x.holdReason,
|
||||
logisticStatus: String(r.logistic_status_name || ''),
|
||||
logisticId: String(r.logistic_id || '').trim(),
|
||||
logisticCompany: String(r.logistic_company_name || '').trim(),
|
||||
centerKey: x.center.key,
|
||||
centerName: x.center.name,
|
||||
warehouseName: x.warehouseName,
|
||||
heldDays: from ? Math.floor(diffMinutes(from, round.deadline) / 1440) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const centers = centerList.map((c) => {
|
||||
const b = acc.get(c.key) || { pendingCount: 0, oldestDays: 0, heldCount: 0 };
|
||||
return {
|
||||
key: c.key,
|
||||
name: c.name,
|
||||
placeholder: c.placeholder,
|
||||
warehouseCount: c.warehouses.length,
|
||||
warehouses: c.warehouses.map((w) => ({ wmsCoId: w.wmsCoId, name: w.name })),
|
||||
overdueUnshipped: b.pendingCount,
|
||||
oldestDays: b.oldestDays,
|
||||
heldCount: b.heldCount,
|
||||
// 该仓在本轮的窗口上界(可能是仓级例外,如山东两仓 15:00、河南洛宁 13:00)
|
||||
windowEnd: registry.centerWindowEnd(c.key, round),
|
||||
};
|
||||
});
|
||||
|
||||
// 本轮有「仓级例外窗口」的仓(给前端提示用;没有就是空数组)
|
||||
const windowOverrides = centerList
|
||||
.filter((c) => registry.centerWindowEndHour(c.key, round.key) !== null)
|
||||
.map((c) => ({
|
||||
key: c.key,
|
||||
name: c.name,
|
||||
hour: registry.centerWindowEndHour(c.key, round.key),
|
||||
windowEnd: registry.centerWindowEnd(c.key, round),
|
||||
}));
|
||||
|
||||
const summary = centers.reduce((a, c) => {
|
||||
a.overdueUnshipped += c.overdueUnshipped;
|
||||
if (c.overdueUnshipped > 0) a.overdueCenterCount += 1;
|
||||
if (c.oldestDays > a.oldestDays) a.oldestDays = c.oldestDays;
|
||||
a.heldCount += c.heldCount || 0;
|
||||
return a;
|
||||
}, emptySummary());
|
||||
summary.voidCount = voidCount;
|
||||
summary.carrierSkipped = carrierSkipped;
|
||||
|
||||
// 新鲜度:快照时间取自上面主查询的 query_time(不再额外扫一遍 402 万行表)
|
||||
// 🔥 2026-10-20 业务报「已发货的单还显示未发货」时才发现这里曾写死 stale:false ——
|
||||
// 状态表是**准实时镜像**,上游 ETL 一停(实测当天 15:48 之后 0 行刷新),
|
||||
// 15:48 之后发的货就永远进不来,页面只能基于旧快照判「未发」。
|
||||
// 必须把滞后暴露到页面上,否则业务只会以为判定写错。
|
||||
const snapshotAgeMin = snapshotAt ? diffMinutes(snapshotAt, formatDateTime(now)) : null;
|
||||
const stale = snapshotAgeMin != null && snapshotAgeMin > STATE_STALE_MINUTES;
|
||||
// 🔥 更狠的一条:快照**比本轮核验时刻还早** ⇒ 核验时刻那一刻的真实状态根本没看到。
|
||||
// 实测 2026-10-20 18:54:快照停在 15:48,18:00 核验时早已发出的单全被判「未发货」,
|
||||
// 业务连续报了 3 单都是这个原因。这种时候数字必然偏大,必须红字告警。
|
||||
const coversDeadline = !!snapshotAt && snapshotAt >= round.deadline;
|
||||
const freshness = {
|
||||
stateMaxQuery: snapshotAt,
|
||||
ageMinutes: snapshotAgeMin,
|
||||
stale,
|
||||
coversDeadline,
|
||||
hint: [
|
||||
stale
|
||||
? `聚水潭订单状态镜像最新只到 ${String(snapshotAt).slice(11, 16)}(已 ${Math.round(snapshotAgeMin / 60 * 10) / 10} 小时未刷新)。`
|
||||
: '',
|
||||
!coversDeadline
|
||||
? `数据还没覆盖到本轮核验时刻 ${String(round.deadline).slice(11, 16)}:`
|
||||
+ '此后发出的单在这里仍显示「未发货」,下面的超时单量偏大,请以聚水潭实时状态为准。'
|
||||
: '',
|
||||
].filter(Boolean).join(' '),
|
||||
};
|
||||
|
||||
const payload = {
|
||||
generatedAt: formatDateTime(now),
|
||||
date: round.date,
|
||||
round: {
|
||||
key: round.key,
|
||||
label: round.label,
|
||||
verifyHour: round.verifyHour,
|
||||
windowEnd: round.windowEnd,
|
||||
},
|
||||
notStarted: false,
|
||||
isPreviousDay: false,
|
||||
rounds,
|
||||
deadline: round.deadline,
|
||||
// boundary='audit':轮次归属、已等时长都按**首次审核时间**算
|
||||
window: {
|
||||
start: round.windowStart,
|
||||
end: round.windowEnd,
|
||||
includeHistory: true,
|
||||
boundary: 'audit',
|
||||
measuredBy: 'audit',
|
||||
// 仓级例外窗口(山东青岗集/山东品牌 15:00、河南洛宁 13:00,仅 18:00 轮)
|
||||
overrides: windowOverrides,
|
||||
},
|
||||
summary,
|
||||
centers,
|
||||
orders,
|
||||
// 不考核的快递(前端文案直接用,避免两边各写一份)
|
||||
excludedCarriers: EXCLUDED_CARRIERS,
|
||||
// 不计考核清单(待审核 / 异常)—— 与 orders 互斥
|
||||
heldOrders,
|
||||
truncated: pendingRows.length > orders.length,
|
||||
detailLimit: limit,
|
||||
detailDegraded: false,
|
||||
freshness,
|
||||
cached: false,
|
||||
cacheAgeSec: 0,
|
||||
note: '',
|
||||
};
|
||||
|
||||
resultCache = { key: cacheKey, at: Date.now(), data: payload };
|
||||
return payload;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getShipTimeliness,
|
||||
clearShipTimelinessCache,
|
||||
DEFAULT_DETAIL_LIMIT,
|
||||
MAX_DETAIL_LIMIT,
|
||||
};
|
||||
@ -1,126 +0,0 @@
|
||||
/**
|
||||
* 配置负缓存 TTL 回归测试。
|
||||
*
|
||||
* 背景(线上真实故障):
|
||||
* 隧道抖动 → 系统库(走 SSH 隧道)读取失败 → 旧实现把「空候选」永久缓存下来
|
||||
* (入口是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy),
|
||||
* 此后永不重试,直接抛「业务数据库尚未配置」500,**必须重启后端**才能恢复。
|
||||
*
|
||||
* 修复:失败态记为「带 TTL 的负缓存」,到期自动重新解析。
|
||||
* 本文件锁定三条语义:
|
||||
* ① TTL 内不重复解析(不必每个请求都去撞系统库的慢超时)
|
||||
* ② TTL 到期后必须重试
|
||||
* ③ 上游恢复后能自动回到正常态,且成功态不会被 TTL 反复淘汰
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
// ── 必须在加载被测模块之前完成环境与替身准备 ─────────────────────────────
|
||||
// 1) 先清掉业务库环境变量,稳定走「无环境变量兜底」分支(否则 fallbacks 非空,不会报未配置)
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (/^(CLOUD_DB|APP_DB|SQLSERVER)_/u.test(key)) {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
// 2) 再缩短负缓存 TTL —— 必须在上面那次清理之后,否则会被同一前缀规则一并删掉
|
||||
process.env.CLOUD_DB_CONFIG_NEGATIVE_TTL_MS = '150';
|
||||
|
||||
// 3) 让「系统库读取」可控:替换 mysql2/promise 的 createPool
|
||||
const MYSQL_PROMISE_PATH = require.resolve('mysql2/promise');
|
||||
const realMysqlPromise = require(MYSQL_PROMISE_PATH);
|
||||
|
||||
let systemDbDialAttempts = 0;
|
||||
let systemDbMode = 'down'; // down = 模拟隧道断开;recovered = 模拟隧道已恢复
|
||||
|
||||
const systemConfigRow = {
|
||||
name: 'cloud_database',
|
||||
host: '127.0.0.1',
|
||||
port: 59999, // 故意指向无人监听的端口,避免测试真去连业务库
|
||||
database_name: 'JSReportReplica',
|
||||
username: 'probe',
|
||||
password: 'probe',
|
||||
ssl: 0,
|
||||
driver: 'mssql',
|
||||
use_env_config: 0,
|
||||
is_default: 1,
|
||||
};
|
||||
|
||||
require.cache[MYSQL_PROMISE_PATH].exports = Object.assign({}, realMysqlPromise, {
|
||||
createPool: () => {
|
||||
systemDbDialAttempts += 1;
|
||||
return {
|
||||
query: async () => {
|
||||
if (systemDbMode === 'down') {
|
||||
throw Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:13307'), {
|
||||
code: 'ECONNREFUSED',
|
||||
});
|
||||
}
|
||||
return [[systemConfigRow]];
|
||||
},
|
||||
end: async () => {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const NOT_CONFIGURED = /业务数据库尚未配置/u;
|
||||
|
||||
test('负缓存带 TTL:TTL 内不重复解析,到期后必须重试', async () => {
|
||||
await cloudDatabase.close();
|
||||
const base = systemDbDialAttempts;
|
||||
|
||||
await assert.rejects(
|
||||
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
|
||||
NOT_CONFIGURED
|
||||
);
|
||||
assert.equal(systemDbDialAttempts, base + 1, '首次查询应真正尝试解析配置');
|
||||
|
||||
await assert.rejects(
|
||||
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
|
||||
NOT_CONFIGURED
|
||||
);
|
||||
assert.equal(systemDbDialAttempts, base + 1, 'TTL 内应命中负缓存,不重复解析');
|
||||
|
||||
await sleep(220);
|
||||
await assert.rejects(
|
||||
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
|
||||
NOT_CONFIGURED
|
||||
);
|
||||
assert.equal(
|
||||
systemDbDialAttempts,
|
||||
base + 2,
|
||||
'TTL 到期后必须重试 —— 这正是「隧道恢复后无需重启后端」的关键'
|
||||
);
|
||||
});
|
||||
|
||||
test('隧道恢复后自动复原:TTL 到期重试能读回配置,且成功态不被 TTL 淘汰', async () => {
|
||||
await sleep(220); // 越过上一轮的负缓存 TTL
|
||||
systemDbMode = 'recovered';
|
||||
const base = systemDbDialAttempts;
|
||||
|
||||
await assert.rejects(
|
||||
() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'),
|
||||
(error) => {
|
||||
// 关键断言:不应再是「尚未配置」——说明配置已成功读回,
|
||||
// 失败只是发生在后面连业务库那一层(这里故意指向空端口)。
|
||||
assert.doesNotMatch(
|
||||
error.message,
|
||||
NOT_CONFIGURED,
|
||||
'配置读取应已恢复,不该再报「业务数据库尚未配置」'
|
||||
);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
assert.ok(systemDbDialAttempts > base, '上游恢复后应重新解析配置');
|
||||
|
||||
// 成功态 expiresAt = Infinity:后续查询不得再反复解析系统库
|
||||
const afterRecovery = systemDbDialAttempts;
|
||||
await assert.rejects(() => cloudDatabase.queryWithFreshConnection('SELECT 1 AS x'));
|
||||
assert.equal(
|
||||
systemDbDialAttempts,
|
||||
afterRecovery,
|
||||
'成功态不应被 TTL 反复淘汰(否则每个请求都要多打一次系统库)'
|
||||
);
|
||||
});
|
||||
@ -1,110 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
AVAILABLE_FLOW_FIELDS,
|
||||
buildInClause,
|
||||
fetchFlows,
|
||||
} = require('../src/services/dailyInventoryFlows');
|
||||
const { deriveAndStoreOpeningSnapshot } = require('../src/services/dailyInventoryOpening');
|
||||
|
||||
const BASELINE = [
|
||||
{ warehouse: '仓A主仓', dbWarehouse: '仓A', wmsCoId: 101 },
|
||||
{ warehouse: '仓B主仓', dbWarehouse: '仓B', wmsCoId: 102 },
|
||||
{ warehouse: '仓C主仓', dbWarehouse: null, wmsCoId: 103 },
|
||||
];
|
||||
|
||||
/** 构造一个按 SQL 关键字路由的假业务库 */
|
||||
function fakeCloudDb(handler) {
|
||||
return { query: async (sql, params) => handler(String(sql), params || {}) };
|
||||
}
|
||||
|
||||
test('buildInClause 展开参数且不为 async', () => {
|
||||
const { clause, params } = buildInClause([1, 2], 'w');
|
||||
assert.equal(clause, '@w0,@w1');
|
||||
assert.deepEqual(params, { w0: 1, w1: 2 });
|
||||
});
|
||||
|
||||
test('fetchFlows 查询四类单据并按符号约定返回(流入正、流出负)', async () => {
|
||||
const seen = [];
|
||||
const db = fakeCloudDb((sql, params) => {
|
||||
seen.push(sql);
|
||||
if (sql.includes('jst_purchaseItem')) return [{ wmsCoId: 101, qty: '100' }];
|
||||
if (sql.includes('jst_purchaseoutItem')) return [{ wmsCoId: 101, qty: '30' }];
|
||||
if (sql.includes('jst_OrderItem')) return [{ wmsCoId: 101, qty: '50' }];
|
||||
if (sql.includes('jst_returnAndRefundItem')) return [{ wmsCoId: 101, qty: '8' }];
|
||||
return [];
|
||||
});
|
||||
const map = await fetchFlows(db, [101, 102], '2026-09-26 01:00:00');
|
||||
const f = map.get('101');
|
||||
assert.equal(f.purchaseIn, 100, '采购进仓为流入正数');
|
||||
assert.equal(f.purchaseReturn, -30, '采购退货为流出负数');
|
||||
assert.equal(f.salesOut, -50, '销售出仓为流出负数');
|
||||
assert.equal(f.salesReturn, 8, '销售退货为流入正数');
|
||||
assert.equal(seen.length, 4, '应发出 4 条单据查询');
|
||||
assert.ok(seen.every((s) => s.includes('@from')), '每条查询都带 @from 参数');
|
||||
assert.deepEqual(AVAILABLE_FLOW_FIELDS.sort(), ['purchaseIn', 'purchaseReturn', 'salesOut', 'salesReturn']);
|
||||
});
|
||||
|
||||
test('fetchFlows 空仓库列表直接返回空 Map', async () => {
|
||||
const map = await fetchFlows(fakeCloudDb(() => []), [], '2026-09-26 01:00:00');
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
|
||||
test('deriveAndStoreOpeningSnapshot:全部仓库用实时库存扣流水回推,并保留基线仓库名', async () => {
|
||||
const upserts = [];
|
||||
const cloudDb = fakeCloudDb((sql, params) => {
|
||||
if (sql.includes('jst_inventoryInfo')) {
|
||||
return [
|
||||
{ wmsCoId: 101, qty: '520', skuCount: '13' },
|
||||
{ wmsCoId: 102, qty: '300', skuCount: '9' },
|
||||
{ wmsCoId: 103, qty: '10', skuCount: '2' },
|
||||
];
|
||||
}
|
||||
if (sql.includes('jst_purchaseItem')) return [{ wmsCoId: 102, qty: '60' }];
|
||||
if (sql.includes('jst_purchaseoutItem')) return [{ wmsCoId: 102, qty: '10' }];
|
||||
if (sql.includes('jst_OrderItem')) return [{ wmsCoId: 102, qty: '40' }];
|
||||
if (sql.includes('jst_returnAndRefundItem')) return [];
|
||||
return [];
|
||||
});
|
||||
const snapshotDb = {
|
||||
async upsertSnapshot(bizDate, rows) {
|
||||
upserts.push({ bizDate, rows });
|
||||
return rows.length;
|
||||
},
|
||||
};
|
||||
|
||||
const result = await deriveAndStoreOpeningSnapshot({
|
||||
cloudDb,
|
||||
snapshotDb,
|
||||
baseline: BASELINE,
|
||||
openingDate: '2026-09-25',
|
||||
latestBizDate: '2026-09-26',
|
||||
takenAt: null,
|
||||
});
|
||||
|
||||
assert.equal(result.count, 3);
|
||||
assert.equal(result.derivedCount, 3);
|
||||
assert.equal(upserts.length, 1);
|
||||
assert.equal(upserts[0].bizDate, '2026-09-25');
|
||||
|
||||
const byWh = new Map(upserts[0].rows.map((r) => [r.wmsCoId, r]));
|
||||
// 仓A:无流水 → 等于实时库存 520
|
||||
assert.equal(byWh.get(101).closingQty, 520);
|
||||
// 仓B:300 − 净流入(60 − 10 − 40) = 300 − 10 = 290
|
||||
assert.equal(byWh.get(102).closingQty, 290);
|
||||
// 仓C:无流水 → 等于实时库存 10
|
||||
assert.equal(byWh.get(103).closingQty, 10);
|
||||
// 带回基线仓库名,保证后续按 wmsCoId 读取正确
|
||||
assert.equal(byWh.get(102).warehouse, '仓B主仓');
|
||||
assert.equal(byWh.get(102).dbWarehouse, '仓B');
|
||||
});
|
||||
|
||||
test('fetchFlows 缺少数据库实例参数时立即抛错(防静默空结果)', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchFlows([101, 102], '2026-09-26 01:00:00'),
|
||||
/第一个参数必须是数据库实例/
|
||||
);
|
||||
});
|
||||
@ -1,333 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
DEFAULT_NOTIFY_TIME,
|
||||
MAX_RECIPIENTS,
|
||||
buildIdempotencyKey,
|
||||
normalizeFlag,
|
||||
normalizeNotifyTime,
|
||||
normalizeRecipients,
|
||||
} = require('../src/services/dailyInventoryNotificationSettings');
|
||||
const {
|
||||
ACTION_TITLE,
|
||||
NOTIFICATION_TITLE,
|
||||
buildNotificationContent,
|
||||
minutesOfDay,
|
||||
resolveBizDate,
|
||||
withRetry,
|
||||
} = require('../src/jobs/dailyInventoryNotificationJob');
|
||||
const {
|
||||
buildScreenshotNames,
|
||||
chinaTimeParts,
|
||||
} = require('../src/services/dailyInventoryScreenshot');
|
||||
|
||||
// ── 收件人名单规范化 ────────────────────────────────────────────
|
||||
test('normalizeRecipients 去空值、去空白、保持顺序', () => {
|
||||
assert.deepEqual(normalizeRecipients(['张三', ' ', '李四', '', null, undefined]), ['张三', '李四']);
|
||||
});
|
||||
|
||||
test('normalizeRecipients 去重且忽略首尾空格', () => {
|
||||
assert.deepEqual(normalizeRecipients([' 张三 ', '张三', '李四', ' 李四']), ['张三', '李四']);
|
||||
});
|
||||
|
||||
test('normalizeRecipients 支持逗号/中文逗号/分号/换行分隔的字符串', () => {
|
||||
assert.deepEqual(
|
||||
normalizeRecipients('张三,李四,王五;赵六\n孙七'),
|
||||
['张三', '李四', '王五', '赵六', '孙七']
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeRecipients 对非法输入返回空数组', () => {
|
||||
assert.deepEqual(normalizeRecipients(null), []);
|
||||
assert.deepEqual(normalizeRecipients(undefined), []);
|
||||
assert.deepEqual(normalizeRecipients(12345), []);
|
||||
});
|
||||
|
||||
test('normalizeRecipients 超量时截断到上限', () => {
|
||||
const list = Array.from({ length: MAX_RECIPIENTS + 5 }, (_, i) => `用户${i}`);
|
||||
const result = normalizeRecipients(list);
|
||||
assert.equal(result.length, MAX_RECIPIENTS);
|
||||
assert.equal(result[0], '用户0');
|
||||
});
|
||||
|
||||
// ── 通知时间校验 ────────────────────────────────────────────────
|
||||
test('normalizeNotifyTime 接受合法 HH:mm 并补零', () => {
|
||||
assert.equal(normalizeNotifyTime('1:05'), '01:05');
|
||||
assert.equal(normalizeNotifyTime('23:59'), '23:59');
|
||||
assert.equal(normalizeNotifyTime('00:00'), '00:00');
|
||||
});
|
||||
|
||||
test('normalizeNotifyTime 支持 HH:mm:ss 并截断到分钟', () => {
|
||||
assert.equal(normalizeNotifyTime('01:00:30'), '01:00');
|
||||
});
|
||||
|
||||
test('normalizeNotifyTime 非法值回退默认 01:00', () => {
|
||||
assert.equal(normalizeNotifyTime('24:00'), DEFAULT_NOTIFY_TIME);
|
||||
assert.equal(normalizeNotifyTime('12:60'), DEFAULT_NOTIFY_TIME);
|
||||
assert.equal(normalizeNotifyTime('abc'), DEFAULT_NOTIFY_TIME);
|
||||
assert.equal(normalizeNotifyTime(''), DEFAULT_NOTIFY_TIME);
|
||||
assert.equal(normalizeNotifyTime(null), DEFAULT_NOTIFY_TIME);
|
||||
});
|
||||
|
||||
test('normalizeFlag 只接受明确的布尔语义,未知值回退默认', () => {
|
||||
assert.equal(normalizeFlag('true', false), true);
|
||||
assert.equal(normalizeFlag('off', true), false);
|
||||
assert.equal(normalizeFlag('maybe', true), true);
|
||||
assert.equal(normalizeFlag(null, false), false);
|
||||
});
|
||||
|
||||
// ── 幂等键 ──────────────────────────────────────────────────────
|
||||
test('buildIdempotencyKey 正式推送按业务日期+收件人稳定生成', () => {
|
||||
const a = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
||||
const b = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
||||
const c = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '李四', mode: 'scheduled' });
|
||||
assert.equal(a, b);
|
||||
assert.notEqual(a, c);
|
||||
});
|
||||
|
||||
test('buildIdempotencyKey 测试推送带随机数,不占用正式幂等记录', () => {
|
||||
const formal = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'scheduled' });
|
||||
const t1 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n1' });
|
||||
const t2 = buildIdempotencyKey({ bizDate: '2026-09-25', recipient: '张三', mode: 'test', nonce: 'n2' });
|
||||
assert.notEqual(t1, t2);
|
||||
assert.notEqual(t1, formal);
|
||||
});
|
||||
|
||||
// ── 通知正文 ────────────────────────────────────────────────────
|
||||
test('buildNotificationContent 包含数据日期、生成时间、图片地址与页面链接', () => {
|
||||
const content = buildNotificationContent({
|
||||
bizDate: '2026-09-25',
|
||||
takenAt: '2026/09/25 01:00:00',
|
||||
imageUrl: 'https://cdn.example.com/daily-inventory/2026-09-25/daily-inventory-20260925-010000.png',
|
||||
pageUrl: 'https://ck.yydd.ai/planning/operation/daily-inventory',
|
||||
});
|
||||
assert.match(content, /2026-09-25/);
|
||||
assert.match(content, /2026\/09\/25 01:00:00/);
|
||||
assert.match(content, /https:\/\/cdn\.example\.com\//);
|
||||
assert.match(content, /https:\/\/ck\.yydd\.ai\/planning\/operation\/daily-inventory/);
|
||||
assert.match(content, new RegExp(ACTION_TITLE));
|
||||
assert.equal(NOTIFICATION_TITLE, '每日进销存数据');
|
||||
});
|
||||
|
||||
// ── 业务日期与调度时刻 ──────────────────────────────────────────
|
||||
test('resolveBizDate 取北京时间昨日(与页面 T-1 口径一致)', () => {
|
||||
assert.equal(resolveBizDate(new Date('2026-09-25T01:00:00+08:00')), '2026-09-24');
|
||||
assert.equal(resolveBizDate(new Date('2026-09-25T23:30:00+08:00')), '2026-09-24');
|
||||
});
|
||||
|
||||
test('minutesOfDay 正确换算,非法值返回 null', () => {
|
||||
assert.equal(minutesOfDay('00:00'), 0);
|
||||
assert.equal(minutesOfDay('01:00'), 60);
|
||||
assert.equal(minutesOfDay('23:59'), 1439);
|
||||
assert.equal(minutesOfDay('24:00'), null);
|
||||
assert.equal(minutesOfDay('abc'), null);
|
||||
assert.equal(minutesOfDay(null), null);
|
||||
});
|
||||
|
||||
test('chinaTimeParts 生成对象键所需的日期与时间片段', () => {
|
||||
const parts = chinaTimeParts(new Date('2026-09-25T01:00:00+08:00'));
|
||||
assert.equal(parts.bizDate, '2026-09-25');
|
||||
assert.equal(parts.dateKey, '20260925');
|
||||
assert.equal(parts.timeKey, '010000');
|
||||
assert.equal(
|
||||
`daily-inventory/${parts.bizDate}/daily-inventory-${parts.dateKey}-${parts.timeKey}.png`,
|
||||
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScreenshotNames 用业务日期归档目录与文件名,时间取执行时刻', () => {
|
||||
const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' });
|
||||
assert.equal(names.fileName, 'daily-inventory-20260925-010000.png');
|
||||
assert.equal(
|
||||
names.objectKey,
|
||||
'daily-inventory/2026-09-25/daily-inventory-20260925-010000.png'
|
||||
);
|
||||
});
|
||||
|
||||
test('buildScreenshotNames 业务日期与执行日期不同时以业务日期为准', () => {
|
||||
// 凌晨 01:00 推送的是「昨日」数据,归档必须落在业务日而不是执行日
|
||||
const names = buildScreenshotNames({ bizDate: '2026-09-25', timeKey: '010000' });
|
||||
assert.ok(!names.objectKey.includes('2026-09-26'));
|
||||
assert.ok(names.objectKey.startsWith('daily-inventory/2026-09-25/'));
|
||||
});
|
||||
|
||||
test('buildScreenshotNames 业务日期缺失时回退空目录占位而不抛错', () => {
|
||||
const names = buildScreenshotNames({ bizDate: '', timeKey: '010000' });
|
||||
assert.equal(names.fileName, 'daily-inventory--010000.png');
|
||||
});
|
||||
|
||||
// ── 幂等记录:同一业务日期 + 同一用户只成功通知一次 ────────────
|
||||
async function withStubbedState(runner) {
|
||||
const database = require('../src/config/database');
|
||||
const settings = require('../src/services/dailyInventoryNotificationSettings');
|
||||
const original = database.local;
|
||||
const store = new Map();
|
||||
|
||||
database.local = {
|
||||
async query(sql, params) {
|
||||
const text = String(sql);
|
||||
if (text.startsWith('SELECT config_value')) {
|
||||
const value = store.get(params[0]);
|
||||
return value === undefined ? [] : [{ configValue: value }];
|
||||
}
|
||||
if (text.startsWith('SELECT id FROM system_config')) {
|
||||
const value = store.get(params[0]);
|
||||
return value === undefined ? [] : [{ id: 1 }];
|
||||
}
|
||||
// UPDATE system_config SET config_value = ? ...
|
||||
store.set(params[params.length - 1], params[0]);
|
||||
return { affectedRows: 1 };
|
||||
},
|
||||
async insert(_table, data) {
|
||||
store.set(data.config_key, data.config_value);
|
||||
return { insertId: 1 };
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await runner(settings);
|
||||
} finally {
|
||||
database.local = original;
|
||||
}
|
||||
}
|
||||
|
||||
test('markNotified 后同一业务日期同一用户命中幂等,跨日期互不影响', async () => {
|
||||
await withStubbedState(async (settings) => {
|
||||
assert.equal(await settings.hasNotified('2026-09-25', '苏适'), false);
|
||||
await settings.markNotified('2026-09-25', '苏适', { deliveryId: 'd-1' });
|
||||
assert.equal(await settings.hasNotified('2026-09-25', '苏适'), true);
|
||||
// 同一天其他用户不受影响
|
||||
assert.equal(await settings.hasNotified('2026-09-25', '李四'), false);
|
||||
// 同一用户换一天应当重新通知
|
||||
assert.equal(await settings.hasNotified('2026-09-26', '苏适'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test('appendRunRecord 保留执行时间、截图地址、通知对象与失败原因', async () => {
|
||||
await withStubbedState(async (settings) => {
|
||||
await settings.appendRunRecord({
|
||||
runAt: '2026-09-25T01:00:00.000Z',
|
||||
bizDate: '2026-09-25',
|
||||
mode: 'scheduled',
|
||||
success: false,
|
||||
error: '所有收件人均通知失败',
|
||||
screenshotUrl: 'https://scqny.jiansun.vip/daily-inventory/2026-09-25/x.png',
|
||||
recipients: [{ recipient: '苏适', status: 'failed', error: '通知中心 502' }],
|
||||
});
|
||||
const runs = await settings.listRunRecords(5);
|
||||
assert.equal(runs.length, 1);
|
||||
assert.equal(runs[0].bizDate, '2026-09-25');
|
||||
assert.equal(runs[0].success, false);
|
||||
assert.equal(runs[0].error, '所有收件人均通知失败');
|
||||
assert.equal(runs[0].recipients[0].recipient, '苏适');
|
||||
assert.ok(runs[0].screenshotUrl.startsWith('https://'));
|
||||
});
|
||||
});
|
||||
|
||||
// ── 重试与退避 ──────────────────────────────────────────────────
|
||||
test('withRetry 第二次成功后返回结果', async () => {
|
||||
let calls = 0;
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
if (calls < 2) throw new Error('临时失败');
|
||||
return 'ok';
|
||||
},
|
||||
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
|
||||
);
|
||||
assert.equal(result, 'ok');
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
test('withRetry 超过次数后抛出最后一次错误', async () => {
|
||||
let calls = 0;
|
||||
await assert.rejects(
|
||||
withRetry(
|
||||
async () => {
|
||||
calls += 1;
|
||||
throw new Error(`失败${calls}`);
|
||||
},
|
||||
{ label: '单元测试', attempts: 3, baseDelayMs: 1 }
|
||||
),
|
||||
/失败3/
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
// ── 截图身份:必须使用系统库中真实存在的服务账号 ──────────────
|
||||
test('ensureScreenshotServiceUser 账号不存在时创建,存在时复用 id', async () => {
|
||||
const database = require('../src/config/database');
|
||||
const screenshot = require('../src/services/dailyInventoryScreenshot');
|
||||
const original = database.local;
|
||||
const calls = { select: 0, insert: 0, update: 0 };
|
||||
let existing = null;
|
||||
|
||||
database.local = {
|
||||
async query(sql, params) {
|
||||
if (String(sql).startsWith('SELECT id, role, status FROM users')) {
|
||||
calls.select += 1;
|
||||
return existing ? [existing] : [];
|
||||
}
|
||||
calls.update += 1;
|
||||
return { affectedRows: 1 };
|
||||
},
|
||||
async insert(_table, data) {
|
||||
calls.insert += 1;
|
||||
assert.equal(data.role, 'admin');
|
||||
assert.equal(data.status, 'active');
|
||||
assert.equal(data.auth_source, 'service');
|
||||
assert.ok(String(data.password_hash || '').length > 20, '密码哈希不能为空');
|
||||
existing = { id: 4242, role: data.role, status: data.status };
|
||||
return { insertId: 4242 };
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
screenshot.resetScreenshotServiceUserCache();
|
||||
const created = await screenshot.ensureScreenshotServiceUser();
|
||||
assert.equal(created, 4242);
|
||||
assert.equal(calls.insert, 1);
|
||||
assert.equal(calls.select, 1);
|
||||
|
||||
// 第二次调用命中缓存,不再查库
|
||||
assert.equal(await screenshot.ensureScreenshotServiceUser(), 4242);
|
||||
assert.equal(calls.select, 1);
|
||||
} finally {
|
||||
database.local = original;
|
||||
screenshot.resetScreenshotServiceUserCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('signScreenshotToken 的 sub 指向服务账号 id 且通过签名校验', async () => {
|
||||
const database = require('../src/config/database');
|
||||
const screenshot = require('../src/services/dailyInventoryScreenshot');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const original = database.local;
|
||||
const originalSecret = process.env.CUBEJS_SECRET;
|
||||
process.env.CUBEJS_SECRET = originalSecret || 'test-secret-for-unit';
|
||||
|
||||
database.local = {
|
||||
async query() {
|
||||
return [{ id: 99, role: 'admin', status: 'active' }];
|
||||
},
|
||||
async insert() {
|
||||
throw new Error('不应在账号已存在时插入');
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
screenshot.resetScreenshotServiceUserCache();
|
||||
const token = await screenshot.signScreenshotToken();
|
||||
const payload = jwt.verify(token, process.env.CUBEJS_SECRET || 'test-secret');
|
||||
assert.equal(payload.sub, '99');
|
||||
assert.equal(payload.role, 'admin');
|
||||
assert.equal(payload.authSource, 'service');
|
||||
} finally {
|
||||
database.local = original;
|
||||
process.env.CUBEJS_SECRET = originalSecret;
|
||||
screenshot.resetScreenshotServiceUserCache();
|
||||
}
|
||||
});
|
||||
@ -1,101 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const XLSX = require('xlsx');
|
||||
|
||||
const {
|
||||
normalizeRecords,
|
||||
parseWorkbook,
|
||||
checkBalance,
|
||||
} = require('../src/services/dailyInventoryOfficial');
|
||||
|
||||
const RECORDS = [
|
||||
{
|
||||
一级功能按键: '大货库存监控',
|
||||
二级功能按键: '每日进销存',
|
||||
仓库: '简尚-崔桥工厂仓主仓',
|
||||
期初数量: 90811,
|
||||
加工进仓: 0,
|
||||
采购退货: 0,
|
||||
采购进仓: 0,
|
||||
其它进仓: 0,
|
||||
其它退货: 0,
|
||||
销售出仓: -3252,
|
||||
盘点: 0,
|
||||
加工出仓: 0,
|
||||
调拨出: -881,
|
||||
销售退货: 0,
|
||||
调拨入: 4144,
|
||||
期初: 0,
|
||||
其它出库: 0,
|
||||
期末数量: 90822,
|
||||
},
|
||||
{
|
||||
仓库: '简尚-新外贸仓主仓',
|
||||
期初数量: 5165,
|
||||
销售出仓: -1743,
|
||||
期末数量: 3422,
|
||||
},
|
||||
];
|
||||
|
||||
test('normalizeRecords 解析官方导出并映射仓库 ID', () => {
|
||||
const { rows, warnings } = normalizeRecords(RECORDS);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(warnings, []);
|
||||
|
||||
const [cuiqiao] = rows;
|
||||
assert.equal(cuiqiao.warehouse, '简尚-崔桥工厂仓主仓');
|
||||
assert.equal(cuiqiao.wmsCoId, 15038426); // 来自项目仓库清单
|
||||
assert.equal(cuiqiao.openingQty, 90811);
|
||||
assert.equal(cuiqiao.closingQty, 90822);
|
||||
assert.equal(cuiqiao.flows.salesOut, -3252);
|
||||
assert.equal(cuiqiao.flows.transferIn, 4144);
|
||||
assert.equal(cuiqiao.flows.otherIn, 0); // 导出里显式为 0 的列保留 0
|
||||
// 导出里没有出现的流水列保持 null,不能被当成 0
|
||||
assert.equal(rows[1].flows.transferOut, null);
|
||||
assert.equal(rows[1].flows.stocktaking, null);
|
||||
// 导出里的第二个「期初」列不参与映射
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(cuiqiao.flows, 'openingQty'), false);
|
||||
});
|
||||
|
||||
test('normalizeRecords 跳过无仓库名的行,未匹配仓库的 wmsCoId 为 null', () => {
|
||||
const { rows } = normalizeRecords([
|
||||
{ 仓库: '', 期初数量: 1, 期末数量: 1 },
|
||||
{ 仓库: '某某新仓主仓', 期初数量: 10, 期末数量: 10 },
|
||||
]);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].warehouse, '某某新仓主仓');
|
||||
assert.equal(rows[0].wmsCoId, null);
|
||||
});
|
||||
|
||||
test('checkBalance 校验「期初 + 流水 = 期末」,不闭合的仓库会被列出', () => {
|
||||
const { rows } = normalizeRecords(RECORDS);
|
||||
assert.deepEqual(checkBalance(rows), []);
|
||||
|
||||
const broken = checkBalance([{
|
||||
warehouse: '差异仓', openingQty: 100, closingQty: 90, flows: { salesOut: -5 },
|
||||
}]);
|
||||
assert.equal(broken.length, 1);
|
||||
assert.equal(broken[0].diff, 5);
|
||||
});
|
||||
|
||||
test('parseWorkbook 能从 xlsx 缓冲区还原仓库行(表头不在首行也能定位)', () => {
|
||||
const grid = [
|
||||
['说明', '这一行是导出时附加的标题'],
|
||||
['一级功能按键', '二级功能按键', '仓库', '期初数量', '销售出仓', '调拨入', '期末数量'],
|
||||
['大货库存监控', '每日进销存', '简尚-崔桥工厂仓主仓', 90811, -3252, 4144, 90822],
|
||||
['', '', '简尚-新外贸仓主仓', 5165, -1743, 0, 3422],
|
||||
];
|
||||
const ws = XLSX.utils.aoa_to_sheet(grid);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Sheet2');
|
||||
const buffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' });
|
||||
|
||||
const { rows, sheetName } = parseWorkbook(buffer);
|
||||
assert.equal(sheetName, 'Sheet2');
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].warehouse, '简尚-崔桥工厂仓主仓');
|
||||
assert.equal(rows[0].flows.transferIn, 4144);
|
||||
assert.equal(rows[1].closingQty, 3422);
|
||||
});
|
||||
@ -1,13 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { _private } = require('../src/api/mkhBom');
|
||||
|
||||
test('manual MKH sync always forces a live operational refresh', () => {
|
||||
assert.deepEqual(
|
||||
_private.buildSyncQuery({ page: 2, fresh: 0, live: 0 }),
|
||||
{ page: 2, fresh: '1', live: '1' }
|
||||
);
|
||||
});
|
||||
@ -234,54 +234,6 @@ test('replenish: stock prefers the brand-warehouse realtime value, snapshot only
|
||||
assert.equal(zero.warningLevel, '缺货');
|
||||
});
|
||||
|
||||
test('replenish: live refresh ignores delayed sales and stock snapshots', () => {
|
||||
const live = metrics({
|
||||
preferLiveData: true,
|
||||
avgSale: 9,
|
||||
recentShippedQty: 30,
|
||||
realtimeStock: null,
|
||||
packStock: 0,
|
||||
monitorStock: 99,
|
||||
});
|
||||
|
||||
assert.equal(live.dailySale, 2, '实时日销必须按 30 件 ÷ 15 天计算');
|
||||
assert.equal(live.dailySaleSource, 'orders-live');
|
||||
assert.equal(live.stock, 0, '实时库存源无记录时不得回退延迟快照库存 99');
|
||||
assert.equal(live.stockSource, 'live-missing');
|
||||
});
|
||||
|
||||
test('replenish: the larger current-stock source wins when the daily inventory table is stale (⑦-d)', () => {
|
||||
// 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货
|
||||
const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 });
|
||||
assert.equal(fillFromZero.stock, 4);
|
||||
assert.equal(fillFromZero.stockSource, 'pack');
|
||||
assert.notEqual(fillFromZero.warningLevel, '缺货');
|
||||
|
||||
const fillFromMissing = metrics({ avgSale: 1, realtimeStock: null, packStock: 5, monitorStock: 0 });
|
||||
assert.equal(fillFromMissing.stock, 5);
|
||||
assert.equal(fillFromMissing.stockSource, 'pack');
|
||||
|
||||
// 新入库先到箱表:库存快照仍为旧的正数 4 时,不能把箱表的 47 压住
|
||||
const packWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 47, monitorStock: 0 });
|
||||
assert.equal(packWins.stock, 47);
|
||||
assert.equal(packWins.stockSource, 'pack');
|
||||
|
||||
// 散货只在 inventoryInfo 中:该表较大时仍保留完整库存
|
||||
const realtimeWins = metrics({ avgSale: 1, realtimeStock: 100, packStock: 47, monitorStock: 0 });
|
||||
assert.equal(realtimeWins.stock, 100);
|
||||
assert.equal(realtimeWins.stockSource, 'realtime');
|
||||
|
||||
// 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照
|
||||
const bothZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 0, monitorStock: 9 });
|
||||
assert.equal(bothZero.stock, 0);
|
||||
assert.equal(bothZero.stockSource, 'realtime');
|
||||
|
||||
// 库存表整个品牌仓无记录、箱表也没货 → 才回退监控快照
|
||||
const snapshot = metrics({ avgSale: 1, realtimeStock: null, packStock: 0, monitorStock: 9 });
|
||||
assert.equal(snapshot.stock, 9);
|
||||
assert.equal(snapshot.stockSource, 'monitor');
|
||||
});
|
||||
|
||||
test('replenish: products without 7-day sales never warn', () => {
|
||||
const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 });
|
||||
assert.equal(noSale.hasWarning, false);
|
||||
@ -368,16 +320,16 @@ test('replenish: pending work order fields default to empty without any order',
|
||||
assert.equal(none.pendingWorkOrderEta, null);
|
||||
assert.equal(none.pendingWorkOrderNo, null);
|
||||
|
||||
// 已入库数量追平后,即使上游状态文本或单据计数延迟,也不再按「已排产」处理
|
||||
// 数量为 0 但确实挂着加工单(未入库)时,同样按「已排产」处理
|
||||
const zeroQty = metrics({
|
||||
avgSale: 1,
|
||||
realtimeStock: 0,
|
||||
pendingWorkOrderQty: 0,
|
||||
pendingWorkOrderCount: 2,
|
||||
});
|
||||
assert.equal(zeroQty.hasPendingWorkOrder, false);
|
||||
assert.equal(zeroQty.warningLevel, '缺货');
|
||||
assert.equal(zeroQty.hasWarning, true);
|
||||
assert.equal(zeroQty.hasPendingWorkOrder, true);
|
||||
assert.equal(zeroQty.warningLevel, '已排产');
|
||||
assert.equal(zeroQty.hasWarning, false);
|
||||
});
|
||||
|
||||
test('replenish: products without sales stay "no sale" even with a pending work order', () => {
|
||||
@ -450,147 +402,6 @@ test('pending order: only MKH finished products count, combination codes are exc
|
||||
assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true);
|
||||
assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false);
|
||||
assert.equal(_private.isExcludedSaleShop(null), false);
|
||||
// 日销兜底(⑦-a)排除异常单:0.01 元特殊单「标了发货但没真出库」,不是销售
|
||||
assert.equal(_private.SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, 0.01);
|
||||
assert.deepEqual([..._private.SALE_EXCLUDED_ORDER_LABEL_KEYWORDS], ['特殊单']);
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 0.01 }), true, '实付 0.01 元 → 异常单');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 0 }), true, '实付 0 元 → 异常单');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: '0.01' }), true, '字符串金额同样识别');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 908.01, labels: '模块化成品,特殊单,禁止拆分' }), true,
|
||||
'标签含「特殊单」→ 异常单(即使实付正常)');
|
||||
// ⚠️ 反例:正常单普遍有折扣免额,**不能**按 free_amount 判异常,否则会误杀
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 356, freeAmount: 50 }), false, '有免额的正常单不得排除');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: 1338.82, freeAmount: 236.26 }), false, '有免额的正常单不得排除');
|
||||
// 金额取不到时不排除(宁可用数据也别误杀)
|
||||
assert.equal(_private.isExcludedSaleOrder({}), false, '金额缺省 → 不排除');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: null }), false, '金额为 null → 不排除');
|
||||
assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除');
|
||||
});
|
||||
|
||||
test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
const originalGetPool = cloudDatabase.getPool;
|
||||
const queried = [];
|
||||
|
||||
// 复现 MKHDNZ0409260700491:当日 15:19 入库,日更的 inventoryInfo 还没有这条,
|
||||
// 只有小时级的箱表有;监控快照 jsStock = 0。
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (sql.includes('jst_packInventoryInfo')) {
|
||||
queried.push('pack');
|
||||
return [{ skuId: 'MKHDNZ0409260700491', qty: 4 }];
|
||||
}
|
||||
if (sql.includes('jst_inventoryInfo')) {
|
||||
queried.push('inventory');
|
||||
return [];
|
||||
}
|
||||
if (sql.includes('pm_stockMonitor')) {
|
||||
queried.push('monitor');
|
||||
return [{
|
||||
skuId: 'MKHDNZ0409260700491',
|
||||
fifteenAvgSale: 0,
|
||||
monitorStock: 0,
|
||||
safetyDays: 3,
|
||||
suggestStock: 0,
|
||||
monitorDate: new Date('2026-09-18T00:00:00Z'),
|
||||
}];
|
||||
}
|
||||
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) {
|
||||
queried.push('outbound');
|
||||
return [];
|
||||
}
|
||||
if (sql.includes('jst_qmcust_orderlist')) {
|
||||
queried.push('qmcust');
|
||||
return [];
|
||||
}
|
||||
queried.push('orders');
|
||||
return [];
|
||||
};
|
||||
cloudDatabase.getPool = async () => ({
|
||||
request: () => ({ input() {}, query: async () => ({ recordset: [] }) }),
|
||||
});
|
||||
|
||||
try {
|
||||
const index = await svc._private.loadReplenishIndex();
|
||||
assert.ok(queried.includes('pack'), '箱表必须被读到');
|
||||
assert.ok(queried.includes('inventory'), '实时库存表仍要读');
|
||||
const row = index.bySku.get('MKHDNZ0409260700491');
|
||||
assert.equal(row.stock, 4, '当日入库的货必须显示出来,不能显示 0');
|
||||
assert.equal(row.stockSource, 'pack');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
cloudDatabase.getPool = originalGetPool;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
// ⑦-e 成品库存多仓合并(2026-10-17):
|
||||
// 实物搬到「简尚-横林二仓品牌仓」(16132681) 时,归属仓 (15870495) 为 0,
|
||||
// 只查归属仓会把有货的成品算成 0(本次 5 个误报缺货 + 2 个误报预警)。
|
||||
test('replenish index: sums stock across brand warehouse whitelist (⑦-e)', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
const originalGetPool = cloudDatabase.getPool;
|
||||
const captured = [];
|
||||
|
||||
cloudDatabase.query = async (sql) => {
|
||||
captured.push(sql);
|
||||
if (sql.includes('jst_packInventoryInfo')) {
|
||||
// 箱表:同样的「老仓 0 / 新仓 10」格局
|
||||
return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }];
|
||||
}
|
||||
if (sql.includes('jst_inventoryInfo')) {
|
||||
// 关键:SUM 之后返回的是**多仓合计**。旧口径只查单仓时这里拿不到这笔货。
|
||||
return [{ skuId: 'MKHDNZ0409260600788', qty: 10 }];
|
||||
}
|
||||
if (sql.includes('pm_stockMonitor')) {
|
||||
return [{
|
||||
skuId: 'MKHDNZ0409260600788',
|
||||
fifteenAvgSale: 1,
|
||||
monitorStock: 0,
|
||||
safetyDays: 3,
|
||||
suggestStock: 0,
|
||||
monitorDate: new Date('2026-10-17T00:00:00Z'),
|
||||
}];
|
||||
}
|
||||
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) return [];
|
||||
if (sql.includes('jst_qmcust_orderlist')) return [];
|
||||
return [];
|
||||
};
|
||||
cloudDatabase.getPool = async () => ({
|
||||
request: () => ({ input() {}, query: async () => ({ recordset: [] }) }),
|
||||
});
|
||||
|
||||
try {
|
||||
const index = await svc._private.loadReplenishIndex();
|
||||
const row = index.bySku.get('MKHDNZ0409260600788');
|
||||
assert.equal(row.stock, 10, '多仓求和后必须看到新仓的 10 件,不能是 0');
|
||||
|
||||
// SQL 层必须真的按白名单多仓取数,而不是退回单仓
|
||||
const invSql = captured.find((s) => s.includes('jst_inventoryInfo'));
|
||||
assert.ok(invSql.includes('IN (15870495,16132681)'),
|
||||
`实时库存必须按仓白名单取数,实际: ${invSql}`);
|
||||
assert.ok(invSql.includes('GROUP BY') && invSql.includes('SUM('),
|
||||
'实时库存必须多仓 SUM + GROUP BY(旧版是 ORDER BY updateTime DESC 取首条)');
|
||||
const packSql = captured.find((s) => s.includes('jst_packInventoryInfo'));
|
||||
assert.ok(packSql.includes('IN (15870495,16132681)'),
|
||||
`箱表必须按同一份仓白名单取数,实际: ${packSql}`);
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
cloudDatabase.getPool = originalGetPool;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
// 白名单常量本身要被导出(新增同类仓时改这一处即可)
|
||||
test('replenish index: brand warehouse whitelist contains main + Henglin No.2 brand warehouse', () => {
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const ids = svc._private.BRAND_WMS_CO_IDS;
|
||||
assert.ok(Array.isArray(ids), 'BRAND_WMS_CO_IDS 必须是数组');
|
||||
assert.ok(ids.includes(15870495), '必须含崔桥工厂品牌仓(归属主仓)');
|
||||
assert.ok(ids.includes(16132681), '必须含横林二仓品牌仓(2026-10-13 新增)');
|
||||
});
|
||||
|
||||
test('order index: reads both channel tables, merges and dedupes by o_id', async () => {
|
||||
@ -599,14 +410,7 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
|
||||
const originalQuery = cloudDatabase.query;
|
||||
const queried = [];
|
||||
|
||||
// 出库表命中集合(⑦-c):9005 / 9007 / 9011 有真实出库记录;
|
||||
// 9012(金额正常但无出库)、9009 / 9010 / 9015(异常单)刻意不在此列
|
||||
const outboundHits = ['9005', '9007', '9011'];
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) {
|
||||
queried.push('outbound');
|
||||
return outboundHits.map((oId) => ({ oId }));
|
||||
}
|
||||
if (sql.includes('jst_qmcust_orderlist')) {
|
||||
queried.push('qmcust');
|
||||
return [
|
||||
@ -654,76 +458,6 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
|
||||
shopName: '某某测试专用店铺',
|
||||
skus: '1.MKHDNZ0409260608888',
|
||||
},
|
||||
// 异常单(⑦-a):0.01 元全免单,标了已发货但没真出库 → 排除
|
||||
// (复现 MKHDNZ0410260700005:实付 0.01 / 免额 908 / 标签含「特殊单」)
|
||||
{
|
||||
oId: 9009,
|
||||
sendDate: new Date('2026-09-18T00:00:00Z'),
|
||||
shopName: '拼多多-亿家达书房家具官方旗舰店',
|
||||
payAmount: 0.01,
|
||||
freeAmount: 908,
|
||||
labels: '大单待确认,模块化成品,特殊单,禁止拆分,线上发货,转x00005',
|
||||
skus: '9.MKHDNZ0410260700005*9',
|
||||
},
|
||||
// 异常单(⑦-a):实付正常但标签含「特殊单」→ 同样排除
|
||||
{
|
||||
oId: 9010,
|
||||
sendDate: new Date('2026-09-18T00:00:00Z'),
|
||||
shopName: '拼多多-亿家达书房家具官方旗舰店',
|
||||
payAmount: 500,
|
||||
labels: '模块化成品,特殊单',
|
||||
skus: '1.MKHDNZ0410260700099',
|
||||
},
|
||||
// 对照:有折扣免额的**正常单**必须照常计入(第一版按 free_amount 判会误杀它)
|
||||
{
|
||||
oId: 9011,
|
||||
sendDate: new Date('2026-09-18T00:00:00Z'),
|
||||
shopName: '拼多多-HX旗舰店103',
|
||||
payAmount: 356,
|
||||
freeAmount: 50,
|
||||
labels: '店铺-拼多多,模块化成品',
|
||||
skus: '1.MKHDNZ0410260700023',
|
||||
},
|
||||
// ⑦-c:金额正常、但出库表查不到 → 系统标了「已发货」货没真出 → 排除
|
||||
// (业务原话:标了已发货但没有实际出库的不需要出现缺货预警)
|
||||
{
|
||||
oId: 9012,
|
||||
sendDate: new Date('2026-09-10T00:00:00Z'),
|
||||
shopName: '拼多多-HX旗舰店105',
|
||||
payAmount: 388,
|
||||
labels: '发货后取消,发货后售后,风控订单',
|
||||
skus: '1.MKHDNZ0409260601888',
|
||||
},
|
||||
// ⑦-c 豁免①:拆分单的出库挂在子单上,父单查不到属正常 → 必须保留
|
||||
// (实测窗口内 40 张 Split 单 100% 无出库记录)
|
||||
{
|
||||
oId: 9013,
|
||||
status: 'Split',
|
||||
sendDate: new Date('2026-09-15T00:00:00Z'),
|
||||
shopName: '京东-HX旗舰店104',
|
||||
payAmount: 1194.14,
|
||||
labels: '以旧换新,模块化成品',
|
||||
skus: '1.MKHDNZ0409260601777',
|
||||
},
|
||||
// ⑦-c 豁免②:今天发货的单,当天出库尚未同步完 → 必须保留
|
||||
{
|
||||
oId: 9014,
|
||||
sendDate: new Date(),
|
||||
shopName: '京东-HX旗舰店104',
|
||||
payAmount: 500,
|
||||
labels: '模块化成品',
|
||||
skus: '1.MKHDNZ0409260601666',
|
||||
},
|
||||
// ⑦-c:拆分单/今天单虽然豁免**出库**校验,但金额/标签判据照样生效
|
||||
{
|
||||
oId: 9015,
|
||||
status: 'Split',
|
||||
sendDate: new Date('2026-09-15T00:00:00Z'),
|
||||
shopName: '拼多多-亿家达书房家具官方旗舰店',
|
||||
payAmount: 0.01,
|
||||
labels: '特殊单,模块化成品',
|
||||
skus: '1.MKHDNZ0410260700777',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
@ -731,7 +465,7 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
|
||||
svc.clearMkhBomCache();
|
||||
const index = await _private.loadOrderIndex();
|
||||
|
||||
assert.deepEqual(queried.sort(), ['orders', 'outbound', 'qmcust'], '两张订单表 + 出库表都必须读到');
|
||||
assert.deepEqual(queried.sort(), ['orders', 'qmcust'], '两张订单表都必须读到');
|
||||
assert.equal(index.degraded, false);
|
||||
|
||||
const a = index.pendingBySku.get('MKHDNZ0409260700059');
|
||||
@ -748,58 +482,7 @@ test('order index: reads both channel tables, merges and dedupes by o_id', async
|
||||
assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加');
|
||||
assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除');
|
||||
assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除');
|
||||
// 异常单排除(⑦-a):0.01 元全免单、标签含「特殊单」都不计入已发货
|
||||
assert.equal(index.shippedBySku.has('MKHDNZ0410260700005'), false, '0.01 元异常单不得计入日销');
|
||||
assert.equal(index.shippedBySku.has('MKHDNZ0410260700099'), false, '标签含「特殊单」的不得计入日销');
|
||||
// 有免额的正常单必须照常计入
|
||||
assert.equal(index.shippedBySku.get('MKHDNZ0410260700023'), 1, '有免额的正常单不得被误排除');
|
||||
// ⑦-c 出库校验:标了已发货但出库表查不到 → 不计入日销
|
||||
assert.equal(index.shippedBySku.has('MKHDNZ0409260601888'), false, '无实际出库记录的不得计入日销');
|
||||
// ⑦-c 豁免:拆分单 / 今天发货的单不能用出库判定,必须保留
|
||||
assert.equal(index.shippedBySku.get('MKHDNZ0409260601777'), 1, '拆分单无出库记录属正常,不得误杀');
|
||||
assert.equal(index.shippedBySku.get('MKHDNZ0409260601666'), 1, '今天发货的出库尚未同步,不得误杀');
|
||||
// ⑦-c 豁免只免「出库校验」,金额/标签判据照样生效
|
||||
assert.equal(index.shippedBySku.has('MKHDNZ0410260700777'), false, '拆分单的 0.01 元异常单仍要排除');
|
||||
assert.equal(index.shippedBySku.size, 4, '被排除的店铺/异常单/无出库单不进入索引');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('order index: outbound lookup failure skips the check instead of dropping sales', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
// 出库表整体不可用(例如视图未同步)——此时宁可漏杀不可错杀:
|
||||
// 不做出库排除(否则真实销售会被整批砍掉),但要标 degraded 触发短 TTL 重试。
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) {
|
||||
throw new Error('出库视图不可用');
|
||||
}
|
||||
if (sql.includes('jst_qmcust_orderlist')) return [];
|
||||
return [
|
||||
{
|
||||
oId: 9101,
|
||||
sendDate: new Date('2026-09-10T00:00:00Z'),
|
||||
shopName: '京东-HX旗舰店104',
|
||||
payAmount: 388,
|
||||
labels: '模块化成品',
|
||||
skus: '1.MKHDNZ0409260601777',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
const index = await _private.loadOrderIndex();
|
||||
assert.equal(index.degraded, true, '出库源失败要标 degraded,让接口透出并尽快重试');
|
||||
assert.equal(
|
||||
index.shippedBySku.get('MKHDNZ0409260601777'),
|
||||
1,
|
||||
'出库数据取不到时不得据此砍掉销量(退化成只按金额/标签判据)'
|
||||
);
|
||||
assert.equal(index.shippedBySku.size, 1, '被排除的店铺不进入索引');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
@ -1256,261 +939,3 @@ test('export: 配件不足 rows are flagged and carry no replenish target', () =
|
||||
// 可组装数一并导出,便于业务核对短板
|
||||
assert.equal(rows[0].assemblableQty, 0);
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 缓存代际(cacheEpoch):修复「点同步最新数据,反而显示旧数据」
|
||||
//
|
||||
// 症状:用户点「同步最新数据」后,页面 2~10 分钟内持续显示旧值,像在用缓存。
|
||||
// 成因:缓存是「先挂 pending → await 结果 → 无条件写回」。若某个查询在途时用户点了
|
||||
// 同步(清空缓存),那个发起于清缓存之前的查询读到的是旧快照,返回时会把旧值
|
||||
// 写回缓存,覆盖掉清缓存后新请求填入的新值 —— 谁后完成谁生效。
|
||||
//
|
||||
// 下面第一个用例复现旧行为(修复前失败),第二个用例锁定正确语义。
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('cache epoch: 清缓存后在途的旧查询结果不得写回缓存(否则同步按钮点完仍是旧数据)', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
const CODE = 'DNZTESTCACHE01';
|
||||
const staleQty = 9; // 清缓存前那一刻库里/旧快照的值
|
||||
const freshQty = 1; // 清缓存后重新查到的值
|
||||
|
||||
let phase = 'old';
|
||||
let stockQueries = 0;
|
||||
let releaseStale;
|
||||
const staleGate = new Promise((resolve) => { releaseStale = resolve; });
|
||||
let markStaleStarted;
|
||||
const staleStarted = new Promise((resolve) => { markStaleStarted = resolve; });
|
||||
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (!sql.includes('jst_inventoryInfo')) return [];
|
||||
stockQueries += 1;
|
||||
if (phase === 'old') {
|
||||
markStaleStarted();
|
||||
await staleGate; // 卡住旧查询:模拟「发起于此、期间用户点了同步」的慢查询
|
||||
return [{ code: CODE, qty: staleQty }];
|
||||
}
|
||||
return [{ code: CODE, qty: freshQty }];
|
||||
};
|
||||
|
||||
try {
|
||||
// 1) 旧查询发起并卡住
|
||||
const inFlight = svc._private.loadComponentStock([CODE]);
|
||||
await staleStarted;
|
||||
|
||||
// 2) 用户点「同步最新数据」→ 清缓存并推进代际
|
||||
const cleared = svc.clearMkhBomCache();
|
||||
assert.ok(cleared.cacheEpoch >= 1, '清缓存必须推进代际号');
|
||||
phase = 'new';
|
||||
|
||||
// 3) 清缓存后的新查询先返回,拿到新值并写缓存
|
||||
const fresh = await svc._private.loadComponentStock([CODE]);
|
||||
assert.equal(fresh.stockMap.get(CODE), freshQty);
|
||||
assert.equal(stockQueries, 2, '清缓存后必须真正重查数据源');
|
||||
|
||||
// 4) 旧查询这才返回(它读到的仍是旧快照)
|
||||
releaseStale();
|
||||
const staleResult = await inFlight;
|
||||
assert.equal(staleResult.stockMap.get(CODE), staleQty,
|
||||
'过期结果仍应原样返回给发起它的那个请求,不能改变它自己的语义');
|
||||
|
||||
// 5) 🔥 关键断言:缓存里必须还是新值
|
||||
const after = await svc._private.loadComponentStock([CODE]);
|
||||
assert.equal(after.stockMap.get(CODE), freshQty,
|
||||
'清缓存后返回的过期结果写回了缓存 → 用户会看到「点了同步还是旧数据」');
|
||||
assert.equal(stockQueries, 2,
|
||||
'第 5 步应直接命中缓存,不应再打数据库(说明缓存确实被写入了新值)');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
const CODE = 'DNZTESTCACHE02';
|
||||
let stockQueries = 0;
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (!sql.includes('jst_inventoryInfo')) return [];
|
||||
stockQueries += 1;
|
||||
return [{ code: CODE, qty: 3 }];
|
||||
};
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
const first = await svc._private.loadComponentStock([CODE]);
|
||||
const second = await svc._private.loadComponentStock([CODE]);
|
||||
assert.equal(first.stockMap.get(CODE), 3);
|
||||
assert.equal(second.stockMap.get(CODE), 3);
|
||||
assert.equal(stockQueries, 1, '同一次缓存代际内,第二次必须命中缓存不再查库');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 配件库存的箱表兜底(2026-09-30 修正)
|
||||
//
|
||||
// 症状:配件监控页显示该配件有 200 件(货位 ZT-30、正常),MKH-BOM 却把成品
|
||||
// MKHDNZ0409260600849 判成「配件不足」、可组装数 0。
|
||||
// 成因:配件监控页是「库存表 → 同仓箱表 → 快照」三级兜底,而 MKH-BOM 的
|
||||
// loadComponentStock **只查 jst_inventoryInfo**。上游库存表滞后停在 0 时,
|
||||
// 箱表(小时级)已经有货 → 两个页面结论相反。
|
||||
// 口径:与配件监控页 buildRealtimeStockSql 对齐 —— 库存表有正数就认它,
|
||||
// 为 0 / 缺行才用同仓 jst_packInventoryInfo 的 bin 非空行合计兜底。
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('配件库存兜底:库存表为 0 / 缺行时用同仓箱表补,但正库存不被覆盖', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
const ONLY_PACK = 'DNZTESTPACK01'; // 库存表 0、箱表 200 → 必须取 200(误判就是这里)
|
||||
const HAS_INV = 'DNZTESTPACK02'; // 库存表 5、箱表 300 → 必须取 5(箱表只记部分装箱量)
|
||||
const PACK_ZERO = 'DNZTESTPACK03'; // 库存表 0、箱表 0 → 0
|
||||
const NEITHER = 'DNZTESTPACK04'; // 两表都没有 → 0
|
||||
|
||||
const INV_ROWS = [ // 库存表:滞后停在旧值
|
||||
{ code: ONLY_PACK, qty: 0 },
|
||||
{ code: HAS_INV, qty: 5 },
|
||||
{ code: PACK_ZERO, qty: 0 },
|
||||
];
|
||||
const PACK_ROWS = [ // 同仓箱表(bin 非空):小时级,已反映真实到货
|
||||
{ code: ONLY_PACK, qty: 200 },
|
||||
{ code: HAS_INV, qty: 300 },
|
||||
{ code: PACK_ZERO, qty: 0 },
|
||||
];
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (!sql.includes('jst_inventoryInfo')) return [];
|
||||
// 按 SQL 形状分流:旧实现只查库存表(SQL 里没有箱表名)→ 拿不到箱表数据,
|
||||
// 兜底失效、ONLY_PACK 被判 0(对照组 = 修复前必然失败)。
|
||||
if (!sql.includes('jst_packInventoryInfo')) return INV_ROWS;
|
||||
return [
|
||||
...INV_ROWS.map((row) => ({ src: 'inv', ...row })),
|
||||
...PACK_ROWS.map((row) => ({ src: 'pack', ...row })),
|
||||
];
|
||||
};
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
const { stockMap, degraded } = await svc._private.loadComponentStock([
|
||||
ONLY_PACK, HAS_INV, PACK_ZERO, NEITHER,
|
||||
]);
|
||||
assert.equal(degraded, false);
|
||||
assert.equal(stockMap.get(ONLY_PACK), 200,
|
||||
'库存表为 0 时必须用箱表兜底,否则配件被误判不足(本问题的根因)');
|
||||
assert.equal(stockMap.get(HAS_INV), 5,
|
||||
'库存表有正库存时必须以它为准,不能被箱表覆盖');
|
||||
assert.equal(stockMap.get(PACK_ZERO), 0, '箱表也是 0 时不能补出正数');
|
||||
assert.equal(stockMap.get(NEITHER), 0, '两表都查不到 = 该仓无库存,仍按 0');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('配件库存兜底:单条查询取两张表,且箱表必须过滤 bin 为空的行(防腐化)', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
const seen = [];
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (!sql.includes('jst_inventoryInfo')) return [];
|
||||
seen.push(sql);
|
||||
return [];
|
||||
};
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
await svc._private.loadComponentStock(['DNZTESTPACK05']);
|
||||
assert.equal(seen.length, 1, '一个分片只应打一条查询(避免多一次往返与快照时点差异)');
|
||||
const sql = seen[0];
|
||||
assert.match(sql, /jst_packInventoryInfo/, '必须查箱表,否则兜底不存在');
|
||||
assert.match(sql, /NULLIF\(LTRIM\(RTRIM\(p\.bin\)\), N''\)\s+IS NOT NULL/,
|
||||
'箱表必须过滤 bin 为空的行 —— 那是装箱记录,不是库存');
|
||||
assert.match(sql, /p\.wms_co_id = @componentStockWmsCoId/,
|
||||
'箱表必须限定配件归属仓,不能跨仓汇总');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('配件库存兜底:查询失败仍降级为「不做判定」,不得当成 0', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
|
||||
const CODE = 'DNZTESTPACK06';
|
||||
cloudDatabase.query = async (sql) => {
|
||||
if (!sql.includes('jst_inventoryInfo')) return [];
|
||||
throw new Error('模拟连接抖动');
|
||||
};
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
const { stockMap, degraded } = await svc._private.loadComponentStock([CODE]);
|
||||
assert.equal(degraded, true, '查询失败必须降级,让调用方放弃配件不足判定(宁可少标不误标)');
|
||||
assert.equal(stockMap.get(CODE), undefined, '失败时不得把未知库存当作 0 返回');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => {
|
||||
const cloudDatabase = require('../src/config/cloudDatabase');
|
||||
const svc = require('../src/services/mkhBomService');
|
||||
const originalQuery = cloudDatabase.query;
|
||||
const originalGetPool = cloudDatabase.getPool;
|
||||
|
||||
let queryCount = 0;
|
||||
let releaseMonitor;
|
||||
let markMonitorStarted;
|
||||
const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; });
|
||||
const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; });
|
||||
|
||||
cloudDatabase.query = async (sql) => {
|
||||
queryCount += 1;
|
||||
if (sql.includes('pm_stockMonitor')) {
|
||||
markMonitorStarted();
|
||||
await monitorGate;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
cloudDatabase.getPool = async () => ({
|
||||
request: () => ({
|
||||
input() { return this; },
|
||||
async query() { queryCount += 1; return { recordset: [] }; },
|
||||
}),
|
||||
});
|
||||
|
||||
try {
|
||||
svc.clearMkhBomCache();
|
||||
const first = svc.refreshMkhOperationalData();
|
||||
await monitorStarted;
|
||||
const second = svc.refreshMkhOperationalData();
|
||||
releaseMonitor();
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
assert.equal(firstResult.refreshed, true);
|
||||
assert.equal(secondResult.joined, true);
|
||||
assert.equal(secondResult.refreshedAt, firstResult.refreshedAt);
|
||||
|
||||
const afterConcurrent = queryCount;
|
||||
const reused = await svc.refreshMkhOperationalData();
|
||||
assert.equal(reused.reused, true);
|
||||
assert.equal(queryCount, afterConcurrent,
|
||||
'15 秒保护窗内不应重复访问业务云库');
|
||||
} finally {
|
||||
cloudDatabase.query = originalQuery;
|
||||
cloudDatabase.getPool = originalGetPool;
|
||||
svc.clearMkhBomCache();
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,171 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
buildGradeCase,
|
||||
buildInClause,
|
||||
fetchPickingBinSkuRows,
|
||||
} = require('../src/services/pickingBinSalesAnalysis');
|
||||
const {
|
||||
PICKING_BIN_GRADE_RULES,
|
||||
PICKING_BIN_DEFAULT_DAYS,
|
||||
PICKING_BIN_WINDOW_OPTIONS,
|
||||
PICKING_BIN_BIN_TYPES,
|
||||
} = require('../src/config/pickingBinSalesAnalysis');
|
||||
|
||||
test('buildInClause 按位展开参数', () => {
|
||||
const { clause, params } = buildInClause([101, 102], 'w');
|
||||
assert.equal(clause, '@w0,@w1');
|
||||
assert.deepEqual(params, { w0: 101, w1: 102 });
|
||||
});
|
||||
|
||||
test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => {
|
||||
const expr = buildGradeCase();
|
||||
PICKING_BIN_GRADE_RULES.filter((rule) => rule.lower > 0).forEach((rule) => {
|
||||
assert.ok(expr.includes(`THEN '${rule.key}'`), `${rule.key} 未出现在分档表达式中`);
|
||||
});
|
||||
assert.ok(expr.includes("THEN 'slow2'"));
|
||||
assert.ok(expr.includes("ELSE 'zero'"));
|
||||
});
|
||||
|
||||
test('等级按商品销量判定(默认表达式取 s.salesQty,不含仓位汇总列)', () => {
|
||||
const expr = buildGradeCase();
|
||||
assert.ok(expr.includes('ISNULL(s.salesQty, 0) / @days'));
|
||||
assert.ok(!expr.includes('x.salesQty'));
|
||||
});
|
||||
|
||||
test('默认窗口为 30 天,统计范围固定为拣选位', () => {
|
||||
assert.equal(PICKING_BIN_DEFAULT_DAYS, 30);
|
||||
assert.ok(PICKING_BIN_WINDOW_OPTIONS.includes(30));
|
||||
assert.deepEqual(PICKING_BIN_BIN_TYPES, ['拣选']);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 首参不是数据库实例时抛错', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchPickingBinSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 缺失必要窗口参数时返回空数组,不打库', async () => {
|
||||
let called = 0;
|
||||
const db = { query: async () => { called += 1; return [{ skuId: 'S1' }]; } };
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 0 }), []);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 按仓位×商品输出,等级与日销来自商品整档销量', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => {
|
||||
captured = { sqlText, params };
|
||||
return [
|
||||
{
|
||||
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU1',
|
||||
stockQty: 12, skuCount: 2, salesQty: 600, allocatedSalesQty: 300,
|
||||
dailySales: 20, gradeKey: 'hot',
|
||||
},
|
||||
{
|
||||
wmsCoId: 101, binName: 'XA01-01-01', binType: '拣选', skuId: 'SKU2',
|
||||
stockQty: 8, skuCount: 2, salesQty: 45, allocatedSalesQty: 22.5,
|
||||
dailySales: 1.5, gradeKey: 'slow1',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const rows = await fetchPickingBinSkuRows(db, [101, 102], {
|
||||
shipFrom: '2026-09-08 01:00:00',
|
||||
shipTo: '2026-10-08 01:00:00',
|
||||
days: 30,
|
||||
});
|
||||
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows.map((r) => r.skuId), ['SKU1', 'SKU2']);
|
||||
// 同一仓位两个商品拆两行,库存是各商品在该仓位的实际库存
|
||||
assert.deepEqual(rows.map((r) => r.stockQty), [12, 8]);
|
||||
// 等级按商品自身日销,同一仓位的多行可以不同
|
||||
assert.deepEqual(rows.map((r) => r.gradeKey), ['hot', 'slow1']);
|
||||
// 分摊销量 = 商品销量 ÷ 占用的同类型仓位数(用于按行求和)
|
||||
assert.deepEqual(rows.map((r) => r.allocatedSalesQty), [300, 22.5]);
|
||||
assert.deepEqual(rows[0], {
|
||||
wmsCoId: '101',
|
||||
binName: 'XA01-01-01',
|
||||
binType: '拣选',
|
||||
skuId: 'SKU1',
|
||||
stockQty: 12,
|
||||
skuCount: 2,
|
||||
salesQty: 600,
|
||||
allocatedSalesQty: 300,
|
||||
dailySales: 20,
|
||||
gradeKey: 'hot',
|
||||
});
|
||||
|
||||
// 参数必须带上两个仓库、发货窗口与天数
|
||||
assert.equal(captured.params.w0, 101);
|
||||
assert.equal(captured.params.w1, 102);
|
||||
assert.equal(captured.params.v0, 101);
|
||||
assert.equal(captured.params.shipFrom, '2026-09-08 01:00:00');
|
||||
assert.equal(captured.params.shipTo, '2026-10-08 01:00:00');
|
||||
assert.equal(captured.params.days, 30);
|
||||
|
||||
// 商品销量按占用仓位数分摊(供求和),但等级依据的是未分摊的商品销量
|
||||
assert.ok(captured.sqlText.includes('/ NULLIF(sp.binCount, 0)'));
|
||||
assert.ok(captured.sqlText.includes('ISNULL(s.salesQty, 0) / @days AS DECIMAL(18, 4)'));
|
||||
// 统计范围固定为真实拣选仓位:pack_type=Bin 且 bin_group 为空;只统计有库存的行
|
||||
assert.ok(captured.sqlText.includes("p.pack_type = N'Bin'"));
|
||||
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
|
||||
assert.ok(captured.sqlText.includes('ISNULL(p.qty, 0) <> 0'));
|
||||
assert.ok(captured.sqlText.includes('HAVING ABS(SUM(CAST(b.binSkuQty AS DECIMAL(18, 2)))) > 0.0001'));
|
||||
});
|
||||
|
||||
test('fetchPickingBinSkuRows 只统计真实拣选仓位,排除 Pack 箱记录且不带类型参数', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
|
||||
};
|
||||
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
|
||||
// Pack 箱记录和存货位被排除,且不再有 binType IN (...) 的可变过滤
|
||||
assert.ok(captured.sqlText.includes("p.pack_type = N'Bin'"));
|
||||
assert.ok(captured.sqlText.includes("ISNULL(p.bin_group, '') = ''"));
|
||||
assert.ok(!captured.sqlText.includes('b.binType IN'));
|
||||
assert.equal(captured.params.t0, undefined);
|
||||
assert.equal(captured.params.binTypes, undefined);
|
||||
// 多来源 union 后必须再按 仓库+商品 汇总,否则同一仓位行会被拆成多行
|
||||
assert.ok(captured.sqlText.includes('shipAgg AS ('));
|
||||
assert.ok(captured.sqlText.includes('LEFT JOIN shipAgg s'));
|
||||
assert.ok(!captured.sqlText.includes('LEFT JOIN ship s'));
|
||||
});
|
||||
|
||||
test('销量覆盖 4 个来源:线上普通行 + 组合装展开 + 全民客户普通行 + 全民客户组合装展开', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => { captured = { sqlText, params }; return []; },
|
||||
};
|
||||
await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 30 });
|
||||
|
||||
// ① 线上店铺订单普通行
|
||||
assert.ok(captured.sqlText.includes('FROM rpt.jst_OrderItem'), '缺少 jst_OrderItem 普通行分支');
|
||||
// ② 线上订单组合装行按映射表展开为子件
|
||||
assert.ok(captured.sqlText.includes('JOIN rpt.jst_comboProductItem'), '缺少组合装展开分支');
|
||||
assert.ok(captured.sqlText.includes("oi.sku_type = 'combine'"), '缺少组合装行识别条件');
|
||||
// ③ 全民客户(B2B 分销)渠道普通行,排除拆单父单 Split 与问题单
|
||||
assert.ok(captured.sqlText.includes('FROM rpt.jst_qmcust_orderlist_item'), '缺少 qmcust 分支');
|
||||
assert.ok(captured.sqlText.includes("'Split', 'Question'"), 'qmcust 未排除拆单父单/问题单');
|
||||
// 四个分支共用同一发货窗口
|
||||
assert.equal(captured.sqlText.split('o.send_date >= @shipFrom').length, 5);
|
||||
});
|
||||
|
||||
test('gradeKey 缺失时兜底为 zero', async () => {
|
||||
const db = { query: async () => [{ wmsCoId: 101, binName: 'X', binType: '拣选', skuId: 'S1', stockQty: 5 }] };
|
||||
const rows = await fetchPickingBinSkuRows(db, [101], { shipFrom: 'a', shipTo: 'b', days: 15 });
|
||||
assert.equal(rows[0].gradeKey, 'zero');
|
||||
assert.equal(rows[0].salesQty, 0);
|
||||
assert.equal(rows[0].allocatedSalesQty, 0);
|
||||
assert.equal(rows[0].dailySales, 0);
|
||||
assert.equal(rows[0].skuId, 'S1');
|
||||
});
|
||||
@ -1,149 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
buildGradeCase,
|
||||
buildInClause,
|
||||
fetchGradeRows,
|
||||
fetchZeroSaleSkuRows,
|
||||
} = require('../src/services/stockSalesAnalysis');
|
||||
const { SALES_GRADE_RULES } = require('../src/config/stockSalesAnalysis');
|
||||
|
||||
test('buildInClause 按位展开仓库参数', () => {
|
||||
const { clause, params } = buildInClause([101, 102], 'w');
|
||||
assert.equal(clause, '@w0,@w1');
|
||||
assert.deepEqual(params, { w0: 101, w1: 102 });
|
||||
});
|
||||
|
||||
test('buildGradeCase 覆盖全部正向等级,并以销售=0 兜底', () => {
|
||||
const expr = buildGradeCase();
|
||||
SALES_GRADE_RULES.filter((rule) => rule.lower > 0).forEach((rule) => {
|
||||
assert.ok(expr.includes(`THEN '${rule.key}'`), `${rule.key} 未出现在分档表达式中`);
|
||||
});
|
||||
assert.ok(expr.includes("THEN 'slow2'"));
|
||||
assert.ok(expr.includes("ELSE 'zero'"));
|
||||
});
|
||||
|
||||
test('fetchGradeRows 首参不是数据库实例时抛错,避免静默返回空数组', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchGradeRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchGradeRows 无仓库或缺失窗口时直接返回空结果', async () => {
|
||||
let called = 0;
|
||||
const db = { query: async () => { called += 1; return []; } };
|
||||
assert.deepEqual(await fetchGradeRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchGradeRows(db, [101], { shipFrom: null, shipTo: 'b', days: 30 }), []);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test('fetchGradeRows 把仓库参数与窗口参数一并传给业务库', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => {
|
||||
captured = { sqlText, params };
|
||||
return [
|
||||
{ wmsCoId: 101, gradeKey: 'hot', skuCount: 2, stockQty: 438, salesQty: 1136, dailySales: 37.87 },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const rows = await fetchGradeRows(db, [101, 102], {
|
||||
shipFrom: '2026-08-28 01:00:00',
|
||||
shipTo: '2026-09-27 01:00:00',
|
||||
days: 30,
|
||||
});
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].wmsCoId, '101');
|
||||
assert.equal(rows[0].stockQty, 438);
|
||||
|
||||
assert.equal(captured.params.w0, 101);
|
||||
assert.equal(captured.params.w1, 102);
|
||||
assert.equal(captured.params.v0, 101);
|
||||
assert.equal(captured.params.days, 30);
|
||||
assert.equal(captured.params.shipFrom, '2026-08-28 01:00:00');
|
||||
assert.equal(captured.params.shipTo, '2026-09-27 01:00:00');
|
||||
assert.ok(captured.sqlText.includes('@days'));
|
||||
assert.ok(captured.sqlText.includes('rpt.jst_inventoryInfo'));
|
||||
assert.ok(captured.sqlText.includes('rpt.jst_OrderItem'));
|
||||
});
|
||||
|
||||
test('fetchZeroSaleSkuRows 首参不是数据库实例时抛错,避免静默返回空数组', async () => {
|
||||
await assert.rejects(
|
||||
() => fetchZeroSaleSkuRows({}, [101], { shipFrom: 'a', shipTo: 'b', days: 30 }),
|
||||
TypeError
|
||||
);
|
||||
});
|
||||
|
||||
test('fetchZeroSaleSkuRows 无仓库或缺失窗口时直接返回空结果', async () => {
|
||||
let called = 0;
|
||||
const db = { query: async () => { called += 1; return []; } };
|
||||
assert.deepEqual(await fetchZeroSaleSkuRows(db, [], { shipFrom: 'a', shipTo: 'b', days: 30 }), []);
|
||||
assert.deepEqual(await fetchZeroSaleSkuRows(db, [101], { shipFrom: 'a', shipTo: null, days: 30 }), []);
|
||||
assert.equal(called, 0);
|
||||
});
|
||||
|
||||
test('fetchZeroSaleSkuRows 复用同一分档口径并关联 SKU 名称', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText, params) => {
|
||||
captured = { sqlText, params };
|
||||
return [
|
||||
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A01-01-1', stockQty: 7, salesQty: 0 },
|
||||
{ wmsCoId: 101, skuId: 'SKU-A', skuName: '款式A', binName: 'A02-03-1', stockQty: 5, salesQty: 0 },
|
||||
{ wmsCoId: 101, skuId: 'SKU-B', skuName: '款式B', binName: null, stockQty: 3, salesQty: 0 },
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
const rows = await fetchZeroSaleSkuRows(db, [101, 102], {
|
||||
shipFrom: '2026-08-28 01:00:00',
|
||||
shipTo: '2026-09-27 01:00:00',
|
||||
days: 30,
|
||||
});
|
||||
|
||||
// 每个仓位独立一行;无仓位的 SKU 保留一行(仓位为空、库存取总库存)
|
||||
assert.equal(rows.length, 3);
|
||||
assert.equal(rows[0].wmsCoId, '101');
|
||||
assert.equal(rows[0].skuName, '款式A');
|
||||
assert.equal(rows[0].binName, 'A01-01-1');
|
||||
assert.equal(rows[0].stockQty, 7);
|
||||
assert.equal(rows[1].binName, 'A02-03-1');
|
||||
assert.equal(rows[1].stockQty, 5);
|
||||
assert.equal(rows[2].binName, '');
|
||||
assert.equal(rows[2].stockQty, 3);
|
||||
|
||||
// 口径必须与 fetchGradeRows 一致:同一段 gradeCase 表达式 + 同样的「有库存或有销量」过滤
|
||||
assert.ok(captured.sqlText.includes("ELSE 'zero'"));
|
||||
assert.ok(captured.sqlText.includes("g.gradeKey = 'zero'"));
|
||||
assert.ok(captured.sqlText.includes('rpt.jst_sku'));
|
||||
assert.ok(captured.sqlText.includes("NOT IN ('Delete', 'Cancelled')"));
|
||||
assert.equal(captured.params.w0, 101);
|
||||
assert.equal(captured.params.days, 30);
|
||||
|
||||
// 系统仓位:来自 rpt.jst_packInventoryInfo,按「仓库+SKU+仓位」聚合出仓位级库存,
|
||||
// 每仓位一行(COALESCE 兜底无仓位 SKU 取总库存),排除无仓位与零库存记录
|
||||
assert.ok(captured.sqlText.includes('rpt.jst_packInventoryInfo'));
|
||||
assert.ok(captured.sqlText.includes('COALESCE(bn.binQty, g.stockQty)'));
|
||||
assert.ok(captured.sqlText.includes('binName'));
|
||||
});
|
||||
|
||||
test('fetchZeroSaleSkuRows 的仓位必须按同一仓库匹配,避免跨仓错配', async () => {
|
||||
let captured = null;
|
||||
const db = {
|
||||
query: async (sqlText) => {
|
||||
captured = sqlText;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
await fetchZeroSaleSkuRows(db, [101], {
|
||||
shipFrom: '2026-08-28 01:00:00', shipTo: '2026-09-27 01:00:00', days: 30,
|
||||
});
|
||||
assert.ok(captured.includes('bn.wmsCoId = g.wmsCoId AND bn.skuId = g.skuId'));
|
||||
assert.ok(captured.includes("ISNULL(p.qty, 0) <> 0"));
|
||||
});
|
||||
@ -1,527 +0,0 @@
|
||||
/**
|
||||
* 仓储中心物流转运异常 —— 口径回归测试(2026-10-21)
|
||||
*
|
||||
* 锁定几条最容易翻车的语义:
|
||||
* ① 12 个发货仓的关键词归属:「山东青岗集仓唯品仓」不能被「山东青岗集仓」抢走;
|
||||
* 品牌仓不能被主仓抢走;「京仓-横林二仓京东云仓」业务排除;
|
||||
* 山东/河南的代发仓不能误并入。
|
||||
* ② 核验批次:09:00 核验**前一日**发货的单;未到 09:00 时默认看昨天那批,
|
||||
* 不能拿今天的空批次冒充结论。
|
||||
* ③ 异常判定:已签收正常;未揽收/无物流单/异常/拒收/其他 → 异常;
|
||||
* 运输中类只在「最后轨迹距核验时刻 ≥ 24h」才算运输停滞。
|
||||
* ④ 一单多包裹:明细按包裹保留,汇总按**内部单号**去重(异常率不能虚高)。
|
||||
* ⑤ 缓存:第二次命中缓存,force=1 必须绕过。
|
||||
*
|
||||
* ⚠️ 本文件不连数据库,纯逻辑 + 打桩。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const registry = require('../src/services/warehouseLogisticsExceptionRegistry');
|
||||
|
||||
// ── ① 仓库归属 ──────────────────────────────────────────────────────────
|
||||
test('12 个发货仓都能按关键词归位', () => {
|
||||
const cases = [
|
||||
['简尚-横林二仓', 'HL2'],
|
||||
['简尚-横林三仓', 'HL3'],
|
||||
['简尚-横林二仓品牌仓', 'HL2_BRAND'],
|
||||
['简尚-崔桥工厂仓', 'CQ'],
|
||||
['简尚-崔桥工厂品牌仓', 'CQ_BRAND'],
|
||||
['简尚-崔桥工厂半成品包装项目仓', 'CQ_WIP'],
|
||||
['简尚-新苏北仓', 'XSB'],
|
||||
['简尚-睢宁仓', 'SN'],
|
||||
['简尚-山东青岗集仓', 'SD_QGJ'],
|
||||
['简尚-山东品牌仓', 'SD_BRAND'],
|
||||
['简尚-山东青岗集仓唯品仓', 'SD_QGJ_VIP'],
|
||||
['简尚-河南洛宁仓', 'HN_LN'],
|
||||
];
|
||||
assert.equal(registry.CENTERS.length, 12);
|
||||
for (const [name, key] of cases) {
|
||||
assert.equal(registry.resolveCenter(name)?.key, key, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('唯品仓 / 品牌仓不会被主仓的关键词抢走(最长匹配)', () => {
|
||||
assert.notEqual(
|
||||
registry.resolveCenter('简尚-山东青岗集仓唯品仓').key,
|
||||
registry.resolveCenter('简尚-山东青岗集仓').key,
|
||||
'唯品仓必须独立,不能并进青岗集仓'
|
||||
);
|
||||
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓').key, 'CQ_WIP');
|
||||
});
|
||||
|
||||
test('子仓能并入主仓,无关代发仓 / 京东云仓被排除', () => {
|
||||
assert.equal(registry.resolveCenter('简尚-运营C横林二仓专用仓')?.key, 'HL2');
|
||||
assert.equal(registry.resolveCenter('简尚-分销部睢宁专用仓')?.key, 'SN');
|
||||
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
|
||||
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null, '业务指定排除');
|
||||
for (const name of ['工厂-山东菏泽森美代发仓', '工厂-河南千优代发仓', '简尚-苏州福利品仓']) {
|
||||
assert.equal(registry.resolveCenter(name), null, `${name} 不该并入任何主仓`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── ② 核验批次 ──────────────────────────────────────────────────────────
|
||||
const at = (d, h, m = 0) => new Date(2026, 9, d, h, m, 0); // 2026-10-xx 本机时区
|
||||
|
||||
test('已过 09:00 → 核验今天这批(发货日是昨天)', () => {
|
||||
const b = registry.resolveBatch(undefined, at(21, 9, 30));
|
||||
assert.equal(b.verifyDate, '2026-10-21');
|
||||
assert.equal(b.shipDate, '2026-10-20');
|
||||
assert.equal(b.deadline, '2026-10-21 09:00:00');
|
||||
assert.equal(b.windowStart, '2026-10-20 00:00:00');
|
||||
assert.equal(b.windowEnd, '2026-10-21 00:00:00');
|
||||
assert.equal(b.started, true);
|
||||
assert.equal(b.isToday, true);
|
||||
});
|
||||
|
||||
test('未到 09:00 → 今天这批还没跑,默认退回昨天那批', () => {
|
||||
const b = registry.resolveBatch(undefined, at(21, 8, 30));
|
||||
assert.equal(b.verifyDate, '2026-10-20');
|
||||
assert.equal(b.shipDate, '2026-10-19');
|
||||
assert.equal(b.started, true, '昨天的批次早就到点了');
|
||||
assert.equal(b.lookback, true);
|
||||
});
|
||||
|
||||
test('显式指定今天但还没到 09:00 → notStarted,不能给结论', async () => {
|
||||
const b = registry.resolveBatch('2026-10-21', at(21, 8, 0));
|
||||
assert.equal(b.started, false);
|
||||
});
|
||||
|
||||
test('批次护栏:未来日期压到今天,超过 30 天压到下限', () => {
|
||||
const future = registry.resolveBatch('2026-12-01', at(21, 10, 0));
|
||||
assert.equal(future.verifyDate, '2026-10-21');
|
||||
const tooOld = registry.resolveBatch('2026-01-01', at(21, 10, 0));
|
||||
assert.equal(tooOld.verifyDate, '2026-09-22', `回看下限应为今天-29天,实际 ${tooOld.verifyDate}`);
|
||||
});
|
||||
|
||||
// ── ③ 异常判定 ──────────────────────────────────────────────────────────
|
||||
const batch = registry.resolveBatch('2026-10-21', at(21, 10, 0));
|
||||
const svcPath = require.resolve('../src/services/warehouseLogisticsExceptionService');
|
||||
const { classify } = require(svcPath);
|
||||
|
||||
test('已签收 = 正常;未揽收/无物流单/异常/拒收/其他 = 异常', () => {
|
||||
const row = (o) => ({ logistic_id: 'L', logistic_status_name: '', logistic_time: null, ...o });
|
||||
assert.equal(classify(row({ logistic_status_name: '已签收' }), batch), null);
|
||||
assert.equal(classify(row({ logistic_status_name: '未揽收' }), batch), 'NOT_PICKED');
|
||||
assert.equal(classify(row({ logistic_status_name: '无物流单' }), batch), 'NO_TRACKING');
|
||||
assert.equal(classify(row({ logistic_status_name: '异常' }), batch), 'CARRIER_EXCEPTION');
|
||||
assert.equal(classify(row({ logistic_status_name: '拒收' }), batch), 'RETURNED');
|
||||
// 「其他」实测全是「包裹已换单退回」
|
||||
assert.equal(classify(row({ logistic_status_name: '其他' }), batch), 'RETURNED');
|
||||
});
|
||||
|
||||
test('运输中 / 派送中一律不考核(有「运输」字样,往后中转记录不看)', () => {
|
||||
// 2026-10-21 二次定版:业务明确「有『运输』字样的,往后中转记录不考核」
|
||||
assert.equal(classify({
|
||||
logistic_id: 'L', logistic_status_name: '运输中', logistic_time: '2026-10-20 08:00:00',
|
||||
}), null, '运输中哪怕 25h 没新轨迹也不算异常');
|
||||
assert.equal(classify({
|
||||
logistic_id: 'L', logistic_status_name: '派送中', logistic_time: '2026-10-20 08:00:00',
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('已揽收:没有下一步中转才算异常,轨迹里出现运输/中转字样就算动起来了', () => {
|
||||
const picked = (info) => classify({
|
||||
logistic_id: 'L', logistic_status_name: '已揽收', logistic_time: '2026-10-21 04:00:00',
|
||||
last_info: info,
|
||||
});
|
||||
// 只有「已揽收/已收取快件/库存中」,没有任何中转 → 卡在网点
|
||||
assert.equal(picked('京东快递 已收取快件'), 'PICKED_NO_TRANSIT');
|
||||
assert.equal(picked('中通快运【简尚家居】已揽收'), 'PICKED_NO_TRANSIT');
|
||||
assert.equal(picked(''), 'PICKED_NO_TRANSIT');
|
||||
// 轨迹里已经有运输/中转/发往 → 有下一步中转记录,不考核
|
||||
assert.equal(picked('快件已发往【常州中转部】'), null);
|
||||
assert.equal(picked('运输中,预计明日送达'), null);
|
||||
});
|
||||
|
||||
test('状态为空时按运单号区分:有号=未揽收,无号=无物流单', () => {
|
||||
assert.equal(classify({ logistic_id: 'L1', logistic_status_name: '' }, batch), 'NOT_PICKED');
|
||||
assert.equal(classify({ logistic_id: '', logistic_status_name: '' }, batch), 'NO_TRACKING');
|
||||
});
|
||||
|
||||
// ── ④ 服务层(打桩数据库)──────────────────────────────────────────────
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
|
||||
function stubDb(rows) {
|
||||
const calls = { times: 0, sql: [], params: [] };
|
||||
const handler = async (sql, params) => {
|
||||
calls.times += 1;
|
||||
calls.sql.push(String(sql));
|
||||
calls.params.push(params || {});
|
||||
return rows;
|
||||
};
|
||||
require.cache[dbPath] = {
|
||||
id: dbPath,
|
||||
filename: dbPath,
|
||||
loaded: true,
|
||||
exports: { query: handler, querySlow: handler },
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
const mk = (o) => ({
|
||||
so_id: 'SO', inner_order_id: 1, shop_name: '测试店', warehouse_name: '简尚-崔桥工厂仓',
|
||||
logistic_id: 'L1', logistic_company_name: '中通', logistic_status_name: '已签收',
|
||||
send_time: '2026-10-20 10:00:00', logistic_time: '2026-10-20 18:00:00',
|
||||
query_time: '2026-10-21 09:05:00', last_info: '', ...o,
|
||||
});
|
||||
|
||||
const rows = [
|
||||
// 1:一单两包裹 —— 一个未揽收、一个已签收 → 该单算异常,但只占 1 个「单」
|
||||
mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1A', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
mk({ inner_order_id: 1, so_id: 'SO-1', logistic_id: 'L1B', logistic_status_name: '已签收' }),
|
||||
// 2:山东青岗集仓唯品仓 · 异常
|
||||
mk({ inner_order_id: 2, so_id: 'SO-2', warehouse_name: '简尚-山东青岗集仓唯品仓',
|
||||
logistic_id: 'L2', logistic_status_name: '异常', logistic_time: '2026-10-20 20:00:00',
|
||||
send_time: '2026-10-20 12:00:00' }),
|
||||
// 3:横林二仓品牌仓 · 拒收
|
||||
mk({ inner_order_id: 3, so_id: 'SO-3', warehouse_name: '简尚-横林二仓品牌仓',
|
||||
logistic_id: 'L3', logistic_status_name: '拒收', logistic_time: '2026-10-21 07:00:00',
|
||||
send_time: '2026-10-20 15:00:00' }),
|
||||
// 4:京东云仓 —— 业务排除,不进任何主仓
|
||||
mk({ inner_order_id: 4, so_id: 'SO-4', warehouse_name: '京仓-横林二仓京东云仓',
|
||||
logistic_id: 'L4', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 5:代发仓 —— 不归属任何主仓
|
||||
mk({ inner_order_id: 5, so_id: 'SO-5', warehouse_name: '工厂-山东菏泽森美代发仓',
|
||||
logistic_id: 'L5', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 6:新苏北仓 · 已揽收后没有任何中转记录(最后轨迹 10-20 02:00)→ 已揽收未中转
|
||||
mk({ inner_order_id: 6, so_id: 'SO-6', warehouse_name: '简尚-新苏北仓',
|
||||
logistic_id: 'L6', logistic_status_name: '已揽收', logistic_time: '2026-10-20 02:00:00',
|
||||
send_time: '2026-10-20 01:00:00', last_info: '京东快递 已收取快件' }),
|
||||
// 7:睢宁仓 · 运输中 → 已动起来,不考核
|
||||
mk({ inner_order_id: 7, so_id: 'SO-7', warehouse_name: '简尚-睢宁仓',
|
||||
logistic_id: 'L7', logistic_status_name: '运输中', logistic_time: '2026-10-21 04:00:00',
|
||||
send_time: '2026-10-20 09:00:00' }),
|
||||
// 8:山东青岗集仓 · 无物流单
|
||||
mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓',
|
||||
logistic_id: '', logistic_status_name: '无物流单', logistic_time: null,
|
||||
send_time: '2026-10-20 11:00:00' }),
|
||||
// 9:横林三仓 · 已签收 → 正常
|
||||
mk({ inner_order_id: 9, so_id: 'SO-9', warehouse_name: '简尚-横林三仓' }),
|
||||
// 10:完全重复行(同单同运单号)→ 必须去重
|
||||
mk({ inner_order_id: 8, so_id: 'SO-8', warehouse_name: '简尚-山东青岗集仓',
|
||||
logistic_id: '', logistic_status_name: '无物流单', logistic_time: null,
|
||||
send_time: '2026-10-20 11:00:00' }),
|
||||
];
|
||||
|
||||
async function withStub(fn, stubRows = rows) {
|
||||
const realDb = require.cache[dbPath];
|
||||
const calls = stubDb(stubRows);
|
||||
delete require.cache[svcPath];
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
try {
|
||||
const svc = require(svcPath);
|
||||
svc.clearLogisticsExceptionCache();
|
||||
return await fn(svc, calls);
|
||||
} finally {
|
||||
if (realDb) require.cache[dbPath] = realDb;
|
||||
else delete require.cache[dbPath];
|
||||
delete require.cache[svcPath];
|
||||
delete require.cache[require.resolve('../src/services/warehouseLogisticsExceptionRegistry')];
|
||||
}
|
||||
}
|
||||
|
||||
test('汇总按内部单号去重,明细按包裹保留;排除仓/代发仓不进统计', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
|
||||
// 归属主仓的单:1,2,3,6,7,8,9 = 7 单;包裹 8 个(1 号单两包)
|
||||
assert.equal(data.summary.shippedOrders, 7);
|
||||
assert.equal(data.summary.packageCount, 8, '重复行要去重');
|
||||
// 异常单:1(未揽收) 2(异常) 3(拒收) 6(已揽收未中转) 8(无物流单) = 5
|
||||
assert.equal(data.summary.exceptionOrders, 5);
|
||||
assert.equal(data.summary.exceptionPackages, 5);
|
||||
assert.equal(data.summary.centerCount, 5);
|
||||
assert.equal(data.summary.exceptionRate, 71.4);
|
||||
|
||||
const typeCount = data.summary.byType;
|
||||
assert.equal(typeCount.NOT_PICKED, 1);
|
||||
assert.equal(typeCount.CARRIER_EXCEPTION, 1);
|
||||
assert.equal(typeCount.RETURNED, 1);
|
||||
assert.equal(typeCount.PICKED_NO_TRANSIT, 1);
|
||||
assert.equal(typeCount.NO_TRACKING, 1);
|
||||
|
||||
// 唯品仓独立成仓,不并进青岗集仓
|
||||
const vip = data.centers.find((c) => c.key === 'SD_QGJ_VIP');
|
||||
assert.equal(vip.shippedOrders, 1);
|
||||
assert.equal(vip.exceptionOrders, 1);
|
||||
const qgj = data.centers.find((c) => c.key === 'SD_QGJ');
|
||||
assert.equal(qgj.exceptionOrders, 1, '青岗集仓自己只有 1 单异常(无物流单那单)');
|
||||
|
||||
// 排除仓 / 代发仓不该出现在任何仓的统计里
|
||||
assert.equal(data.centers.reduce((s, c) => s + c.shippedOrders, 0), 7);
|
||||
|
||||
// 明细按「发货→核验」等得最久的排前面(6 号单 10-20 01:00 发货,到 09:00 已 32 小时)
|
||||
assert.equal(data.orders[0].oId, '6', `首行应为最久未动的单,实际 ${data.orders[0].oId}`);
|
||||
assert.equal(data.orders[0].pendingHours, 32);
|
||||
assert.equal(data.orders[0].staleHours, 31, '最后轨迹 10-20 02:00 → 距核验 31 小时');
|
||||
|
||||
// 数据源护栏:只查一张表、带发货日窗口
|
||||
assert.equal(data.freshness.coversDeadline, true);
|
||||
assert.equal(data.freshness.snapshotAt, '2026-10-21 09:05:00');
|
||||
});
|
||||
});
|
||||
|
||||
test('未到 09:00 的批次返回 notStarted,不查库也不给数字', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
|
||||
assert.equal(data.notStarted, true);
|
||||
assert.equal(data.summary.exceptionOrders, 0);
|
||||
assert.equal(calls.times, 0, '未到点不该打库');
|
||||
assert.equal(data.centers.length, 12);
|
||||
});
|
||||
});
|
||||
|
||||
test('结果缓存生效,force=1 绕过缓存重查', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||
const first = await svc.getLogisticsExceptions(opts);
|
||||
assert.equal(first.cached, false);
|
||||
assert.equal(calls.times, 1);
|
||||
|
||||
const second = await svc.getLogisticsExceptions(opts);
|
||||
assert.equal(second.cached, true);
|
||||
assert.equal(calls.times, 1, '第二次应命中缓存');
|
||||
|
||||
const third = await svc.getLogisticsExceptions({ ...opts, force: 1 });
|
||||
assert.equal(third.cached, false);
|
||||
assert.equal(calls.times, 2, 'force=1 必须真查库');
|
||||
});
|
||||
});
|
||||
|
||||
test('明细 limit 生效并给出截断标记', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 2 });
|
||||
assert.equal(data.orders.length, 2);
|
||||
assert.equal(data.truncated, true);
|
||||
assert.equal(data.detailLimit, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('导出:放开明细上限、返回全量异常(不受列表 2000 上限限制)', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptionExportRows({
|
||||
date: '2026-10-21',
|
||||
now: at(21, 10, 0),
|
||||
});
|
||||
assert.equal(data.detailLimit, 5000, '导出的明细上限应放到 5000');
|
||||
assert.equal(data.truncated, false);
|
||||
assert.equal(data.rows.length, data.summary.exceptionPackages, '导出行数应等于异常包裹数');
|
||||
assert.ok(data.batch?.label, '导出要带批次信息给文件名用');
|
||||
assert.ok(Array.isArray(data.exclusions?.carriers), '导出也要带剔除口径');
|
||||
// 明细字段够导出用(页面上那几列)
|
||||
const row = data.rows[0];
|
||||
for (const key of ['warehouseName', 'oId', 'soId', 'carrier', 'trackingNo',
|
||||
'logisticStatus', 'exceptionLabel', 'sendTime', 'pendingHours']) {
|
||||
assert.ok(key in row, `导出缺字段 ${key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('缓存按 limit 分键:页面列表(1000)与导出(5000)互不踢掉', async () => {
|
||||
await withStub(async (svc, calls) => {
|
||||
const opts = { date: '2026-10-21', now: at(21, 10, 0) };
|
||||
await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||
assert.equal(calls.times, 1);
|
||||
|
||||
// 导出用另一个 limit —— 不能把页面那份缓存顶掉
|
||||
await svc.getLogisticsExceptionExportRows(opts);
|
||||
assert.equal(calls.times, 2, '导出是首次查,应真查库');
|
||||
|
||||
const listAgain = await svc.getLogisticsExceptions({ ...opts, limit: 1000 });
|
||||
assert.equal(listAgain.cached, true, '页面那份缓存应还在');
|
||||
assert.equal(calls.times, 2, '列表应命中缓存,不再查库');
|
||||
});
|
||||
});
|
||||
|
||||
test('快照没覆盖核验时刻时给出红色告警文案', async () => {
|
||||
const staleRows = rows.map((r) => ({ ...r, query_time: '2026-10-21 06:10:00' }));
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0) });
|
||||
assert.equal(data.freshness.coversDeadline, false);
|
||||
assert.match(data.freshness.hint, /没覆盖/u);
|
||||
}, staleRows);
|
||||
});
|
||||
|
||||
// ── ⑥ 剔除口径(业务 2026-10-21 指定)───────────────────────────────────
|
||||
test('不参与考核的承运商:顺丰系全剔 + 武进安能/中铁/壹米滴答/自提/线下/商家配送/京喜京东', () => {
|
||||
const hit = (name) => registry.matchExcludedCarrier(name);
|
||||
for (const name of [
|
||||
'唯品顺丰',
|
||||
'武进安能',
|
||||
'武进安能大件',
|
||||
'中铁物流',
|
||||
'壹米滴答专线',
|
||||
'自提',
|
||||
'乐鑫达线下韵达配件',
|
||||
'商家配送',
|
||||
// 顺丰系:业务 2026-10-22「涉及到顺丰的快递全部剔除」,账号一个不漏
|
||||
'顺丰速运',
|
||||
'顺丰标快',
|
||||
'洛宁顺丰干配',
|
||||
'山东顺丰干配',
|
||||
'苏北顺丰干配大件',
|
||||
'苏北顺丰干配小件',
|
||||
'山东顺丰大件',
|
||||
'景瓴十方顺丰大件-莒南',
|
||||
// 京东系只有京喜自营那条线剔(10-22 曾要求全剔,业务随即撤回)
|
||||
'京喜京东快递',
|
||||
'京喜京东快运',
|
||||
]) {
|
||||
assert.ok(hit(name), `${name} 应剔除,实际未命中`);
|
||||
}
|
||||
// 「唯品顺丰」要返回更具体的原因(页脚能看出是唯品那条线),但同样是剔除
|
||||
assert.equal(hit('唯品顺丰'), '唯品顺丰');
|
||||
assert.equal(hit('山东顺丰大件'), '顺丰');
|
||||
assert.equal(hit('京喜京东快递'), '京喜京东');
|
||||
// 🔥 别把「京喜京东」放大成「京东」:这些京东系账号要正常考核
|
||||
assert.equal(hit('京东快递'), null);
|
||||
assert.equal(hit('京东自营快递'), null);
|
||||
assert.equal(hit('京东自营快递亿家达'), null);
|
||||
assert.equal(hit('京东自营快递朴美'), null);
|
||||
assert.equal(hit('崔桥京东特惠'), null);
|
||||
assert.equal(hit('曹县京东快运'), null);
|
||||
// 非顺丰/非京东的常规承运商不能误剔
|
||||
assert.equal(hit('常州中通快运'), null);
|
||||
assert.equal(hit('常州德邦'), null);
|
||||
assert.equal(hit('百世快运'), null);
|
||||
assert.equal(hit('睢宁德邦'), null);
|
||||
assert.equal(hit('曹县德邦小件'), null);
|
||||
});
|
||||
|
||||
test('🔥 睢宁中通是条件剔除:睢宁仓照常考核,其它仓异地打单的剔', () => {
|
||||
const hit = (name, centerKey) => registry.matchExcludedCarrier(name, centerKey);
|
||||
// 睢宁仓自己用 → 正常
|
||||
assert.equal(hit('睢宁中通', 'SN'), null);
|
||||
// 异地打单(实测山东品牌仓 137 单未揽收、河南洛宁 20 单、崔桥工厂 6 单)→ 剔
|
||||
assert.ok(hit('睢宁中通', 'SD_BRAND'));
|
||||
assert.ok(hit('睢宁中通', 'HN_LN'));
|
||||
assert.ok(hit('睢宁中通', 'CQ'));
|
||||
assert.ok(hit('睢宁中通', 'HL2'));
|
||||
// 没给仓(保守)→ 不剔,宁可少剔也不能误剔睢宁仓
|
||||
assert.equal(hit('睢宁中通'), null);
|
||||
// 别把「常州中通快运」等其它中通账号连带剔掉
|
||||
assert.equal(hit('常州中通快运', 'SD_BRAND'), null);
|
||||
});
|
||||
|
||||
test('🔥 「武进安能」不能泛化成「安能」:常州安能新要正常考核', () => {
|
||||
// 12 仓实测「常州安能新」3383 单/30 天,泛化「安能」会把整片正常单误剔
|
||||
assert.equal(registry.matchExcludedCarrier('常州安能新'), null);
|
||||
assert.equal(registry.matchExcludedCarrier('雅柏安能'), null);
|
||||
assert.equal(registry.matchExcludedCarrier('常州中通快运'), null);
|
||||
assert.equal(registry.matchExcludedCarrier(''), null);
|
||||
assert.equal(registry.matchExcludedCarrier(null), null);
|
||||
});
|
||||
|
||||
test('已取消 / 作废 / 拦截的订单剔除', () => {
|
||||
const hit = (status, labels) => registry.matchVoidedOrder(status, labels);
|
||||
assert.ok(hit('已取消', ''), '订单状态已取消要剔');
|
||||
assert.ok(hit('已作废', ''));
|
||||
assert.ok(hit('已发货', '["发货后取消","发货后售后"]'), '发货后取消要剔');
|
||||
assert.ok(hit('已发货', '["留言备注拦截","禁止拆分"]'), '留言备注拦截要剔');
|
||||
assert.ok(hit('已发货', '["作废"]'));
|
||||
// 正常单不能被误剔
|
||||
assert.equal(hit('已发货', '["店铺-拼多多","已成团"]'), null);
|
||||
assert.equal(hit('已发货', ''), null);
|
||||
// 「取消预售」是营销标签,不是取消,不能剔
|
||||
assert.equal(hit('已发货', '["取消预售"]'), null);
|
||||
});
|
||||
|
||||
test('剔除的单既不算异常,也不进发货分母', async () => {
|
||||
const mixed = [
|
||||
// 10:唯品顺丰 · 未揽收 → 本来会误报,必须整单消失
|
||||
mk({ inner_order_id: 10, so_id: 'SO-10', logistic_company_name: '唯品顺丰',
|
||||
logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 11:中铁物流 · 异常
|
||||
mk({ inner_order_id: 11, so_id: 'SO-11', logistic_company_name: '中铁物流',
|
||||
logistic_status_name: '异常' }),
|
||||
// 12:武进安能 · 已签收 → 连带分母也要消失
|
||||
mk({ inner_order_id: 12, so_id: 'SO-12', logistic_company_name: '武进安能' }),
|
||||
// 13:常州安能新 · 未揽收 → 必须留下(反例)
|
||||
mk({ inner_order_id: 13, so_id: 'SO-13', logistic_company_name: '常州安能新',
|
||||
logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 14:正常承运商但订单已取消 → 剔
|
||||
mk({ inner_order_id: 14, so_id: 'SO-14', logistic_company_name: '常州中通快运',
|
||||
logistic_status_name: '无物流单', logistic_time: null,
|
||||
order_status_name: '已取消' }),
|
||||
// 15:正常承运商但发货后取消 → 剔
|
||||
mk({ inner_order_id: 15, so_id: 'SO-15', logistic_company_name: '常州中通快运',
|
||||
logistic_status_name: '未揽收', logistic_time: null,
|
||||
order_labels: '["发货后取消","发货后售后"]' }),
|
||||
// 16:正常单留作对照
|
||||
mk({ inner_order_id: 16, so_id: 'SO-16', logistic_company_name: '常州中通快运' }),
|
||||
// 17:京喜京东快递 · 已揽收未中转 → 京喜自营轨迹不回传,剔
|
||||
mk({ inner_order_id: 17, so_id: 'SO-17', warehouse_name: '简尚-山东青岗集仓',
|
||||
logistic_company_name: '京喜京东快递', logistic_status_name: '已揽收', logistic_time: '2026-10-20 20:00:00',
|
||||
last_info: '快件已从【菏泽水岸城邦站】揽收完成' }),
|
||||
// 18:睢宁中通在**非睢宁仓**(山东品牌仓)未揽收 → 异地打单,剔
|
||||
mk({ inner_order_id: 18, so_id: 'SO-18', warehouse_name: '简尚-山东品牌仓',
|
||||
logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
// 19:睢宁中通在**睢宁仓**未揽收 → 本仓本账号,照常考核(反例,必须留)
|
||||
mk({ inner_order_id: 19, so_id: 'SO-19', warehouse_name: '简尚-睢宁仓',
|
||||
logistic_company_name: '睢宁中通', logistic_status_name: '未揽收', logistic_time: null }),
|
||||
];
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
|
||||
// 只剩 13、16、19 三单(10/11/12 承运商剔除,14/15 作废剔除,17 京喜、18 异地睢宁中通剔除)
|
||||
assert.equal(data.summary.shippedOrders, 3, `分母应只剩 3 单,实际 ${data.summary.shippedOrders}`);
|
||||
assert.equal(data.summary.exceptionOrders, 2, '只剩 13、19 两单异常');
|
||||
assert.equal(data.summary.exceptionRate, 66.7);
|
||||
assert.deepEqual(data.orders.map((o) => o.oId).sort(), ['13', '19']);
|
||||
|
||||
// 剔除量要如实返回,页面能展示
|
||||
assert.equal(data.exclusions.carrierOrders, 5, '唯品顺丰/中铁/武进安能/京喜京东/异地睢宁中通 5 单');
|
||||
assert.equal(data.exclusions.voidedOrders, 2, '已取消 + 发货后取消 2 单');
|
||||
assert.equal(data.exclusions.carrierPackages, 5);
|
||||
assert.equal(data.exclusions.voidedPackages, 2);
|
||||
assert.ok(data.exclusions.carrierReasons['睢宁中通(非睢宁仓异地揽收)']);
|
||||
assert.ok(data.exclusions.voidedReasons['订单已取消']);
|
||||
assert.ok(data.exclusions.voidedReasons['标签含「发货后取消」']);
|
||||
}, mixed);
|
||||
});
|
||||
|
||||
test('刷单 / 信誉单剔除(按订单标签,不按「特殊单」)', () => {
|
||||
assert.equal(registry.matchNonRealOrder('["刷单待处理","特殊单","店铺-拼多多"]'), '刷单');
|
||||
assert.equal(registry.matchNonRealOrder('["特殊单","禁止拆分","转空包发货"]'), '信誉单(转空包)');
|
||||
// 反例:「特殊单」本身混着补差价/配件单,不能当判据
|
||||
assert.equal(registry.matchNonRealOrder('["特殊单","补差价"]'), null);
|
||||
assert.equal(registry.matchNonRealOrder('["店铺-京东","晚发赔"]'), null);
|
||||
assert.equal(registry.matchNonRealOrder(''), null);
|
||||
assert.equal(registry.matchNonRealOrder(null), null);
|
||||
});
|
||||
|
||||
test('刷单的包裹不进分母也不算异常', async () => {
|
||||
const mixed = [
|
||||
// 20:刷单 · 未揽收 → 假异常,必须消失
|
||||
mk({ inner_order_id: 20, so_id: 'SO-20', logistic_status_name: '未揽收', logistic_time: null,
|
||||
order_labels: '["刷单待处理","特殊单","店铺-拼多多"]' }),
|
||||
// 21:信誉单(转空包)· 无物流单 → 消失
|
||||
mk({ inner_order_id: 21, so_id: 'SO-21', logistic_status_name: '无物流单', logistic_time: null,
|
||||
order_labels: '["特殊单","禁止拆分","转空包发货"]' }),
|
||||
// 22:带「特殊单」但不是刷单/信誉单 → 必须留下
|
||||
mk({ inner_order_id: 22, so_id: 'SO-22', logistic_status_name: '未揽收', logistic_time: null,
|
||||
order_labels: '["特殊单","补差价"]' }),
|
||||
];
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 10, 0), limit: 100 });
|
||||
assert.equal(data.summary.shippedOrders, 1, '只剩 22 号单');
|
||||
assert.deepEqual(data.orders.map((o) => o.oId), ['22']);
|
||||
assert.equal(data.exclusions.nonRealOrders, 2);
|
||||
assert.equal(data.exclusions.nonRealReasons['刷单'], 1);
|
||||
assert.equal(data.exclusions.nonRealReasons['信誉单(转空包)'], 1);
|
||||
assert.deepEqual(data.exclusions.orderTypeLabels, ['刷单', '信誉单']);
|
||||
}, mixed);
|
||||
});
|
||||
|
||||
test('未到 09:00 也返回剔除口径,前端不用判空', async () => {
|
||||
await withStub(async (svc) => {
|
||||
const data = await svc.getLogisticsExceptions({ date: '2026-10-21', now: at(21, 8, 0) });
|
||||
assert.equal(data.notStarted, true);
|
||||
assert.ok(Array.isArray(data.exclusions.carriers));
|
||||
assert.ok(data.exclusions.carriers.includes('唯品顺丰'));
|
||||
assert.equal(data.exclusions.carrierOrders, 0);
|
||||
});
|
||||
});
|
||||
@ -1,497 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 「仓储中心物流转运异常」每天推文本到钉钉群 —— 正文组装与通道回归测试
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 这里只测四件容易悄悄坏掉的事:
|
||||
* ① 群消息正文的数字与明细(业务照着这条消息干活,数字错了没人能发现);
|
||||
* ② 0 条 / 明细条数上限 / 超长精简(钉钉 text 上限 20000 字节,超了整条被拒);
|
||||
* ③ webhook 地址与加签(sign 算错就是 310000,肉眼看不出来);
|
||||
* ④ 钉钉报错必须原样透出 errcode/errmsg(业务排错全靠这个码)。
|
||||
* 不测数据库与真实网络:settings / job 的 DB 分支在接口层验证。
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const job = require('../src/jobs/warehouseLogisticsExceptionNotifyJob');
|
||||
const sender = require('../src/services/dingtalkRobotWebhookSender');
|
||||
const settings = require('../src/services/warehouseLogisticsExceptionNotifySettings');
|
||||
|
||||
function sampleData(overrides = {}) {
|
||||
return {
|
||||
batch: {
|
||||
label: '10-22 09:00 核验 · 10-21 发货',
|
||||
verifyDate: '2026-10-22',
|
||||
shipDate: '2026-10-21',
|
||||
deadline: '2026-10-22 09:00:00',
|
||||
},
|
||||
summary: {
|
||||
shippedOrders: 9886,
|
||||
packageCount: 9886,
|
||||
exceptionOrders: 143,
|
||||
exceptionPackages: 144,
|
||||
exceptionRate: 1.4,
|
||||
centerCount: 9,
|
||||
byType: { PICKED_NO_TRANSIT: 137, NOT_PICKED: 7 },
|
||||
},
|
||||
centers: [
|
||||
{ key: 'sn', name: '睢宁仓', shippedOrders: 1200, exceptionOrders: 1, exceptionRate: 0.1 },
|
||||
{ key: 'cq', name: '崔桥工厂仓', shippedOrders: 3000, exceptionOrders: 2, exceptionRate: 0.1 },
|
||||
{ key: 'hl', name: '横林二仓', shippedOrders: 800, exceptionOrders: 0, exceptionRate: 0 },
|
||||
],
|
||||
types: [
|
||||
{ key: 'PICKED_NO_TRANSIT', label: '已揽收未中转', desc: '揽收后没有下一步中转记录' },
|
||||
{ key: 'NOT_PICKED', label: '未揽收', desc: '快递还没来揽' },
|
||||
],
|
||||
exclusions: {
|
||||
carriers: ['唯品顺丰', '顺丰', '京喜京东'],
|
||||
conditionalCarriers: [{ keyword: '睢宁中通', keepCenters: ['睢宁仓'] }],
|
||||
orderStatusKeywords: ['取消', '作废'],
|
||||
orderLabelKeywords: ['发货后取消'],
|
||||
orderTypeLabels: ['刷单', '转空包'],
|
||||
carrierPackages: 12,
|
||||
voidedPackages: 3,
|
||||
nonRealPackages: 5,
|
||||
},
|
||||
rows: [
|
||||
{
|
||||
centerKey: 'cq',
|
||||
warehouseName: '崔桥工厂仓',
|
||||
oId: '9845001',
|
||||
soId: '3318',
|
||||
shopName: '京东自营',
|
||||
carrier: '京东快递',
|
||||
trackingNo: 'JD1',
|
||||
logisticStatus: '已揽收',
|
||||
exceptionLabel: '已揽收未中转',
|
||||
exceptionDesc: '揽收后没有下一步中转记录',
|
||||
sendTime: '2026-10-21 16:00:00',
|
||||
lastLogisticTime: '2026-10-21 21:00:00',
|
||||
pendingHours: 17,
|
||||
lastLogisticInfo: '已揽收',
|
||||
},
|
||||
{
|
||||
centerKey: 'sn',
|
||||
warehouseName: '睢宁仓',
|
||||
oId: '9844248',
|
||||
soId: '3316',
|
||||
shopName: '天猫旗舰店',
|
||||
carrier: '睢宁德邦',
|
||||
trackingNo: '',
|
||||
logisticStatus: '',
|
||||
exceptionLabel: '未揽收',
|
||||
exceptionDesc: '快递还没来揽',
|
||||
sendTime: '2026-10-21 14:22:10',
|
||||
lastLogisticTime: '',
|
||||
pendingHours: null,
|
||||
lastLogisticInfo: '',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('群消息正文:抬头带「异常」关键词、数字与分类型、按仓只列有异常的仓', () => {
|
||||
const built = job.buildNotifyText(sampleData(), { maxDetailRows: 30, pageUrl: 'https://ck.yydd.ai:8443/p' });
|
||||
const text = built.text;
|
||||
|
||||
// 钉钉「自定义关键词」安全设置一般配「异常」,抬头必须含这两个字
|
||||
assert.match(text, /【仓储物流转运异常】/);
|
||||
assert.match(text, /10-22 09:00 核验 · 10-21 发货/);
|
||||
assert.match(text, /已发货 9886 单 \/ 9886 个包裹,异常 144 个包裹(1\.4%)/);
|
||||
// 分类型按数量降序,且必须是可读标签(不是 PICKED_NO_TRANSIT 这种 key)
|
||||
assert.match(text, /类型:已揽收未中转 137 · 未揽收 7/);
|
||||
// 剔除数量 = 12 + 3 + 5
|
||||
assert.match(text, /已剔除 20 个包裹/);
|
||||
// 按仓只列有异常的:睢宁 1、崔桥 1;横林二仓 0 条不能出现
|
||||
assert.match(text, /按仓(异常包裹):/);
|
||||
assert.match(text, /睢宁仓 1 个(发货 1200 单)/);
|
||||
assert.match(text, /崔桥工厂仓 1 个(发货 3000 单)/);
|
||||
assert.ok(!text.includes('横林二仓'), '没有异常的仓不该占行');
|
||||
// 页码链接带核验日
|
||||
assert.match(text, /查看全部 \/ 导出 Excel:https:\/\/ck\.yydd\.ai:8443\/p\?date=2026-10-22/);
|
||||
|
||||
assert.equal(built.rowCount, 144);
|
||||
assert.equal(built.listedCount, 2);
|
||||
assert.equal(built.truncated, false);
|
||||
});
|
||||
|
||||
test('明细行:仓|快递单号|快递|异常类型(4 段,不带发货时间和待处理时长),字段里换行要压平', () => {
|
||||
const line = job.buildDetailLine({
|
||||
warehouseName: '崔桥工厂仓',
|
||||
oId: '9845001',
|
||||
trackingNo: 'JDVA00123456789',
|
||||
carrier: '京东快递',
|
||||
exceptionLabel: '已揽收未中转',
|
||||
sendTime: '2026-10-21 16:00:00',
|
||||
pendingHours: 17.25,
|
||||
lastLogisticInfo: '第一行\n第二行',
|
||||
}, 3);
|
||||
|
||||
assert.equal(line, '3. 崔桥工厂仓|JDVA00123456789|京东快递|已揽收未中转');
|
||||
// 业务要求:第二段是快递单号,不是内部单号
|
||||
assert.ok(!line.includes('9845001'), '明细行不该再显示内部单号');
|
||||
// 业务要求:明细不带发货时间(哪怕传了 sendTime 也不能出现在消息里)
|
||||
assert.ok(!line.includes('10-21'), '明细行不该再带发货时间');
|
||||
assert.ok(!line.includes('16:00'), '明细行不该再带发货时间');
|
||||
// 业务要求:末尾的待处理小时数也去掉(哪怕传了 pendingHours 也不能出现在消息里)
|
||||
assert.ok(!line.includes('17.3h'), '明细行不该再带待处理时长');
|
||||
assert.equal(line.split('|').length, 4, '明细必须正好 4 段');
|
||||
|
||||
// 物流描述里的换行不能漏进消息(会把一条明细劈成两行,看着像两条异常)
|
||||
assert.equal(job.cleanCell('甲\n乙\t丙 丁'), '甲 乙 丙 丁');
|
||||
// 未揽收/无物流单没有快递单号 → 退回内部单号(否则群里这条无法定位)
|
||||
assert.equal(
|
||||
job.buildDetailLine({ oId: '9845002', carrier: '中通' }, 1),
|
||||
'1. 未知仓|9845002|中通|异常'
|
||||
);
|
||||
// 空值兜底:不能让消息里出现「undefined|null」
|
||||
assert.equal(
|
||||
job.buildDetailLine({}, 1),
|
||||
'1. 未知仓|无单号|未知快递|异常'
|
||||
);
|
||||
});
|
||||
|
||||
test('明细按仓聚集:同仓相邻、组间按异常数降序、组内保持原顺序(时长降序)', () => {
|
||||
const rows = [
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'SN1', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ1', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'SN2', carrier: '睢宁德邦', exceptionLabel: '未揽收' },
|
||||
{ warehouseName: '山东青岗集仓', trackingNo: 'SD1', carrier: '山东中通', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ2', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
{ warehouseName: '崔桥工厂仓', trackingNo: 'CQ3', carrier: '京东快递', exceptionLabel: '已揽收未中转' },
|
||||
];
|
||||
|
||||
const grouped = job.groupRowsByWarehouse(rows);
|
||||
// 崔桥 3 条 > 睢宁 2 条 > 青岗集 1 条,组间按异常数降序
|
||||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['CQ1', 'CQ2', 'CQ3', 'SN1', 'SN2', 'SD1']);
|
||||
// 不丢行、不改行
|
||||
assert.equal(grouped.length, rows.length);
|
||||
|
||||
const text = job.buildNotifyText(sampleData({ rows }), { maxDetailRows: 30 }).text;
|
||||
// 只取「明细前 N 条」块里的行(上面「按仓」块也是 `1. xxx` 格式,别混进来)
|
||||
const detailBlock = text.split('明细前')[1] || '';
|
||||
const detailLines = detailBlock.split('\n').filter((line) => /^\d+\. /.test(line));
|
||||
const warehouses = detailLines.map((line) => line.split('|')[0].replace(/^\d+\. /, ''));
|
||||
|
||||
// 同仓必须相邻:切成连续段后不应出现重复仓名
|
||||
const runs = warehouses.filter((name, i) => name !== warehouses[i - 1]);
|
||||
assert.equal(new Set(runs).size, runs.length, `同仓没有排在一起:${warehouses.join(' / ')}`);
|
||||
assert.deepEqual(warehouses, ['崔桥工厂仓', '崔桥工厂仓', '崔桥工厂仓', '睢宁仓', '睢宁仓', '山东青岗集仓']);
|
||||
// 编号仍然全局连续,不能每组从 1 开始(否则群里看到重复序号)
|
||||
assert.deepEqual(detailLines.map((line) => line.match(/^(\d+)\./)[1]), ['1', '2', '3', '4', '5', '6']);
|
||||
});
|
||||
|
||||
test('明细按仓聚集:仓名缺失的行归到「未知仓」一组,不会被拆散', () => {
|
||||
const rows = [
|
||||
{ warehouseName: '', trackingNo: 'A1' },
|
||||
{ warehouseName: '睢宁仓', trackingNo: 'B1' },
|
||||
{ trackingNo: 'A2' },
|
||||
];
|
||||
const grouped = job.groupRowsByWarehouse(rows);
|
||||
// 未知仓 2 条(含 undefined)> 睢宁 1 条
|
||||
assert.deepEqual(grouped.map((r) => r.trackingNo), ['A1', 'A2', 'B1']);
|
||||
});
|
||||
|
||||
test('明细额度按仓分配:大仓不能吃满,每仓至少 1 条', () => {
|
||||
// 真数据就是这样:青岗集一家占了大头(10-22 实测 140 条里前 40 条全是它)
|
||||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||||
warehouseName: name,
|
||||
trackingNo: `${name}-${i}`,
|
||||
}));
|
||||
const rows = [
|
||||
...make('青岗集仓', 100),
|
||||
...make('崔桥工厂仓', 20),
|
||||
...make('睢宁仓', 8),
|
||||
...make('新苏北仓', 6),
|
||||
...make('横林二仓', 6),
|
||||
];
|
||||
|
||||
const picked = job.pickDetailRows(rows, 40);
|
||||
assert.equal(picked.length, 40, '额度必须正好用满(140 条 > 40)');
|
||||
|
||||
const counts = picked.reduce((acc, row) => {
|
||||
acc[row.warehouseName] = (acc[row.warehouseName] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
// 每个仓都要露面,否则群里那个仓的人看不到自己的单
|
||||
for (const name of ['青岗集仓', '崔桥工厂仓', '睢宁仓', '新苏北仓', '横林二仓']) {
|
||||
assert.ok(counts[name] >= 1, `${name} 一条明细都没分到`);
|
||||
}
|
||||
// 大仓仍然拿得多,但不能吃满额度
|
||||
assert.ok(counts['青岗集仓'] < 40, '大仓不该独占全部额度');
|
||||
assert.ok(counts['青岗集仓'] > counts['崔桥工厂仓'], '异常多的仓应分到更多明细');
|
||||
|
||||
// 同仓仍然相邻
|
||||
const names = picked.map((row) => row.warehouseName);
|
||||
const runs = names.filter((name, i) => name !== names[i - 1]);
|
||||
assert.equal(runs.length, new Set(runs).size, `同仓被拆散:${names.join(',')}`);
|
||||
});
|
||||
|
||||
test('明细额度比仓数还少时:按异常数从多到少,每仓只取 1 条', () => {
|
||||
const make = (name, count) => Array.from({ length: count }, (_v, i) => ({
|
||||
warehouseName: name,
|
||||
trackingNo: `${name}-${i}`,
|
||||
}));
|
||||
const rows = [...make('大仓', 10), ...make('中仓', 5), ...make('小仓', 2)];
|
||||
const picked = job.pickDetailRows(rows, 2);
|
||||
assert.deepEqual(picked.map((row) => row.trackingNo), ['大仓-0', '中仓-0']);
|
||||
// 额度 0 / 超额度的边界
|
||||
assert.deepEqual(job.pickDetailRows(rows, 0), []);
|
||||
assert.equal(job.pickDetailRows(rows, 99).length, 17);
|
||||
});
|
||||
|
||||
test('0 条异常也要发一条平安消息(用来确认任务没挂)', () => {
|
||||
const data = sampleData({
|
||||
rows: [],
|
||||
summary: {
|
||||
...sampleData().summary,
|
||||
exceptionOrders: 0,
|
||||
exceptionPackages: 0,
|
||||
exceptionRate: 0,
|
||||
byType: {},
|
||||
centerCount: 0,
|
||||
},
|
||||
exclusions: {},
|
||||
});
|
||||
const built = job.buildNotifyText(data, { maxDetailRows: 30 });
|
||||
assert.match(built.text, /本批无转运异常,全部正常/);
|
||||
assert.match(built.text, /异常 0 个包裹(/);
|
||||
assert.ok(!built.text.includes('明细前'), '0 条时不该出现明细标题');
|
||||
assert.equal(built.rowCount, 0);
|
||||
assert.equal(built.listedCount, 0);
|
||||
});
|
||||
|
||||
test('明细条数受配置控制:0 = 只发汇总,超量只列前 N 条并提示剩余', () => {
|
||||
const zero = job.buildNotifyText(sampleData(), { maxDetailRows: 0 });
|
||||
assert.ok(!/明细前 \d+ 条/.test(zero.text), 'maxDetailRows=0 时不要明细块');
|
||||
assert.match(zero.text, /明细共 144 条,消息里不放,请点下面链接查看/);
|
||||
|
||||
const one = job.buildNotifyText(sampleData(), { maxDetailRows: 1 });
|
||||
assert.equal(one.listedCount, 1);
|
||||
assert.ok(one.text.includes('1. 崔桥工厂仓'), '只列第一条');
|
||||
assert.ok(!one.text.includes('2. 睢宁仓'), '第二条应被截掉');
|
||||
assert.match(one.text, /其余 143 条见页面/);
|
||||
});
|
||||
|
||||
test('明细太多时按字节精简:不超 20000 字节上限,且不切断半个汉字', () => {
|
||||
// 造 60 条很长的明细(仓名 144 字 ≈ 432 字节/行),必然超过 text 上限
|
||||
const longName = '崔桥工厂半成品包装项目仓'.repeat(12);
|
||||
const rows = Array.from({ length: 60 }, (_item, index) => ({
|
||||
centerKey: 'cq',
|
||||
warehouseName: longName,
|
||||
oId: `9845${String(index).padStart(3, '0')}`,
|
||||
carrier: '中通快递',
|
||||
exceptionLabel: '已揽收未中转',
|
||||
sendTime: '2026-10-21 16:00:00',
|
||||
pendingHours: 17,
|
||||
}));
|
||||
const data = sampleData({
|
||||
rows,
|
||||
summary: { ...sampleData().summary, exceptionPackages: rows.length, exceptionOrders: rows.length },
|
||||
});
|
||||
|
||||
const built = job.buildNotifyText(data, { maxDetailRows: 50 });
|
||||
assert.ok(built.bytes <= sender.MAX_CONTENT_BYTES, `实得 ${built.bytes} 字节,超了上限`);
|
||||
assert.ok(built.bytes <= job.SAFE_CONTENT_BYTES, '应留出安全余量');
|
||||
assert.equal(built.truncated, true, '应标记为已精简');
|
||||
assert.ok(built.listedCount < 50, '应自动少列几条');
|
||||
assert.ok(!built.text.includes('\uFFFD'), '不能出现被截断产生的乱码');
|
||||
// 削掉明细后仍保留抬头和链接
|
||||
assert.match(built.text, /【仓储物流转运异常】/);
|
||||
assert.match(built.text, /查看全部/);
|
||||
});
|
||||
|
||||
test('按字节截断工具:中文按 3 字节算,不切半个字', () => {
|
||||
assert.deepEqual(sender.truncateBytes('abc', 10), { text: 'abc', truncated: false });
|
||||
// 「中」= 3 字节:10 字节最多放 3 个字(9 字节)
|
||||
const cut = sender.truncateBytes('中文字符', 10);
|
||||
assert.equal(cut.text, '中文字');
|
||||
assert.equal(cut.truncated, true);
|
||||
assert.ok(Buffer.byteLength(cut.text, 'utf8') <= 10);
|
||||
});
|
||||
|
||||
test('时区:容器是 UTC,推送时刻仍按北京时间判(09:10 不能变成 01:10)', () => {
|
||||
// 2026-10-22T01:10Z = 北京时间 09:10
|
||||
assert.equal(job.chinaHourMinute(new Date('2026-10-22T01:10:00Z')), '09:10');
|
||||
assert.equal(job.chinaDateKey(new Date('2026-10-22T01:10:00Z')), '2026-10-22');
|
||||
// 北京时间 00:30(UTC 前一天 16:30)→ 日期必须是新的一天
|
||||
assert.equal(job.chinaDateKey(new Date('2026-10-21T16:30:00Z')), '2026-10-22');
|
||||
assert.equal(job.chinaHourMinute(new Date('2026-10-21T16:30:00Z')), '00:30');
|
||||
});
|
||||
|
||||
test('推送时间 / 明细条数:非法输入一律退回默认值,不能把定时任务配挂', () => {
|
||||
assert.equal(settings.normalizeSendTime('08:05'), '08:05');
|
||||
assert.equal(settings.normalizeSendTime('9:10'), '09:10');
|
||||
assert.equal(settings.normalizeSendTime('25:00'), '09:10');
|
||||
assert.equal(settings.normalizeSendTime(''), '09:10');
|
||||
assert.equal(settings.normalizeSendTime(undefined), '09:10');
|
||||
|
||||
assert.equal(settings.normalizeMaxDetailRows(undefined), 30);
|
||||
assert.equal(settings.normalizeMaxDetailRows(-5), 0);
|
||||
assert.equal(settings.normalizeMaxDetailRows(999), 50);
|
||||
assert.equal(settings.normalizeMaxDetailRows('12'), 12);
|
||||
assert.equal(settings.normalizeMaxDetailRows('abc'), 30);
|
||||
});
|
||||
|
||||
test('配置校验:只要有 Webhook 就算配好;加签密钥只回掩码', () => {
|
||||
assert.deepEqual(settings.missingFields({ webhookUrl: '' }), ['Webhook 地址']);
|
||||
assert.deepEqual(settings.missingFields({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }), []);
|
||||
assert.equal(settings.isWarehouseLogisticsExceptionNotifyReady({}), false);
|
||||
assert.equal(
|
||||
settings.isWarehouseLogisticsExceptionNotifyReady({ webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t' }),
|
||||
true,
|
||||
'安全设置用「关键词」时不需要加签密钥'
|
||||
);
|
||||
|
||||
const admin = settings.toAdminConfig({
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=t',
|
||||
webhookSecret: 'SEC0123456789abcdef',
|
||||
enabled: true,
|
||||
});
|
||||
assert.equal(admin.webhookSecretConfigured, true);
|
||||
assert.ok(!JSON.stringify(admin).includes('SEC0123456789abcdef'), '明文加签密钥不能出现在返回体里');
|
||||
assert.match(admin.webhookSecretPreview, /\*+/);
|
||||
// Webhook 要完整回显:业务需要核对 access_token
|
||||
assert.match(admin.webhookUrl, /access_token=t/);
|
||||
});
|
||||
|
||||
test('Webhook 地址校验:必须是 robot/send 且带 access_token', () => {
|
||||
const ok = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
|
||||
assert.equal(sender.normalizeWebhookUrl(ok), ok);
|
||||
|
||||
assert.throws(() => sender.normalizeWebhookUrl(''), /请填写/);
|
||||
assert.throws(() => sender.normalizeWebhookUrl('oapi.dingtalk.com/robot/send'), /不是合法 URL/);
|
||||
// 常见的复制错误:贴成了企业内部应用的地址 / 少复制了路径
|
||||
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/robot/send'), /缺少 access_token/);
|
||||
assert.throws(() => sender.normalizeWebhookUrl('https://oapi.dingtalk.com/topapi/robot/send?access_token=a'), /不像群机器人/);
|
||||
});
|
||||
|
||||
test('加签:sign = urlEncode(base64(HmacSHA256(secret, `${ts}\\n${secret}`)))', () => {
|
||||
const webhook = 'https://oapi.dingtalk.com/robot/send?access_token=abc123';
|
||||
const secret = 'SECtestsecret';
|
||||
const ts = 1700000000000;
|
||||
|
||||
const signed = new URL(sender.buildSignedUrl(webhook, secret, ts));
|
||||
assert.equal(signed.searchParams.get('timestamp'), String(ts));
|
||||
assert.equal(signed.searchParams.get('access_token'), 'abc123', '原参数要保留');
|
||||
|
||||
// 手工算一遍(顺序写错就是 310000,这条断言就是防这个)
|
||||
const expected = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(`${ts}\n${secret}`, 'utf8')
|
||||
.digest('base64');
|
||||
// searchParams 会自动 decode,所以这里拿到的是原始 base64(含 + / =)
|
||||
assert.equal(signed.searchParams.get('sign'), expected);
|
||||
|
||||
// 没配密钥(安全设置用关键词/IP):只追加 timestamp,不带 sign
|
||||
const plain = new URL(sender.buildSignedUrl(webhook, '', ts));
|
||||
assert.equal(plain.searchParams.get('sign'), null);
|
||||
assert.equal(plain.searchParams.get('timestamp'), String(ts));
|
||||
});
|
||||
|
||||
// ── 钉钉通道:stub fetch 验请求形状与错误透传 ────────────────────────────────
|
||||
|
||||
function stubFetch(handler) {
|
||||
const calls = [];
|
||||
const original = global.fetch;
|
||||
global.fetch = async (url, options = {}) => {
|
||||
calls.push({ url: String(url), options });
|
||||
const body = handler.body === undefined ? {} : handler.body;
|
||||
return new Response(JSON.stringify(body), { status: handler.status || 200 });
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
restore: () => { global.fetch = original; },
|
||||
};
|
||||
}
|
||||
|
||||
test('发文本:msgtype=text,加签参数拼在 URL 上,@所有人开关透传', async () => {
|
||||
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
|
||||
try {
|
||||
const result = await sender.sendTextToWebhook({
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=abc123',
|
||||
secret: 'SECtestsecret',
|
||||
content: '【仓储物流转运异常】测试',
|
||||
atAll: true,
|
||||
});
|
||||
|
||||
assert.equal(result.delivered, true);
|
||||
assert.equal(result.errmsg, 'ok');
|
||||
|
||||
const call = stub.calls[0];
|
||||
assert.equal(call.options.method, 'POST');
|
||||
assert.match(call.options.headers['Content-Type'], /application\/json/);
|
||||
// 加签后 URL 必须带 timestamp + sign
|
||||
const url = new URL(call.url);
|
||||
assert.ok(url.searchParams.get('timestamp'), '缺少 timestamp');
|
||||
assert.ok(url.searchParams.get('sign'), '缺少 sign');
|
||||
|
||||
const body = JSON.parse(call.options.body);
|
||||
assert.equal(body.msgtype, 'text');
|
||||
assert.equal(body.text.content, '【仓储物流转运异常】测试');
|
||||
assert.equal(body.at.isAtAll, true);
|
||||
assert.deepEqual(body.at.atMobiles, []);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('发文本:空内容不发请求(避免钉钉 400 把错误码埋掉)', async () => {
|
||||
let touched = 0;
|
||||
const original = global.fetch;
|
||||
global.fetch = async () => { touched += 1; throw new Error('不应该发请求'); };
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => sender.sendTextToWebhook({
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||||
content: ' ',
|
||||
}),
|
||||
/内容为空/
|
||||
);
|
||||
assert.equal(touched, 0);
|
||||
} finally {
|
||||
global.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
test('发文本:钉钉 errcode 原样透出(310000 关键词/加签不匹配是最常见的坑)', async () => {
|
||||
const stub = stubFetch({ body: { errcode: 310000, errmsg: 'sign not match' } });
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => sender.sendTextToWebhook({
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||||
secret: 'SECwrong',
|
||||
content: '【仓储物流转运异常】测试',
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.code, 310000);
|
||||
assert.match(error.message, /310000/);
|
||||
assert.match(error.message, /sign not match/);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('发文本:超长正文自动截断后仍会发出去(不是直接抛错)', async () => {
|
||||
const stub = stubFetch({ body: { errcode: 0, errmsg: 'ok' } });
|
||||
try {
|
||||
const huge = '异常'.repeat(12000); // 24000 字 ≈ 72000 字节,远超上限
|
||||
const result = await sender.sendTextToWebhook({
|
||||
webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=a',
|
||||
content: huge,
|
||||
});
|
||||
assert.equal(result.truncated, true);
|
||||
assert.ok(result.bytes <= sender.MAX_CONTENT_BYTES);
|
||||
|
||||
const body = JSON.parse(stub.calls[0].options.body);
|
||||
assert.ok(Buffer.byteLength(body.text.content, 'utf8') <= sender.MAX_CONTENT_BYTES);
|
||||
} finally {
|
||||
stub.restore();
|
||||
}
|
||||
});
|
||||
@ -1,694 +0,0 @@
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 第八版)
|
||||
*
|
||||
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定几条关键语义:
|
||||
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走;
|
||||
* ② 核验轮次的时刻选择 —— 13:00 前**不做核验**、13:00~17:59 用 07:00 窗口、
|
||||
* 18:00 后用 16:00 窗口;「核验时刻」与「订单窗口上界」是两个字段;
|
||||
* ③ 数据源必须是 `jst_order_express_details`(聚水潭实时状态镜像)——
|
||||
* 订单表的 status 是下单快照,取消/售后/审核都不回写,用它必错;
|
||||
* ④ 🔥 窗口按**创建时间**卡 7:00 / 16:00(含历史未发),**已等时长按审核时间算**:
|
||||
* 「当日 7 点前订单(7 点前订单按照审核时间算),13:00 核验未发就算超时」。
|
||||
* ⚠️ 别把窗口卡到审核时间上 —— 审单集中在早上 8:30~10:00 批量做,
|
||||
* 卡审核时间 13:00 轮全库只剩 2 单(实测 29 单 → 2 单),等于把功能做废;
|
||||
* ⑤ 🔥 **没有「满 24 小时」门槛**:到核验时刻未发即超时。
|
||||
* 第七版加了 24h 门槛,13:00 轮永远看不到当天 7 点那批,被业务打回;
|
||||
* ⑥ 三态互斥:发货中+无发货时间=超时未发货 / 待审核·异常=不计考核 / 其余丢弃;
|
||||
* **作废单(已取消 / 合并 / 拆单父单)不考核**;
|
||||
* ⑦ 性能护栏:状态表只允许 **1 次** querySlow,不得投影宽列、不得回订单表补金额。
|
||||
*
|
||||
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
|
||||
*/
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const registry = require('../src/services/warehouseShipTimelinessRegistry');
|
||||
|
||||
// ── ① 主仓归属:最长匹配 ────────────────────────────────────────────────
|
||||
test('resolveCenter 用最长关键词匹配,品牌仓不会被主仓抢走', () => {
|
||||
assert.equal(registry.resolveCenter('简尚-横林二仓品牌仓').key, 'HL2_BRAND');
|
||||
assert.equal(registry.resolveCenter('简尚-横林二仓').key, 'HL2');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓').key, 'CQ_BRAND');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓').key, 'CQ');
|
||||
assert.equal(registry.resolveCenter('简尚-山东品牌仓').key, 'SD_BRAND');
|
||||
assert.equal(registry.resolveCenter('简尚-山东青岗集仓').key, 'SD_QGJ');
|
||||
});
|
||||
|
||||
test('resolveCenter 能捞到各种命名的子仓', () => {
|
||||
const cases = [
|
||||
['简尚-运营C横林二仓专用仓', 'HL2'],
|
||||
['简尚-横林二仓京东仓', 'HL2'],
|
||||
['京仓-横林二仓京东自营仓', 'HL2'],
|
||||
['简尚-崔桥工厂仓唯品仓', 'CQ'],
|
||||
['简尚-运营C崔桥工厂仓专用仓', 'CQ'],
|
||||
['简尚-新苏北临时仓', 'XSB'],
|
||||
['简尚-分销部睢宁专用仓', 'SN'],
|
||||
['京仓-山东青岗集京东仓储', 'SD_QGJ'],
|
||||
['简尚-山东青岗集仓马来海外仓', 'SD_QGJ'],
|
||||
['简尚-河南洛宁仓', 'HN_LN'],
|
||||
];
|
||||
for (const [name, expected] of cases) {
|
||||
assert.equal(registry.resolveCenter(name)?.key, expected, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('「京仓-横林二仓京东云仓」业务指定排除,不计入横林二仓', () => {
|
||||
assert.equal(registry.resolveCenter('京仓-横林二仓京东云仓'), null);
|
||||
// ⚠️ 不能连带排掉名字相近的仓 —— 关键词必须写完整片段
|
||||
assert.equal(registry.resolveCenter('简尚-横林二仓京东仓')?.key, 'HL2');
|
||||
assert.equal(registry.resolveCenter('京仓-横林二仓京东自营仓')?.key, 'HL2');
|
||||
assert.equal(registry.resolveCenter('京仓-崔桥工厂仓京东云仓')?.key, 'CQ');
|
||||
assert.equal(registry.resolveCenter('京仓-山东青岗集京东云仓')?.key, 'SD_QGJ');
|
||||
});
|
||||
|
||||
test('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
|
||||
const shouldBeNull = [
|
||||
'工厂-山东菏泽森美代发仓',
|
||||
'工厂-山东恒思代发仓',
|
||||
'工厂-河南千优代发仓',
|
||||
'工厂-河北霸州派雅世佳代发仓',
|
||||
'简尚-苏州福利品仓',
|
||||
];
|
||||
for (const name of shouldBeNull) {
|
||||
assert.equal(registry.resolveCenter(name), null, `${name} 不应归属任何主仓`);
|
||||
}
|
||||
});
|
||||
|
||||
test('崔桥工厂半成品包装项目仓已接入(不再是占位仓)', () => {
|
||||
const c = registry.CENTERS.find((x) => x.key === 'CQ_WIP');
|
||||
assert.ok(c, 'CQ_WIP 必须存在');
|
||||
assert.equal(c.placeholder, undefined, '已接入真实仓,不能再是占位仓');
|
||||
assert.equal(c.name, '崔桥工厂半成品包装项目仓');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂半成品包装项目仓')?.key, 'CQ_WIP');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂仓')?.key, 'CQ');
|
||||
assert.equal(registry.resolveCenter('简尚-崔桥工厂品牌仓')?.key, 'CQ_BRAND');
|
||||
});
|
||||
|
||||
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
|
||||
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
|
||||
|
||||
test('13:00 前不做核验,返回 null(不回填昨日数据)', () => {
|
||||
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
|
||||
assert.equal(registry.resolveActiveRound(at(12, 59)), null);
|
||||
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
|
||||
});
|
||||
|
||||
test('13:00~17:59 用 13:00 轮,窗口是「截止今日 07:00 前创建(含历史)」', () => {
|
||||
for (const [h, m] of [[13, 0], [15, 30], [17, 59]]) {
|
||||
const r = registry.resolveActiveRound(at(h, m));
|
||||
assert.ok(r, `${h}:${m} 应已进入首轮核验`);
|
||||
assert.equal(r.key, 'NOON');
|
||||
// 核验时刻 = 13:00(判定「发了没发 / 超时多久」用它)
|
||||
assert.equal(r.deadline, '2026-10-20 13:00:00');
|
||||
assert.equal(r.verifyHour, 13);
|
||||
// 订单窗口上界 = 07:00(只核验该时刻前创建的单)
|
||||
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
|
||||
assert.notEqual(r.deadline, r.windowEnd, '核验时刻与窗口上界必须是两个值');
|
||||
assert.equal(r.includeHistory, true);
|
||||
assert.notEqual(r.windowStart, '2026-10-20 00:00:00', '下界不能卡在当天 00:00');
|
||||
assert.equal(r.windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
|
||||
}
|
||||
});
|
||||
|
||||
test('18:00 之后用 18:00 轮,窗口上界扩到当天 16:00', () => {
|
||||
const r = registry.resolveActiveRound(at(18, 0));
|
||||
assert.equal(r.key, 'AFTERNOON');
|
||||
assert.equal(r.deadline, '2026-10-20 18:00:00');
|
||||
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
|
||||
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
|
||||
});
|
||||
|
||||
test('describeRounds 两轮的核验时刻与窗口上界(16:00~17:59 属于「等 18:00 轮」)', () => {
|
||||
const rounds = registry.describeRounds(at(16, 5));
|
||||
assert.deepEqual(
|
||||
rounds.map((r) => [r.key, r.deadline.slice(11, 16), r.windowEnd.slice(11, 16)]),
|
||||
[['NOON', '13:00', '07:00'], ['AFTERNOON', '18:00', '16:00']]
|
||||
);
|
||||
assert.equal(rounds[0].active, true);
|
||||
assert.equal(rounds[1].active, false, '16:05 还没到 18:00 轮');
|
||||
assert.equal(registry.resolveActiveRound(at(16, 5)).key, 'NOON');
|
||||
});
|
||||
|
||||
test('审核晚于本轮核验时刻 → 顺延到下一轮核验时刻', () => {
|
||||
const [noon, afternoon] = registry.describeRounds(at(16, 5));
|
||||
assert.equal(registry.nextRoundDeadline(noon), '2026-10-20 18:00:00', '13:00 轮 → 当天 18:00');
|
||||
assert.equal(registry.nextRoundDeadline(afternoon), '2026-10-21 13:00:00',
|
||||
'18:00 是当天最后一轮 → 次日 13:00');
|
||||
// 跨月边界也要对
|
||||
assert.equal(
|
||||
registry.nextRoundDeadline({ key: 'AFTERNOON', date: '2026-10-31' }),
|
||||
'2026-11-01 13:00:00'
|
||||
);
|
||||
});
|
||||
|
||||
// ── ③ 常量口径 ──────────────────────────────────────────────────────────
|
||||
test('数据源必须是聚水潭实时状态表,不是订单表', () => {
|
||||
assert.equal(registry.ORDER_STATE_TABLE, 'jst_order_express_details');
|
||||
});
|
||||
|
||||
test('三态名称;24 小时常量仅用于页面命名,不再参与判定', () => {
|
||||
assert.equal(registry.SHIPPING_STATUS, '发货中');
|
||||
assert.equal(registry.UNAUDITED_STATUS, '已支付待审核');
|
||||
assert.equal(registry.ABNORMAL_STATUS, '异常');
|
||||
// 页面/路由仍叫「24 小时发货时效」,但判定是 13:00/18:00 到点即超时
|
||||
assert.equal(registry.SHIP_TIMELINESS_HOURS, 24);
|
||||
});
|
||||
|
||||
test('作废单(已取消)明确不考核', () => {
|
||||
assert.ok(
|
||||
registry.IGNORED_STATES.includes('已取消'),
|
||||
'「已取消」必须在排除名单里 —— 近 30 天 6.4 万单,是「发货中」的 26 倍'
|
||||
);
|
||||
assert.ok(registry.IGNORED_STATES.includes('已发货'));
|
||||
assert.ok(registry.IGNORED_STATES.includes('等供销商|外仓发货'));
|
||||
});
|
||||
|
||||
test('创建时间要从订单表补,且只补窄列', () => {
|
||||
assert.deepEqual(
|
||||
[...registry.CREATED_SOURCE_TABLES],
|
||||
['jst_Orders', 'jst_qmcust_orderlist'],
|
||||
'两张订单表都要查(店铺互斥,交集为 0)'
|
||||
);
|
||||
assert.ok(registry.CREATED_BACKFILL_BATCH >= 100, '必须分批,不能一次 IN 上万个');
|
||||
});
|
||||
|
||||
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||||
function stubDb(dbPath, scenario = 'default') {
|
||||
const wmsId = 15038426;
|
||||
|
||||
// 创建时间(展示)+ 作废事实来源:o_id → 订单表行
|
||||
// 故意让 3 号单**补不到**(模拟订单表缺行)→ 展示回退付款时间
|
||||
const f = (table, o) => ({ table, status: 'WaitConfirm', send_date: null, is_merge: 0, is_split: 0, ...o });
|
||||
|
||||
const defaultFacts = {
|
||||
1: f('jst_Orders', { created: '2026-09-25 00:50:00' }),
|
||||
2: f('jst_Orders', { created: '2026-10-20 16:20:00' }),
|
||||
6: f('jst_Orders', { created: '2026-10-20 17:00:00' }),
|
||||
7: f('jst_qmcust_orderlist', { created: '2026-09-25 01:00:00' }),
|
||||
9: f('jst_Orders', { created: '2026-09-21 09:00:00', status: 'Cancelled' }),
|
||||
// 10:状态表「发货中 + send_time 空」,但订单表是 Merged —— 真实案例
|
||||
// 5127276315127080023(内部单号 9486414)就是这样被误判成超时的
|
||||
10: f('jst_qmcust_orderlist', { created: '2026-09-21 10:00:00', status: 'Merged', send_date: '2026-09-22 06:54:49' }),
|
||||
// 11:状态表 send_time 空,订单表 send_date 有值(已发)
|
||||
11: f('jst_Orders', { created: '2026-09-21 11:00:00', send_date: '2026-09-22 08:00:00' }),
|
||||
// 37:🔥 订单表 send_date 没回写,但销售订单 status 已是 Sent(已发货)
|
||||
// —— 业务 2026-10-20「已发/未发参考聚水潭销售订单页面」
|
||||
37: f('jst_Orders', { created: '2026-09-22 09:00:00', status: 'Sent' }),
|
||||
// 12:拆单父单,status 不是 Split 但 is_split=1
|
||||
12: f('jst_Orders', { created: '2026-09-21 12:00:00', status: 'Delivering', is_split: 1 }),
|
||||
13: f('jst_Orders', { created: '2026-10-20 15:00:00' }),
|
||||
// 31~34:验证仓级例外窗口(山东两仓 15:00、河南洛宁 13:00,仅 18:00 轮)
|
||||
31: f('jst_Orders', { created: '2026-10-20 15:30:00' }),
|
||||
32: f('jst_Orders', { created: '2026-10-20 14:30:00' }),
|
||||
33: f('jst_Orders', { created: '2026-10-20 13:30:00' }),
|
||||
34: f('jst_Orders', { created: '2026-10-20 12:30:00' }),
|
||||
// 26:创建 10-18,但**今天 13:10 才审核** —— 业务点名的形态(9818476)
|
||||
// 只能进 18:00 轮,不能进 13:00 轮
|
||||
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
||||
// 25:重审单(首次 10-19 10:10 / 最后一次 10-20 13:30)—— 聚水潭显示 13:30
|
||||
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
||||
// ── 2026-10-22 作废单三类形态 ──
|
||||
// 38:订单表 Question(聚水潭「已作废/问题单」)
|
||||
38: f('jst_Orders', { created: '2026-10-20 09:10:00', status: 'Question' }),
|
||||
// 39:labels 含「发货前售后」—— stub 直接给布尔结果(真实 SQL 用 CASE WHEN LIKE 转)
|
||||
39: f('jst_Orders', { created: '2026-10-20 10:10:00', presaleVoid: 1 }),
|
||||
// 40:靠状态表 aftersale_status_name='已作废' 剔除,订单表本身没异常
|
||||
40: f('jst_Orders', { created: '2026-10-20 11:10:00' }),
|
||||
};
|
||||
|
||||
// 13:00 轮场景:验证「窗口按创建时间卡 7 点、时长按审核时间算」
|
||||
const noonFacts = {
|
||||
21: f('jst_Orders', { created: '2026-10-20 06:45:00' }),
|
||||
22: f('jst_Orders', { created: '2026-10-20 05:55:00' }),
|
||||
23: f('jst_Orders', { created: '2026-10-19 19:55:00' }),
|
||||
24: f('jst_Orders', { created: '2026-10-20 07:30:00' }),
|
||||
// 25:重审单(首次审核 10-19 10:10、最后一次 10-20 13:30)—— 时长要按首次算
|
||||
25: f('jst_Orders', { created: '2026-10-19 10:00:00' }),
|
||||
// 26:今天 13:10 才审(晚于核验时刻 13:00)—— 已等时长要回退按创建时间算
|
||||
26: f('jst_Orders', { created: '2026-10-18 12:00:00' }),
|
||||
};
|
||||
|
||||
const factsMap = scenario === 'noon' ? noonFacts : defaultFacts;
|
||||
|
||||
const mk = (o) => ({
|
||||
shop_name: '测试店',
|
||||
logistic_status_name: '',
|
||||
query_time: '2026-10-20 11:18:27',
|
||||
// 状态表售后状态:默认「暂无售后」;2026-10-22 新增「已作废」判据要用到
|
||||
aftersale_status_name: '暂无售后',
|
||||
// 默认首次审核 = 最后一次审核;需要区分重审的用例显式覆盖 first_oa_confirm_time
|
||||
first_oa_confirm_time: o.last_oa_confirm_time ?? null,
|
||||
...o,
|
||||
});
|
||||
|
||||
const defaultRows = [
|
||||
// 1:审核 09-25 01:00 → 到 18:00 核验 = 25 天 17 小时,超时
|
||||
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
|
||||
last_oa_confirm_time: '2026-09-25 01:00:00' }),
|
||||
// 2:创建 16:20 晚于窗口上界 16:00 → 本轮不核验(窗口卡的是创建时间)
|
||||
mk({ so_id: 'SO-2', inner_order_id: 2, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 16:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 16:30:00' }),
|
||||
// 3:订单表补不到创建时间 → 展示回退付款时间;审核 09-26 00:15 仍应判超时
|
||||
mk({ so_id: 'SO-3', inner_order_id: 3, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-26 00:10:00',
|
||||
last_oa_confirm_time: '2026-09-26 00:15:00' }),
|
||||
// 4:已支付待审核(还没审单、没有审核时间)→ 不计考核
|
||||
mk({ so_id: 'SO-4', inner_order_id: 4, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '已支付待审核', pay_time: '2026-09-23 01:00:00',
|
||||
last_oa_confirm_time: null }),
|
||||
// 5:异常 → 不计考核
|
||||
mk({ so_id: 'SO-5', inner_order_id: 5, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '异常', logistic_status_name: '常州安能新', pay_time: '2026-09-23 01:00:00',
|
||||
last_oa_confirm_time: '2026-09-23 01:05:00' }),
|
||||
// 6:审核 17:05 晚于窗口上界 → 本轮不该核验
|
||||
mk({ so_id: 'SO-6', inner_order_id: 6, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 16:55:00',
|
||||
last_oa_confirm_time: '2026-10-20 17:05:00' }),
|
||||
// 7:被业务排除的京东云仓 → 不归属任何主仓
|
||||
mk({ so_id: 'SO-7', inner_order_id: 7, warehouse_name: '京仓-横林二仓京东云仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-25 00:00:00',
|
||||
last_oa_confirm_time: '2026-09-25 00:10:00' }),
|
||||
// 8:与 1 同 so_id + 同基准时间(拆包重复行)→ 必须去重
|
||||
mk({ so_id: 'SO-1', inner_order_id: 1, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-24 23:50:00',
|
||||
last_oa_confirm_time: '2026-09-25 01:00:00' }),
|
||||
// 9:作废单(已取消)—— 业务定性不考核
|
||||
mk({ so_id: 'SO-9', inner_order_id: 9, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '已取消', pay_time: '2026-09-21 08:00:00',
|
||||
last_oa_confirm_time: '2026-09-21 08:10:00' }),
|
||||
// 10:状态表「发货中」、但订单表是 Merged + 已发 —— 必须剔除
|
||||
mk({ so_id: 'SO-10', inner_order_id: 10, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-21 09:59:00',
|
||||
last_oa_confirm_time: '2026-09-21 10:05:00' }),
|
||||
// 11:状态表「发货中」、订单表 send_date 有值(已发)—— 必须剔除
|
||||
mk({ so_id: 'SO-11', inner_order_id: 11, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-21 10:59:00',
|
||||
last_oa_confirm_time: '2026-09-21 11:05:00' }),
|
||||
// 12:拆单父单(is_split=1)—— 自己不发货,必须剔除
|
||||
mk({ so_id: 'SO-12', inner_order_id: 12, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-21 11:59:00',
|
||||
last_oa_confirm_time: '2026-09-21 12:05:00' }),
|
||||
// 13:🔥 当天 15:30 审核、18:00 核验只等了 2.5 小时 —— 旧版 24h 门槛会漏掉,
|
||||
// 本版必须算超时(业务口径:到点未发就算超时)
|
||||
mk({ so_id: 'SO-13', inner_order_id: 13, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 14:59:00',
|
||||
last_oa_confirm_time: '2026-10-20 15:30:00' }),
|
||||
// ── 仓级例外窗口(18:00 轮):山东两仓 15:00、河南洛宁 13:00 ──
|
||||
// 31:山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界 → 本轮不核验
|
||||
mk({ so_id: 'SO-31', inner_order_id: 31, warehouse_name: '简尚-山东青岗集仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 15:29:00',
|
||||
last_oa_confirm_time: '2026-10-20 15:35:00' }),
|
||||
// 32:山东品牌仓 14:30 创建 → 早于 15:00 上界 → 核验,且超时
|
||||
mk({ so_id: 'SO-32', inner_order_id: 32, warehouse_name: '简尚-山东品牌仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 14:29:00',
|
||||
last_oa_confirm_time: '2026-10-20 14:35:00' }),
|
||||
// 33:河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界 → 本轮不核验
|
||||
mk({ so_id: 'SO-33', inner_order_id: 33, warehouse_name: '简尚-河南洛宁仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 13:29:00',
|
||||
last_oa_confirm_time: '2026-10-20 13:35:00' }),
|
||||
// 34:河南洛宁仓 12:30 创建 → 早于 13:00 上界 → 核验,且超时
|
||||
mk({ so_id: 'SO-34', inner_order_id: 34, warehouse_name: '简尚-河南洛宁仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 12:29:00',
|
||||
last_oa_confirm_time: '2026-10-20 12:35:00' }),
|
||||
// 26:🔥 业务点名的形态 —— 创建 10-18,但今天 13:10 才审核(9818476 就是这样)。
|
||||
// 它属于 18:00 轮(13:10 ≤ 16:00),已等 = 18:00 − 13:10 = 290 分
|
||||
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
|
||||
first_oa_confirm_time: '2026-10-20 13:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 13:10:00' }),
|
||||
// 25:重审单 —— 聚水潭「审核时间」显示最后一次 10-20 13:30(不是首次 10-19 10:10)
|
||||
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
|
||||
first_oa_confirm_time: '2026-10-19 10:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 13:30:00' }),
|
||||
// 35/36:🔥 不考核快递(业务 2026-10-20):中铁物流、壹米滴答专线 —— 一律跳过。
|
||||
// 这两单状态/时间都够超时,靠快递名剔除,同时计入 summary.carrierSkipped
|
||||
mk({ so_id: 'SO-35', inner_order_id: 35, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-25 01:00:00',
|
||||
last_oa_confirm_time: '2026-09-25 01:30:00', logistic_company_name: '中铁物流' }),
|
||||
mk({ so_id: 'SO-36', inner_order_id: 36, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-25 02:00:00',
|
||||
last_oa_confirm_time: '2026-09-25 02:30:00', logistic_company_name: '壹米滴答专线' }),
|
||||
// 37:销售订单 status=Sent(已发货),状态表还是「发货中 + send_time 空」→ 必须剔除
|
||||
mk({ so_id: 'SO-37', inner_order_id: 37, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-09-22 08:50:00',
|
||||
last_oa_confirm_time: '2026-09-22 09:05:00' }),
|
||||
// ── 2026-10-22 作废单三类形态(业务反馈 3 单在聚水潭后台已作废却仍算超时)──
|
||||
// 38:订单表 status=Question(聚水潭后台的「已作废/问题单」)—— 真实案例 9823980 / 9880810
|
||||
mk({ so_id: 'SO-38', inner_order_id: 38, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 09:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 09:15:00' }),
|
||||
// 39:订单表状态正常,但 labels 含「发货前售后」→ 视同作废 —— 真实案例 9823980
|
||||
mk({ so_id: 'SO-39', inner_order_id: 39, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 10:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 10:15:00' }),
|
||||
// 40:状态表售后状态「已作废」→ 最权威的作废标记,直接剔除
|
||||
mk({ so_id: 'SO-40', inner_order_id: 40, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', aftersale_status_name: '已作废',
|
||||
pay_time: '2026-10-20 11:10:00', last_oa_confirm_time: '2026-10-20 11:15:00' }),
|
||||
];
|
||||
|
||||
// 13:00 轮:窗口上界 07:00(按创建时间),核验时刻 13:00
|
||||
const noonRows = [
|
||||
// 21:创建 06:45(7 点前)+ 审核 07:20 → 进 13:00 轮;已等 = 13:00 − 07:20 = 340 分
|
||||
mk({ so_id: 'SO-21', inner_order_id: 21, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 06:40:00',
|
||||
last_oa_confirm_time: '2026-10-20 07:20:00' }),
|
||||
// 22:创建 05:55 + 审核 06:40 → 13:00 核验只等 380 分钟,也必须算超时(无 24h 门槛)
|
||||
mk({ so_id: 'SO-22', inner_order_id: 22, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 05:50:00',
|
||||
last_oa_confirm_time: '2026-10-20 06:40:00' }),
|
||||
// 23:昨天创建、至今未发(历史未发池)→ 13:00 轮也要看到
|
||||
mk({ so_id: 'SO-23', inner_order_id: 23, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-19 19:50:00',
|
||||
last_oa_confirm_time: '2026-10-19 20:10:00' }),
|
||||
// 24:创建 07:30 晚于窗口上界 07:00 → 本轮不核验(窗口卡的是创建时间)
|
||||
mk({ so_id: 'SO-24', inner_order_id: 24, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-20 07:29:00',
|
||||
last_oa_confirm_time: '2026-10-20 07:40:00' }),
|
||||
// 25:🔥 被「重新审核」过的单:首次审核 10-19 10:10、最后一次 10-20 13:30。
|
||||
// 时长必须按**首次**审核算(1610 分),用 last 会算成 0 分
|
||||
mk({ so_id: 'SO-25', inner_order_id: 25, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-19 09:59:00',
|
||||
first_oa_confirm_time: '2026-10-19 10:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 13:30:00' }),
|
||||
// 26:🔥 今天 13:10 才审(晚于核验时刻 13:00)→ 已等时长回退按创建时间算,
|
||||
// 不能显示 0 分(真实案例:创建 10-18 的单今天才审)
|
||||
mk({ so_id: 'SO-26', inner_order_id: 26, warehouse_name: '简尚-崔桥工厂仓',
|
||||
order_status_name: '发货中', pay_time: '2026-10-18 11:59:00',
|
||||
first_oa_confirm_time: '2026-10-20 13:10:00',
|
||||
last_oa_confirm_time: '2026-10-20 13:10:00' }),
|
||||
];
|
||||
|
||||
const rows = scenario === 'noon' ? noonRows : defaultRows;
|
||||
|
||||
const calls = { state: 0, backfill: 0, sql: [], backfillSql: [], params: [] };
|
||||
|
||||
const handler = async (sql, params) => {
|
||||
const text = String(sql);
|
||||
if (/jst_warehouseInfo/u.test(text)) {
|
||||
return [
|
||||
{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId },
|
||||
{ Id: 2, name: '简尚-山东青岗集仓', wms_co_id: 15137523 },
|
||||
{ Id: 3, name: '简尚-山东品牌仓', wms_co_id: 15182705 },
|
||||
{ Id: 4, name: '简尚-河南洛宁仓', wms_co_id: 15539084 },
|
||||
];
|
||||
}
|
||||
// 补「起算点 + 作废事实」:按 o_id 分批 IN 两张订单表
|
||||
if (/FROM dbo\.(jst_Orders|jst_qmcust_orderlist)/u.test(text)) {
|
||||
calls.backfill += 1;
|
||||
calls.backfillSql.push(text);
|
||||
const table = /jst_qmcust_orderlist/u.test(text) ? 'jst_qmcust_orderlist' : 'jst_Orders';
|
||||
const m = text.match(/IN \(([0-9,\s]+)\)/u);
|
||||
const ids = m ? m[1].split(',').map((s) => Number(s.trim())) : [];
|
||||
return ids
|
||||
.filter((id) => factsMap[id] && factsMap[id].table === table)
|
||||
.map((id) => {
|
||||
const x = factsMap[id];
|
||||
return {
|
||||
o_id: id,
|
||||
created: x.created,
|
||||
status: x.status,
|
||||
send_date: x.send_date,
|
||||
is_merge: x.is_merge,
|
||||
is_split: x.is_split,
|
||||
presaleVoid: x.presaleVoid || 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
calls.state += 1;
|
||||
calls.sql.push(text);
|
||||
calls.params.push(params || {});
|
||||
return rows;
|
||||
};
|
||||
|
||||
require.cache[dbPath] = {
|
||||
id: dbPath,
|
||||
filename: dbPath,
|
||||
loaded: true,
|
||||
exports: { query: handler, querySlow: handler },
|
||||
};
|
||||
return { calls, wmsId };
|
||||
}
|
||||
|
||||
function withStubbedService(fn, scenario = 'default') {
|
||||
return async () => {
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
const realDb = require.cache[dbPath];
|
||||
const ctx = stubDb(dbPath, scenario);
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||
try {
|
||||
const svc = require('../src/services/warehouseShipTimelinessService');
|
||||
await fn(svc, ctx);
|
||||
} finally {
|
||||
if (realDb) require.cache[dbPath] = realDb;
|
||||
else delete require.cache[dbPath];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
test('服务层三态分流:超时未发货 / 不计考核 / 丢弃', withStubbedService(async (svc, { calls }) => {
|
||||
// 18:05 → 18:00 轮:窗口上界 16:00(按审核时间),核验时刻 18:00
|
||||
const data = await svc.getShipTimeliness({ now: at(18, 5), limit: 10 });
|
||||
const c = data.centers.find((x) => x.key === 'CQ');
|
||||
assert.ok(c, '应能归到崔桥工厂仓');
|
||||
|
||||
// 超时 = 1(09-25 审核)+ 3(补不到创建时间)+ 13(15:30 审核,只等 2.5 小时)
|
||||
// + 32(山东品牌仓 14:30)+ 34(河南洛宁 12:30)+ 26(今天 13:10 审核)
|
||||
// = 6 单
|
||||
// 2/6 今天审核晚于 16:00、4/5 不计考核、7 非主仓、8 去重、9~12 作废/已发
|
||||
// 31/33 是仓级例外窗口(山东 15:00 / 河南洛宁 13:00)挡掉的
|
||||
assert.equal(c.overdueUnshipped, 5, '1、3、13、26、25 五单算超时');
|
||||
assert.equal(c.oldestDays, 25, '最久一单 09-25 01:00 审核,到 18:00 核验是 25 天');
|
||||
assert.equal(c.heldCount, 2, '待审核 + 异常各 1 单');
|
||||
|
||||
assert.equal(data.summary.overdueUnshipped, 7);
|
||||
assert.equal(data.summary.overdueCenterCount, 3);
|
||||
assert.equal(data.summary.oldestDays, 25);
|
||||
assert.equal(data.summary.heldCount, 2);
|
||||
|
||||
// 明细:最久的排最前;基准时间是**审核时间**,创建时间只展示
|
||||
assert.equal(data.orders.length, 7);
|
||||
assert.equal(String(data.orders[0].oId), '1');
|
||||
assert.equal(data.orders[0].auditTime, '2026-09-25 01:00:00', '基准是审核时间');
|
||||
assert.equal(data.orders[0].createdAt, '2026-09-25 00:50:00', '创建时间只作展示');
|
||||
assert.equal(data.orders[0].payTime, '2026-09-24 23:50:00');
|
||||
assert.equal(data.orders[0].benchmarkSource, 'last_audit');
|
||||
// 09-25 01:00 → 10-20 18:00 = 25 天 + 17 小时 = 37020 分钟
|
||||
assert.equal(data.orders[0].overdueMinutes, 37020);
|
||||
|
||||
// 13 号单:15:30 审核 → 18:00 核验只等了 150 分钟,旧版 24h 门槛会漏,本版必须算
|
||||
const fresh = data.orders.find((o) => String(o.oId) === '13');
|
||||
assert.ok(fresh, '只等 2.5 小时也必须算超时(无 24h 门槛)');
|
||||
assert.equal(fresh.overdueMinutes, 150);
|
||||
assert.equal(fresh.waitedHours, 2.5);
|
||||
|
||||
// 26 号单:创建 10-18、今天 13:10 才审核 → 属于 18:00 轮,已等 = 18:00 − 13:10 = 290
|
||||
const lateAudit = data.orders.find((o) => String(o.oId) === '26');
|
||||
assert.ok(lateAudit, '下午才审核的老单要进 18:00 轮(不能因为创建早就算进 13:00 轮)');
|
||||
assert.equal(lateAudit.overdueMinutes, 290);
|
||||
|
||||
// 25 号:重审单 → 审核时间必须显示**最后一次**(13:30,与聚水潭一致),不是首次 10:10
|
||||
const reAudited = data.orders.find((o) => String(o.oId) === '25');
|
||||
assert.ok(reAudited, '重审单也要算');
|
||||
assert.equal(reAudited.auditTime, '2026-10-20 13:30:00', '聚水潭「审核时间」= 最后一次审核');
|
||||
assert.equal(reAudited.overdueMinutes, 270, '18:00 − 13:30');
|
||||
|
||||
// 3 号单订单表补不到 created,展示回退付款时间,超时判定仍用审核时间
|
||||
const third = data.orders.find((o) => String(o.oId) === '3');
|
||||
assert.ok(third, '补不到创建时间的单也要算超时');
|
||||
assert.equal(third.createdAt, '', '确实没补到');
|
||||
assert.equal(third.benchmarkSource, 'last_audit');
|
||||
assert.equal(third.payTime, '2026-09-26 00:10:00');
|
||||
|
||||
// 创建时间晚于窗口上界 → 本轮不核验(2 号:创建 16:20、6 号:创建 17:00)
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '2'), '创建 16:20 晚于 16:00 本轮不核验');
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '6'), '创建 17:00 晚于 16:00 本轮不核验');
|
||||
|
||||
// ── 仓级例外窗口(业务 2026-10-20:山东两仓 15:00、河南洛宁 13:00)──
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '31'),
|
||||
'山东青岗集仓 15:30 创建 → 晚于该仓 15:00 上界,18:00 轮不核验');
|
||||
assert.ok(data.orders.some((o) => String(o.oId) === '32'),
|
||||
'山东品牌仓 14:30 创建 → 早于该仓 15:00 上界,要核验');
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '33'),
|
||||
'河南洛宁仓 13:30 创建 → 晚于该仓 13:00 上界,18:00 轮不核验');
|
||||
assert.ok(data.orders.some((o) => String(o.oId) === '34'),
|
||||
'河南洛宁仓 12:30 创建 → 早于该仓 13:00 上界,要核验');
|
||||
assert.equal(data.window.overrides.length, 3, '山东两仓 + 河南洛宁 三处例外');
|
||||
assert.deepEqual(
|
||||
data.window.overrides.map((o) => [o.key, o.hour]).sort(),
|
||||
[['HN_LN', 13], ['SD_BRAND', 15], ['SD_QGJ', 15]]
|
||||
);
|
||||
// 例外只作用在 18:00 轮;13:00 轮没有例外(见下一个用例)
|
||||
assert.match(data.window.overrides.find((o) => o.key === 'HN_LN').windowEnd, /13:00:00$/u);
|
||||
// 被排除的仓不能算
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '7'), '京东云仓不参与考核');
|
||||
// 9(状态表已取消) / 10(Merged) / 11(已发) / 12(拆单父单) 都不能进任何清单
|
||||
// 38/39/40 = 2026-10-22 新补的三类作废形态(Question / 发货前售后标签 / 售后已作废)
|
||||
for (const id of ['9', '10', '11', '12', '38', '39', '40']) {
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === id), `o_id=${id} 不该进超时明细`);
|
||||
assert.ok(!data.heldOrders.some((o) => String(o.oId) === id), `o_id=${id} 不该进挂起清单`);
|
||||
}
|
||||
// 这 8 单要计进 voidCount(9/10/11/12 作废或已发 + 37 已发货 Sent + 38/39/40 作废三类)
|
||||
assert.equal(data.summary.voidCount, 8, '作废/已发单要计入剔除数');
|
||||
|
||||
// 不考核快递:中铁物流、壹米滴答专线必须整单跳过(业务 2026-10-20)
|
||||
assert.equal(data.summary.carrierSkipped, 2, '自提/中铁/壹米滴答 要计入 carrierSkipped');
|
||||
assert.ok(
|
||||
!data.orders.some((o) => ['SO-35', 'SO-36'].includes(o.soId)),
|
||||
'不考核快递的单不能出现在超时明细里'
|
||||
);
|
||||
assert.deepEqual(data.excludedCarriers, ['自提', '中铁', '壹米滴答']);
|
||||
|
||||
// 不计考核清单
|
||||
assert.equal(data.heldOrders.length, 2);
|
||||
const reasons = data.heldOrders.map((h) => h.holdReason).sort();
|
||||
assert.deepEqual(reasons, ['待审核', '订单异常'].sort());
|
||||
|
||||
// 新鲜度直接取自主查询的 query_time,不再额外扫表
|
||||
assert.equal(data.freshness.stateMaxQuery, '2026-10-20 11:18:27');
|
||||
// 🔥 2026-10-20:上游 ETL 停更时页面必须自己喊出来(当天 15:48 之后 0 行刷新,
|
||||
// 业务看到「已发货的单还显示未发货」,其实是旧快照)。阈值 90 分钟。
|
||||
assert.equal(typeof data.freshness.ageMinutes, 'number', '要给出快照距今多少分钟');
|
||||
assert.equal(
|
||||
data.freshness.stale,
|
||||
data.freshness.ageMinutes > 90,
|
||||
'滞后超过 90 分钟必须标 stale(阈值与 STATE_STALE_MINUTES 对齐)'
|
||||
);
|
||||
if (data.freshness.stale) {
|
||||
assert.match(data.freshness.hint, /未刷新/u, 'stale 时必须给出人话提示');
|
||||
}
|
||||
// 快照 11:18 早于本轮 18:00 核验时刻 → 必须明确「数据没覆盖核验时刻」
|
||||
assert.equal(data.freshness.coversDeadline, false, '快照早于核验时刻时 coversDeadline 必须为 false');
|
||||
assert.match(data.freshness.hint, /没覆盖|未覆盖/u, '没覆盖核验时刻要在提示里说清楚');
|
||||
|
||||
// ── 性能护栏 ────────────────────────────────────────────────────────
|
||||
assert.equal(calls.state, 1, '状态表只允许查一次(金额/新鲜度不许再扫表)');
|
||||
const sql = calls.sql[0];
|
||||
assert.match(sql, /jst_order_express_details/u, '数据源必须是实时状态表');
|
||||
assert.match(sql, /e\.send_time IS NULL/u, '已发货靠 send_time 判');
|
||||
// 本版基准是审核时间 → 两列审核时间都必须在投影里
|
||||
assert.match(sql, /e\.last_oa_confirm_time/u, '窗口上界按审核时间判,必须取这一列');
|
||||
assert.match(sql, /e\.first_oa_confirm_time/u, '审核时间缺失时用首次审核时间兜底');
|
||||
// 🔥 不再把「创建/付款 ≤ 核验时刻−24h」下推到 SQL(24h 门槛已取消)
|
||||
assert.doesNotMatch(sql, /@cutoff/u, '不许再有 24h 超时下推参数');
|
||||
assert.doesNotMatch(sql, /pay_time <=/u, '不许再按付款时间做 24h 截断');
|
||||
assert.doesNotMatch(sql, /createdAt/u, '状态表的 createdAt 是同步落库时间,不能用');
|
||||
// 宽列会触发基表回表:同一 WHERE 取 2 列 0.7s,取 15 列(含长文本)54s
|
||||
assert.doesNotMatch(sql, /order_labels|buyer_message|remark/u, '不能投影长文本列');
|
||||
assert.doesNotMatch(sql, /CONVERT/u, '不要在 SQL 里 CONVERT 时间列');
|
||||
assert.doesNotMatch(sql, /modified/u, 'modified 每天凌晨被批量刷新,不可用');
|
||||
assert.doesNotMatch(sql, /pay_amount|jst_salesOutboundInfo/u, '不许补金额,也不再查出库单表');
|
||||
|
||||
// 补创建时间:必须走两张订单表;6 个窄列 + 1 个「发货前售后」布尔(不把 labels 原文传回来)
|
||||
assert.ok(calls.backfill >= 1, '必须回订单表补创建时间与作废事实');
|
||||
for (const bs of calls.backfillSql) {
|
||||
assert.match(bs,
|
||||
/SELECT o_id, created, status, send_date, is_merge, is_split,\s+CASE WHEN labels LIKE N'%发货前售后%' THEN 1 ELSE 0 END AS presaleVoid\s+FROM\s+dbo\.(jst_Orders|jst_qmcust_orderlist)/u,
|
||||
`补订单事实只允许 6 个窄列 + 1 个作废布尔:${bs.slice(0, 130)}`);
|
||||
assert.doesNotMatch(bs, /pay_amount|buyer_message|remark|SELECT \*/u,
|
||||
'多取一列就会把 0.2s 拖到 38s');
|
||||
// labels 只允许出现在 `LIKE` 里转成布尔,不许直接投影原文
|
||||
assert.doesNotMatch(bs, /\blabels\s*(,|AS\b)/u, 'labels 长文本不许直接投影,只允许 CASE WHEN ... LIKE 转布尔');
|
||||
}
|
||||
|
||||
// ⚠️ 窗口参数走 params,不在 SQL 里手写
|
||||
assert.equal(calls.params[0].windowStart, '2026-09-20 00:00:00', '下界是近 30 天');
|
||||
assert.equal(calls.params[0].cutoff, undefined, '24h 下推参数已删除');
|
||||
assert.equal(calls.params[0].shipStatus, '发货中');
|
||||
|
||||
// round 必须带出 windowEnd,否则页面渲染成「核验 前」
|
||||
assert.equal(data.round.key, 'AFTERNOON');
|
||||
assert.match(String(data.round.windowEnd), /16:00:00$/u);
|
||||
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判,要透出给前端');
|
||||
assert.equal(data.window.measuredBy, 'audit', '已等时长按审核时间算');
|
||||
}));
|
||||
|
||||
test('13:00 轮:只核验「今天 00:00~07:00 审核」的单 + 历史未发', withStubbedService(async (svc) => {
|
||||
const data = await svc.getShipTimeliness({ now: at(13, 5), limit: 10 });
|
||||
assert.equal(data.round.key, 'NOON');
|
||||
assert.equal(data.round.windowEnd, '2026-10-20 07:00:00');
|
||||
|
||||
// 21 号:今天 07:20 审核 → 晚于 7 点,**不属于 13:00 轮**(业务口径:7 点前审核的才 13:00 核验)
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '21'),
|
||||
'今天 07:20 审核 → 不是 7 点前审核的单,13:00 轮不该出现');
|
||||
// 24 号:今天 07:40 审核 → 同理不核验
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '24'),
|
||||
'今天 07:40 审核 → 不进 13:00 轮');
|
||||
// 26 号:今天 13:10 才审核(9818476 那种形态)→ 更不能进 13:00 轮
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '26'),
|
||||
'下午才审核的老单不能进 13:00 轮');
|
||||
|
||||
// 22 号:今天 06:40 审核(6:30~7:00 这段)→ 13:00 只等 380 分钟,必须超时(无 24h 门槛)
|
||||
const b = data.orders.find((o) => String(o.oId) === '22');
|
||||
assert.ok(b, '6:30~7:00 审核、13:00 未发就是超时');
|
||||
assert.equal(b.benchmarkSource, 'last_audit');
|
||||
assert.equal(b.overdueMinutes, 380);
|
||||
assert.equal(b.waitedHours, 6.3);
|
||||
// 23 号:昨天审核、至今未发 → 历史未发池也要看到
|
||||
const d = data.orders.find((o) => String(o.oId) === '23');
|
||||
assert.ok(d, '含历史未发单(历史不受 00:00 起限制)');
|
||||
assert.equal(d.overdueMinutes, 1010);
|
||||
|
||||
// 25 号:最后一次审核 10-20 13:30 → 不属于「7 点前审核」,13:00 轮不核验
|
||||
assert.ok(!data.orders.some((o) => String(o.oId) === '25'),
|
||||
'重审到 13:30 的单归 18:00 轮,不进 13:00 轮');
|
||||
|
||||
// 13:00 轮 = 22(今天 06:40 审核)+ 23(昨天 20:10 审核,历史未发)= 2 单
|
||||
assert.equal(data.summary.overdueUnshipped, 2);
|
||||
assert.equal(data.window.boundary, 'audit', '窗口按审核时间判');
|
||||
// 仓级例外只在 18:00 轮生效,13:00 轮没有例外
|
||||
assert.equal(data.window.overrides.length, 0, '13:00 轮统一 07:00,无仓级例外');
|
||||
}, 'noon'));
|
||||
|
||||
test('服务层 13:00 前返回 notStarted,且不去查状态表', withStubbedService(async (svc, { calls }) => {
|
||||
const data = await svc.getShipTimeliness({ now: at(9, 30), limit: 10 });
|
||||
assert.equal(data.notStarted, true);
|
||||
assert.equal(data.round, null);
|
||||
assert.equal(data.orders.length, 0);
|
||||
assert.equal(data.summary.overdueUnshipped, 0);
|
||||
// 11 个主仓仍然列出来(结构不能塌),但数字全为 0
|
||||
assert.equal(data.centers.length, registry.CENTERS.length);
|
||||
assert.ok(data.centers.every((x) => x.overdueUnshipped === 0));
|
||||
assert.equal(calls.state, 0, '未到核验时间不应查库');
|
||||
assert.equal(calls.backfill, 0, '未到核验时间也不该回订单表');
|
||||
assert.match(data.note, /13:00/u);
|
||||
assert.equal(data.nextRoundAt, '2026-10-20 13:00:00');
|
||||
assert.equal(data.nextWindowEnd, '2026-10-20 07:00:00');
|
||||
}));
|
||||
|
||||
// ── ⑧ 时区:生产容器是 UTC,库里时间是北京时间 ──────────────────────────
|
||||
// 2026-10-21 实测:容器 /etc/timezone=Etc/UTC,mssql useUTC:false 把 GETDATE()
|
||||
// 构造成「墙钟当 UTC」的 Date → 比真实 now 快 8 小时。北京时间 00:00~08:00
|
||||
// 服务端取到的是「昨天」,13:00 轮因此把整个历史未发池算进来(3895 单 vs 本地 6 单)。
|
||||
test('beijingNow:任何机器时区下读出来的本地时间都等于北京时间', () => {
|
||||
// 真实时刻 2026-10-21T00:04:00Z = 北京时间 2026-10-21 08:04
|
||||
const base = new Date('2026-10-21T00:04:00Z');
|
||||
const bj = registry.beijingNow(base);
|
||||
const stamp = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
|
||||
+ `-${String(d.getDate()).padStart(2, '0')} `
|
||||
+ `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
assert.equal(stamp(bj), '2026-10-21 08:04', '平移后本地 getter 必须读出北京时间');
|
||||
|
||||
// 跨日场景:真实时刻 2026-10-20T23:48:00Z = 北京时间 2026-10-21 07:48(UTC 上还是「昨天」)
|
||||
const midnight = registry.beijingNow(new Date('2026-10-20T23:48:00Z'));
|
||||
assert.equal(stamp(midnight).slice(0, 10), '2026-10-21',
|
||||
'北京时间 00:00~08:00 不能取成前一天');
|
||||
});
|
||||
|
||||
test('resolveActiveRound 默认按北京时间取「今天」,不依赖容器时区', () => {
|
||||
// 北京时间 2026-10-21 07:48(UTC 上是 10-20 23:48)→ 还没到 13:00,应当 notStarted
|
||||
const round = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-20T23:48:00Z')));
|
||||
assert.equal(round, null, '北京时间 07:48 未到 13:00 首轮核验');
|
||||
// 北京时间 2026-10-21 13:30 → 13:00 轮生效,窗口上界 07:00、核验 13:00
|
||||
const noon = registry.resolveActiveRound(registry.beijingNow(new Date('2026-10-21T05:30:00Z')));
|
||||
assert.equal(noon.key, 'NOON');
|
||||
assert.equal(noon.date, '2026-10-21');
|
||||
assert.equal(noon.deadline, '2026-10-21 13:00:00');
|
||||
assert.equal(noon.windowEnd, '2026-10-21 07:00:00');
|
||||
});
|
||||
@ -1,101 +0,0 @@
|
||||
# 业务库补表需求:每日进销存流水
|
||||
|
||||
> 提给:DBA / 聚水潭数据同步负责人
|
||||
> 提出方:仓库系统(cangku)· 大货库存监控 → 每日进销存
|
||||
> 日期:2026-09-24
|
||||
|
||||
## 背景
|
||||
|
||||
「每日进销存」页面需要按仓库展示 26 个仓的日内进销存流水,表头 21 列:
|
||||
|
||||
| # | 列 | # | 列 |
|
||||
|---|---|---|---|
|
||||
| 1 | 仓库 | 12 | 销售退货 |
|
||||
| 2 | 期初数量 | 13 | 调拨入 |
|
||||
| 3 | 加工进仓 | 14 | 期初(2) |
|
||||
| 4 | 采购退货 | 15 | 其它出库 |
|
||||
| 5 | 采购进仓 | 16 | 期末数量 |
|
||||
| 6 | 其它进仓 | 17 | 库存周转天数 |
|
||||
| 7 | 其它退货 | 18 | 滞销预警级库存量 |
|
||||
| 8 | 销售出仓 | 19 | 商品慢流级库存量 |
|
||||
| 9 | 盘点 | 20 | 商品呆滞级库存量 |
|
||||
| 10 | 加工出仓 | 21 | 商品僵尸级库存量 |
|
||||
| 11 | 调拨出 | | |
|
||||
|
||||
其中 1、16~21 列已能从业务库算出(见下表「已具备」),**2~15 列目前无法从业务库还原**。
|
||||
|
||||
## 已具备(无需补表)
|
||||
|
||||
| 列 | 来源 | 说明 |
|
||||
|---|---|---|
|
||||
| 仓库 | `rpt.jst_warehouseInfo` | `wms_co_id` 与仓库名映射 |
|
||||
| 期末数量 | `rpt.jst_inventoryInfo` | 按 `wms_co_id` 单仓 `SUM(qty)`,实时 |
|
||||
| 库存周转天数 | 近 30 天发货 ÷ 实时库存 | `jst_OrderItem` × `jst_Orders`(`send_date`) |
|
||||
| 四级库存量 | 实时库存 × 发货 × `pm_stockMonitor.created` | 库龄 = 基准时刻 − SKU 建档时间 |
|
||||
|
||||
## 缺口一:没有按天的历史库存结存表(阻塞「期初数量」)
|
||||
|
||||
`rpt.jst_inventoryInfo` 只有**当前**库存,没有历史版本;`rpt.pm_stockMonitor` 是「归属仓」口径的每日快照(`jsStock`),与实物库存口径不一致(崔桥 115135 vs 实物 92439),不能当结存用。
|
||||
|
||||
**现状的临时方案**:项目本地 MySQL 建了 `daily_inventory_snapshot` 表,每天 01:30 把 26 仓实时库存落一份快照,`今日期初 = 昨日快照`。这是自采数据,一旦任务漏跑就会断链。
|
||||
|
||||
**请求**:请提供按天、按仓、按 SKU 的库存结存表,形如:
|
||||
|
||||
```
|
||||
jst_inventoryDaily(
|
||||
biz_date DATE, -- 业务日
|
||||
wms_co_id BIGINT, -- 仓库
|
||||
sku_id VARCHAR, -- SKU
|
||||
qty DECIMAL, -- 当日结存
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
有了它,「期初数量」即可直接取上一业务日结存,本地快照表可退役。
|
||||
|
||||
## 缺口二:缺少 4 类单据表(阻塞 12 个流水列)
|
||||
|
||||
业务库 `rpt` 下已排查过全部 220 张表,**不存在**以下单据:
|
||||
|
||||
| 缺失单据 | 影响的列 |
|
||||
|---|---|
|
||||
| 调拨单(调拨出 / 调拨入) | 调拨出、调拨入 |
|
||||
| 盘点单 | 盘点 |
|
||||
| 加工单(加工进仓 / 加工出仓) | 加工进仓、加工出仓 |
|
||||
| 其它出入库单 | 其它进仓、其它退货、其它出库、期初(2) |
|
||||
|
||||
请补充按单据行(SKU 级)的明细表,至少包含:
|
||||
|
||||
```
|
||||
业务日期 / 单据号 / 仓库 wms_co_id / sku_id / 数量 / 单据类型(出|入) / 状态
|
||||
```
|
||||
|
||||
## 缺口三:已有单据的数值与进销存口径不一致(阻塞 销售出仓 / 采购进仓 / 采购退货 / 销售退货)
|
||||
|
||||
这 4 类单据表**存在**,但按天汇总后与聚水潭进销存导出的数值对不上,不能直接用:
|
||||
|
||||
| 列 | 现有来源 | 9-23 崔桥工厂仓实测 | 基准值 | 偏差 |
|
||||
|---|---|---:|---:|---|
|
||||
| 销售出仓 | `jst_OrderItem` × `jst_Orders` 按 `send_date` | 1,769 | 4,273 | **−59%** |
|
||||
| 销售出仓 | 同上,按 `order_date` | 1,723 | 4,273 | −60% |
|
||||
| 销售出仓 | 同上,按 `modified` | 5,471 | 4,273 | +28% |
|
||||
| 采购进仓 | `jst_purchaseItem.inQty` × `jst_purchase` 按 `confirm_date` | 数百 | 1,295 | 严重偏低 |
|
||||
|
||||
其它已排除的候选:
|
||||
|
||||
- `rpt.jst_salesOutboundInfo`:`qty` 全为 0,且 `order_type` 只含销售类订单,不含调拨 / 其它出库。
|
||||
- `rpt.jst_originalBillInfo`(1,500 万行):是支付宝 / 财务账单表(`channel=alipayCSV`、`fee_name=交易收款`、`sku_id` 为 null),与库存流水无关。
|
||||
- `rpt.jst_qmcust_salesoutbound`(奇门出库单):非本业务口径。
|
||||
|
||||
**请求**:确认这 4 类表在报表库(JSReportReplica)的同步是否完整、以及「进销存导出」所用的日期字段是哪一个(发 / 审 / 确 / 记账)。给出字段口径后这 4 列即可接入。
|
||||
|
||||
## 验收标准
|
||||
|
||||
补表后,任一业务日应满足行内恒等式:
|
||||
|
||||
```
|
||||
期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入
|
||||
− 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库
|
||||
```
|
||||
|
||||
页面会以该恒等式做逐仓自校验,全部通过后才会撤下「暂缺单据来源」的提示。
|
||||
@ -7,9 +7,8 @@
|
||||
"test": "node --test test/*.test.mjs",
|
||||
"prebuild": "node ./scripts/write-build-version.mjs",
|
||||
"dev": "node ./scripts/write-build-version.mjs && vite --configLoader native",
|
||||
"build": "node ./scripts/verify-router-lazy.mjs && vite build && node ./scripts/verify-planning-entry.mjs",
|
||||
"build": "vite build && node ./scripts/verify-planning-entry.mjs",
|
||||
"check": "npm run lint && npm test && npm run build",
|
||||
"verify:router-lazy": "node ./scripts/verify-router-lazy.mjs",
|
||||
"verify:public-build": "node ./scripts/verify-public-build.mjs",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
|
||||
@ -1,70 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
/**
|
||||
* 校验路由表里每个 renderLazyComponent(X) 用到的组件 X 都在同文件里
|
||||
* 用 `const X = lazy(() => import(...))` 声明过。
|
||||
*
|
||||
* 为什么要这个检查(2026-10-22 生产事故):
|
||||
* PR #74 新增「缺货补货监控」时,router/index.jsx 里写了
|
||||
* `element: renderLazyComponent(BinReplenishMonitor)` 却漏了 lazy 声明。
|
||||
* 这行在模块顶层执行 → 加载即抛 `ReferenceError: BinReplenishMonitor is not defined`
|
||||
* → 整个 React 应用挂掉、**全站白屏,连登录页都渲染不出来**。
|
||||
* 而 `vite build` 完全能构建成功(未声明标识符在 JS 里是合法语法),
|
||||
* 所以必须在构建期用静态扫描拦住。
|
||||
*/
|
||||
export async function verifyRouterLazy(routerPath = path.resolve(process.cwd(), 'src/router/index.jsx')) {
|
||||
const source = await readFile(routerPath, 'utf8');
|
||||
|
||||
const used = new Set();
|
||||
for (const match of source.matchAll(/renderLazyComponent\s*\(\s*([A-Za-z_$][\w$]*)/g)) {
|
||||
used.add(match[1]);
|
||||
}
|
||||
|
||||
const declared = new Set();
|
||||
for (const match of source.matchAll(/const\s+([A-Za-z_$][\w$]*)\s*=\s*lazy\s*\(/g)) {
|
||||
declared.add(match[1]);
|
||||
}
|
||||
|
||||
const missing = [...used].filter((name) => !declared.has(name)).sort();
|
||||
|
||||
if (missing.length > 0) {
|
||||
const lines = missing.map((name) => {
|
||||
const idx = source.indexOf(`renderLazyComponent(${name}`);
|
||||
const line = idx === -1 ? '?' : source.slice(0, idx).split('\n').length;
|
||||
return ` - ${name} (router/index.jsx:${line} 使用,缺少 const ${name} = lazy(() => import('...')) 声明)`;
|
||||
});
|
||||
throw new Error(
|
||||
'router/index.jsx 有组件被 renderLazyComponent 使用但未声明为 lazy,' +
|
||||
'这会导致运行时 ReferenceError 并让整站白屏:\n' +
|
||||
lines.join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
return { routerPath, used: [...used].sort(), declared: [...declared].sort() };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const routerArg = process.argv[2];
|
||||
const routerPath = routerArg
|
||||
? path.resolve(process.cwd(), routerArg)
|
||||
: path.resolve(process.cwd(), 'src/router/index.jsx');
|
||||
const result = await verifyRouterLazy(routerPath);
|
||||
process.stdout.write(
|
||||
`Verified router lazy declarations: ${result.used.length} used, ${result.declared.length} declared ` +
|
||||
`(${path.relative(process.cwd(), result.routerPath)})\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const isDirectExecution = process.argv[1]
|
||||
? pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url
|
||||
: false;
|
||||
|
||||
if (isDirectExecution) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// 缺货补货监控(订单驱动版)
|
||||
// 判断口径:当日订单量 > 拣货位库存 的超出部分 = 需从存储位补到拣货位的量
|
||||
export const getBinReplenishList = (params = {}) => request.get(
|
||||
'/api/bin-replenish/list',
|
||||
{ params, timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const exportBinReplenish = (params = {}) => request.get(
|
||||
'/api/bin-replenish/export',
|
||||
{
|
||||
params,
|
||||
responseType: 'blob',
|
||||
timeout: LONG_TIMEOUT_MS,
|
||||
silentError: true,
|
||||
}
|
||||
);
|
||||
@ -1,29 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const IMPORT_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
|
||||
// 每日进销存:进销存流水 + 实时期末 + 分级库存量
|
||||
// refresh=1 跳过后端 5 分钟缓存,强制重算(一次要扫订单明细,约 15~20s)
|
||||
// date=YYYY-MM-DD 查看指定业务日(默认取业务库最新业务日)
|
||||
export const getDailyInventory = (params = {}) => request.get(
|
||||
'/api/daily-inventory',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导入聚水潭「进销存按仓导出」xlsx:file 为 File 对象,date 为业务日 YYYY-MM-DD
|
||||
export const importDailyInventoryOfficial = ({ file, date }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('date', date);
|
||||
return request.post('/api/daily-inventory/official-import', formData, {
|
||||
timeout: IMPORT_TIMEOUT_MS,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
};
|
||||
|
||||
// 查询某业务日已导入的官方进销存数据
|
||||
export const getDailyInventoryOfficial = (date) => request.get(
|
||||
'/api/daily-inventory/official',
|
||||
{ params: { date } }
|
||||
);
|
||||
@ -1,17 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
// 每日进销存 · 定时截图通知(管理员专用)
|
||||
// 后端只返回「是否已配置」与脱敏预览,不返回任何密钥
|
||||
|
||||
export const getDailyInventoryNotificationConfig = () =>
|
||||
request.get('/api/daily-inventory-notification/config');
|
||||
|
||||
export const saveDailyInventoryNotificationConfig = (payload) =>
|
||||
request.put('/api/daily-inventory-notification/config', payload);
|
||||
|
||||
/** 立即测试:真实截图 + 上传 + 通知一次,不影响正式定时任务的幂等记录 */
|
||||
export const runDailyInventoryNotificationTest = () =>
|
||||
request.post('/api/daily-inventory-notification/test', {}, { timeout: 300000 });
|
||||
|
||||
export const getDailyInventoryNotificationRuns = (limit = 10) =>
|
||||
request.get('/api/daily-inventory-notification/runs', { params: { limit } });
|
||||
@ -7,11 +7,7 @@ const EXPORT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
// MKH-BOM 资料列表(模块化组合商品 + 配件汇总)
|
||||
export const getMkhBomList = (params = {}) => request.get(
|
||||
'/api/mkh-bom/list',
|
||||
{
|
||||
params,
|
||||
timeout: LIST_TIMEOUT_MS,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
}
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用)
|
||||
@ -25,7 +21,7 @@ export const getMkhBomDetail = (skuId) => request.get(
|
||||
// ⚠️ 「全部」范围下官方 BOM 明细要分批拉取,耗时明显高于列表接口,故单独放大超时
|
||||
export const getMkhBomExportRows = (params = {}) => request.get(
|
||||
'/api/mkh-bom/export',
|
||||
{ params: { ...params, live: 1 }, timeout: EXPORT_TIMEOUT_MS }
|
||||
{ params, timeout: EXPORT_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 补货预警推送设置(接收人 / 开关 / 推送范围)
|
||||
@ -50,9 +46,10 @@ export const saveMkhNotificationCenter = (payload = {}) => request.put(
|
||||
payload
|
||||
);
|
||||
|
||||
// 手动同步:服务端清空全部 MKH 缓存,并在同一次请求中返回当前筛选条件的数据库新快照
|
||||
export const syncMkhBom = (query = {}) => request.post(
|
||||
// 手动同步:清空服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
|
||||
// 随后重新拉列表即直连副本库取最新快照,可随时点击
|
||||
export const syncMkhBom = () => request.post(
|
||||
'/api/mkh-bom/sync',
|
||||
{ query },
|
||||
null,
|
||||
{ timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
// 拣选库位动销分析:仓库 × 仓位 → 类型 / 库存数量 / SKU数 / 近N天销量 / 日销 / 动销等级
|
||||
// refresh=1 跳过后端 5 分钟缓存,强制重算
|
||||
export const getPickingBinSalesAnalysis = (params = {}) => request.get(
|
||||
'/api/picking-bin-sales-analysis',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出库位明细(xlsx/csv,服务端直接返回文件流)
|
||||
export const exportPickingBinSalesAnalysis = (params = {}) => request.get(
|
||||
'/api/picking-bin-sales-analysis/export',
|
||||
{ params, responseType: 'blob', timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
@ -14,56 +14,6 @@ const request = axios.create({
|
||||
});
|
||||
const AUTH_USER_STORAGE_KEY = 'auth_user';
|
||||
|
||||
// 后端重启窗口内的自动重连
|
||||
// ------------------------------------------------------------------
|
||||
// 本地/线上后端在「隧道掉线 -> 监督器重启后端」期间会有 20~90 秒不可用,
|
||||
// 此时 Vite 代理拿不到后端,会直接回 500(响应体是 HTML,没有 error 字段),
|
||||
// 用户看到的就是一句没头没尾的 "Request failed with status code 500"。
|
||||
// 这里对**幂等请求**(GET/HEAD/OPTIONS)在网络错误或 5xx 时做退避重试,
|
||||
// 重启窗口过去后请求会自己成功,页面不再报错。
|
||||
// 累计等待约 80 秒,覆盖最坏情况下的后端启动耗时。
|
||||
const RETRY_BACKOFF_MS = [1500, 3000, 5000, 8000, 13000, 21000, 30000];
|
||||
const RETRYABLE_METHODS = ['get', 'head', 'options'];
|
||||
const RETRY_NOTICE_KEY = 'api-reconnect-notice';
|
||||
|
||||
function isRetryableRequest(config) {
|
||||
if (!config || config.retry === false) {
|
||||
return false;
|
||||
}
|
||||
const method = String(config.method || 'get').toLowerCase();
|
||||
return RETRYABLE_METHODS.includes(method);
|
||||
}
|
||||
|
||||
function isRetryableError(error) {
|
||||
if (axios.isCancel?.(error) || error?.code === 'ERR_CANCELED') {
|
||||
return false;
|
||||
}
|
||||
const status = error?.response?.status;
|
||||
// 无响应:网络中断、连接被拒(后端还没起来)、代理转发失败
|
||||
if (!status) {
|
||||
return true;
|
||||
}
|
||||
// 5xx:后端内部错误 / 网关与代理错误
|
||||
return status >= 500 && status <= 599;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
function notifyReconnecting() {
|
||||
message.open({
|
||||
key: RETRY_NOTICE_KEY,
|
||||
type: 'loading',
|
||||
content: '服务正在重启,正在自动重连…',
|
||||
duration: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function clearReconnectNotice() {
|
||||
message.destroy(RETRY_NOTICE_KEY);
|
||||
}
|
||||
|
||||
function clearAuthSession() {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem(AUTH_USER_STORAGE_KEY);
|
||||
@ -144,34 +94,11 @@ request.interceptors.response.use(
|
||||
}
|
||||
return Promise.reject(new Error(res.message || 'Error'));
|
||||
}
|
||||
clearReconnectNotice();
|
||||
return res;
|
||||
},
|
||||
async (error) => {
|
||||
const config = error?.config;
|
||||
|
||||
// ---- 后端重启窗口内自动重试(只对幂等请求、只对网络错误/5xx)----
|
||||
// 说明:这里用 `return request(config)`(不带 await)而不是 await,
|
||||
// 这样重试失败时由「内层」这一分支继续退避;外层调用栈不会重新进入本函数,
|
||||
// 因此最终失败提示天然只会出现一次,也不会把重试次数叠加错。
|
||||
if (isRetryableRequest(config) && !config.__retryExhausted && isRetryableError(error)) {
|
||||
const attempt = Number(config.__retryAttempt || 0);
|
||||
if (attempt < RETRY_BACKOFF_MS.length) {
|
||||
config.__retryAttempt = attempt + 1;
|
||||
if (attempt === 0 && !config.silentError) {
|
||||
notifyReconnecting();
|
||||
}
|
||||
await sleep(RETRY_BACKOFF_MS[attempt]);
|
||||
return request(config);
|
||||
}
|
||||
// 次数用尽:标记后不再重试,并把连接提示收掉
|
||||
config.__retryExhausted = true;
|
||||
config.__retryAttempt = 0;
|
||||
clearReconnectNotice();
|
||||
}
|
||||
|
||||
(error) => {
|
||||
const authSessionFailure = isAuthSessionFailure(error.response);
|
||||
if (!authSessionFailure && !error?.config?.silentError) {
|
||||
if (!authSessionFailure && !error.config?.silentError) {
|
||||
message.error(getRequestErrorMessage(error));
|
||||
}
|
||||
if (authSessionFailure && !shouldBypass401Redirect(error)) {
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// 崔桥工厂仓客退:退货入库核销 + 每日库存监控看板
|
||||
export const getReturnMonitorDashboard = (params = {}) => request.get(
|
||||
'/api/return-monitor/dashboard',
|
||||
{ params, timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 手动触发一轮「同步 + 每日比对」
|
||||
export const syncReturnMonitor = (payload = {}) => request.post(
|
||||
'/api/return-monitor/sync',
|
||||
payload,
|
||||
{ timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 某商品编码当前的全部在库入库行(差异清单「手动指定」勾选用)
|
||||
export const getReturnMonitorCodeLines = (params = {}) => request.get(
|
||||
'/api/return-monitor/code-lines',
|
||||
{ params, timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 差异清单处理:mode = fifo | manual
|
||||
export const resolveReturnMonitorDiscrepancy = (payload = {}) => request.post(
|
||||
'/api/return-monitor/discrepancy/resolve',
|
||||
payload,
|
||||
{ timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 人工标记单行状态:在库 / 已出库 / 已报废
|
||||
export const markReturnMonitorInbound = (payload = {}) => request.post(
|
||||
'/api/return-monitor/inbound/mark',
|
||||
payload,
|
||||
{ timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出看板数据(scope=all|detail|overdue|code|discrepancy|age,返回 xlsx / csv 二进制)
|
||||
// silentError:错误体是 Blob,由调用方用 readBlobErrorMessage 解出可读文案后再提示
|
||||
export const exportReturnMonitor = (params = {}) => request.get(
|
||||
'/api/return-monitor/export',
|
||||
{
|
||||
params,
|
||||
responseType: 'blob',
|
||||
timeout: LONG_TIMEOUT_MS,
|
||||
silentError: true,
|
||||
}
|
||||
);
|
||||
@ -1,39 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LONG_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// 客退数据监控(箱及仓位库存 · 客退临时上架仓)
|
||||
// params.warehouse: cuiqiao / subei / suining / shandong / henan(缺省 = 崔桥工厂仓)
|
||||
export const getReturnStockWarningList = (params = {}) => request.get(
|
||||
'/api/return-stock-warning/list',
|
||||
{ params, timeout: LONG_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出(xlsx / csv,服务端全量生成,返回二进制)
|
||||
export const exportReturnStockWarning = (params = {}) => request.get(
|
||||
'/api/return-stock-warning/export',
|
||||
{
|
||||
params,
|
||||
responseType: 'blob',
|
||||
timeout: LONG_TIMEOUT_MS,
|
||||
silentError: true,
|
||||
}
|
||||
);
|
||||
|
||||
// 导入聚水潭「箱及仓位库存」导出文件(xlsx / xls / csv)
|
||||
// warehouse: 导入落到哪个仓库下,各仓的导入互不覆盖
|
||||
export const importReturnStockWarning = (file, warehouse) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (warehouse) formData.append('warehouse', warehouse);
|
||||
return request.post('/api/return-stock-warning/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: LONG_TIMEOUT_MS,
|
||||
});
|
||||
};
|
||||
|
||||
// 清除当前仓库的导入,回到「系统推算」口径(不影响其它仓)
|
||||
export const clearReturnStockWarningImport = (warehouse) => request.delete(
|
||||
'/api/return-stock-warning/import',
|
||||
{ params: warehouse ? { warehouse } : {} }
|
||||
);
|
||||
@ -1,16 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
// 仓库库存动销分析:仓库 × 动销等级 → SKU数量 / SKU占比 / 库存数量 / 库存占比 / 销量
|
||||
// refresh=1 跳过后端 5 分钟缓存,强制重算
|
||||
export const getStockSalesAnalysis = (params = {}) => request.get(
|
||||
'/api/stock-sales-analysis',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出「销售=0」档的 SKU 级明细(xlsx/csv,服务端直接返回文件流)
|
||||
export const exportStockSalesZeroDetail = (params = {}) => request.get(
|
||||
'/api/stock-sales-analysis/zero-sales-export',
|
||||
{ params, responseType: 'blob', timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
@ -1,59 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
// 转运异常要扫一遍「昨日发货」的物流宽表(rpt 下全是视图),冷启动实测 20 秒左右,
|
||||
// 超时放大到 3 分钟,避免前端先断开、后端还在跑。
|
||||
const TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
// 仓储中心物流转运异常:12 个发货仓的异常单量 + 明细
|
||||
// params.date 核验日 'YYYY-MM-DD'(默认:已过 09:00 用今天,否则用昨天)
|
||||
// params.limit 明细条数上限
|
||||
// params.force 1 = 绕过 5 分钟结果缓存
|
||||
export const getWarehouseLogisticsExceptions = (params = {}) => request.get(
|
||||
'/api/warehouse-logistics-exception/list',
|
||||
{
|
||||
params,
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
}
|
||||
);
|
||||
|
||||
// 口径元数据(12 个仓、异常类型、回看天数),不打库,秒回
|
||||
export const getWarehouseLogisticsExceptionMeta = () => request.get(
|
||||
'/api/warehouse-logistics-exception/meta',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// 导出用:同一套口径、不分页(明细上限放到 5000),比列表更慢,超时再放大
|
||||
export const getWarehouseLogisticsExceptionExportRows = (params = {}) => request.get(
|
||||
'/api/warehouse-logistics-exception/export',
|
||||
{
|
||||
params,
|
||||
timeout: 5 * 60 * 1000,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
}
|
||||
);
|
||||
|
||||
// ── 每天定点推文本到钉钉群 ────────────────────────────────────────────────
|
||||
// 通道 = 群自定义机器人 webhook(不发 .xlsx,只发 text;加签密钥可选)
|
||||
// 读配置:加签密钥只回掩码(webhookSecretConfigured + webhookSecretPreview)
|
||||
export const getWarehouseLogisticsExceptionNotifySettings = () => request.get(
|
||||
'/api/warehouse-logistics-exception/notify/settings',
|
||||
{ timeout: 30000, headers: { 'Cache-Control': 'no-cache' } }
|
||||
);
|
||||
|
||||
// 保存配置:加签密钥留空 = 沿用已存的,不必每次重填
|
||||
export const saveWarehouseLogisticsExceptionNotifySettings = (payload = {}) => request.put(
|
||||
'/api/warehouse-logistics-exception/notify/settings',
|
||||
payload,
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
|
||||
// 立即推送 / 预览正文 / 测试通道
|
||||
// dryRun=true 只组装消息不发送(返回正文全文)
|
||||
// probe=true 只发一句通道测试,不查业务数据
|
||||
// 冷启动要全量扫一遍物流宽表(生产实测 3~4 分钟),超时给足
|
||||
export const runWarehouseLogisticsExceptionNotify = (payload = {}) => request.post(
|
||||
'/api/warehouse-logistics-exception/notify/run',
|
||||
payload,
|
||||
{ timeout: 10 * 60 * 1000 }
|
||||
);
|
||||
@ -1,23 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
// 发货时效要扫两张订单表 + 出库表(rpt 下全是视图),冷启动实测 40~50 秒,
|
||||
// 超时必须放大到 3 分钟,否则前端会先断开、后端还在跑。
|
||||
const TIMELINESS_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
// 仓储中心 24 小时发货时效:每个主仓「超时未发」数量 + 超时订单明细
|
||||
// params.round 'NOON' | 'AFTERNOON'(不传按当前时刻自动判定)
|
||||
// params.limit 明细条数上限
|
||||
export const getWarehouseShipTimeliness = (params = {}) => request.get(
|
||||
'/api/warehouse-ship-timeliness/list',
|
||||
{
|
||||
params,
|
||||
timeout: TIMELINESS_TIMEOUT_MS,
|
||||
headers: { 'Cache-Control': 'no-cache' },
|
||||
}
|
||||
);
|
||||
|
||||
// 仅取今日两轮核验的描述(不打订单表,秒回),用于顶部提示
|
||||
export const getWarehouseShipTimelinessRounds = () => request.get(
|
||||
'/api/warehouse-ship-timeliness/rounds',
|
||||
{ timeout: 30000 }
|
||||
);
|
||||
@ -18,18 +18,7 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
{ key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'returns-inventory', type: 'group', label: '退货库存监控', icon: 'inbox', visible: true, order: 75, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'returns-inventory-cuiqiao', label: '崔桥工厂仓客退数据监控', icon: 'table', routeKey: 'returns-inventory-cuiqiao', visible: true, order: 10 },
|
||||
{ key: 'returns-inventory-subei', label: '苏北客退数据监控', icon: 'table', routeKey: 'returns-inventory-subei', visible: true, order: 20 },
|
||||
{ key: 'returns-inventory-suining', label: '睢宁客退数据监控', icon: 'table', routeKey: 'returns-inventory-suining', visible: true, order: 30 },
|
||||
{ key: 'returns-inventory-shandong', label: '山东客退数据监控', icon: 'table', routeKey: 'returns-inventory-shandong', visible: true, order: 40 },
|
||||
{ key: 'returns-inventory-henan', label: '河南客退数据监控', icon: 'table', routeKey: 'returns-inventory-henan', visible: true, order: 50 },
|
||||
],
|
||||
},
|
||||
{ key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 },
|
||||
{ key: 'bin-replenish-monitor', type: 'item', label: '缺货补货监控', icon: 'inbox', routeKey: 'bin-replenish-monitor', visible: true, order: 90 },
|
||||
{
|
||||
key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true,
|
||||
children: [
|
||||
@ -37,16 +26,6 @@ export const DEFAULT_SIDEBAR_CONFIG = {
|
||||
{ key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 },
|
||||
],
|
||||
},
|
||||
{ key: 'warehouse-ship-timeliness', type: 'item', label: '仓储中心24小时发货时效', icon: 'inbox', routeKey: 'warehouse-ship-timeliness', visible: true, order: 110 },
|
||||
{ key: 'warehouse-logistics-exception', type: 'item', label: '仓储中心物流转运异常', icon: 'warning', routeKey: 'warehouse-logistics-exception', visible: true, order: 111 },
|
||||
{
|
||||
key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true,
|
||||
children: [
|
||||
{ key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 },
|
||||
{ key: 'stock-sales-analysis', label: '库存动销分析', icon: 'line-chart', routeKey: 'stock-sales-analysis', visible: true, order: 20 },
|
||||
{ key: 'picking-bin-sales-analysis', label: '拣选库位动销分析', icon: 'inbox', routeKey: 'picking-bin-sales-analysis', visible: true, order: 30 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true,
|
||||
children: [
|
||||
|
||||
@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 每日进销存 —— 列定义
|
||||
*
|
||||
* 表头取自桌面《库存监控.xlsx》,顺序与表格一致。
|
||||
* source 标记该列数据来源,便于在页面上如实标注口径:
|
||||
* - snapshot:来自项目本地快照表 daily_inventory_snapshot(每日定时落库,自动滚动)
|
||||
* - runtime:运行时从业务库实时取数
|
||||
* - unavailable:业务库缺少对应单据表,暂无法计算(渲染为「—」)
|
||||
*/
|
||||
|
||||
export const COLUMN_SOURCE = {
|
||||
snapshot: 'snapshot',
|
||||
runtime: 'runtime',
|
||||
unavailable: 'unavailable',
|
||||
};
|
||||
|
||||
const NO_SOURCE_TIP = '业务库 JSReportReplica 缺少该单据表(调拨 / 盘点 / 加工 / 其它出入库),以聚水潭「进销存按仓导出」为准,导入后本列自动出数';
|
||||
const FLOW_CALIBER_NOTE = '口径说明:业务库只能还原该列的近似值(采购按单据日期、销售按订单发货日期),与聚水潭进销存口径存在差异,已导入官方数据时以官方为准';
|
||||
const OFFICIAL_TIP = '同一业务日导入过聚水潭进销存导出时,本列直接采用官方值(单元格为常规色);未导入时回退业务库推算(单元格标灰)';
|
||||
|
||||
/** 表格存在两个名为「期初」的列,第二个加后缀区分 */
|
||||
export const DAILY_INVENTORY_COLUMNS = [
|
||||
{ key: 'warehouse', label: '仓库', source: 'snapshot', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' },
|
||||
{ key: 'openingQty', label: '期初数量', source: 'snapshot', tip: `优先取聚水潭「进销存按仓导出」的官方期初;未导入时取本地快照表 daily_inventory_snapshot 中上一业务日的期末结存(每日 01:30 自动落库),快照缺失则自动用「实时库存扣流水」回推补写(回推值受业务库缺失的 8 类单据影响,与官方存在偏差)` },
|
||||
{ key: 'processIn', label: '加工进仓', source: 'unavailable', tip: `加工进仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
|
||||
{ key: 'purchaseReturn', label: '采购退货', source: 'runtime', tip: `采购退货(流出,负数)。rpt.jst_purchaseout × jst_purchaseoutItem,按 io_date 统计。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` },
|
||||
{ key: 'purchaseIn', label: '采购进仓', source: 'runtime', tip: `采购进仓(流入)。rpt.jst_purchase × jst_purchaseItem,按 po_date 统计,仅计 Confirmed/Finished 状态。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` },
|
||||
{ key: 'otherIn', label: '其它进仓', source: 'unavailable', tip: `其它进仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
|
||||
{ key: 'otherReturn', label: '其它退货', source: 'unavailable', tip: `其它退货。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
|
||||
{ key: 'salesOut', label: '销售出仓', source: 'runtime', tip: `销售出仓(流出,负数)。rpt.jst_Orders × jst_OrderItem,按 send_date 统计,排除 Delete/Cancelled,与「库存周转天数」的发货口径一致。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` },
|
||||
{ key: 'stocktaking', label: '盘点', source: 'unavailable', tip: `盘点。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
|
||||
{ key: 'processOut', label: '加工出仓', source: 'unavailable', tip: `加工出仓。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
|
||||
{ key: 'transferOut', label: '调拨出', source: 'unavailable', tip: `调拨出。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
|
||||
{ key: 'salesReturn', label: '销售退货', source: 'runtime', tip: `销售退货(流入)。rpt.jst_returnAndRefundInfo × jst_returnAndRefundItem,按 as_date 统计,类型限定普通退货/拒收退货/换货/维修,取明细实收数量 r_qty。${FLOW_CALIBER_NOTE} ${OFFICIAL_TIP}` },
|
||||
{ key: 'transferIn', label: '调拨入', source: 'unavailable', tip: `调拨入。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
|
||||
{ key: 'openingQtyExtra', label: '期初(2)', source: 'unavailable', tip: '聚水潭导出表中的第二个「期初」列,业务库无对应来源' },
|
||||
{ key: 'otherOut', label: '其它出库', source: 'unavailable', tip: `其它出库。${NO_SOURCE_TIP} ${OFFICIAL_TIP}` },
|
||||
{ key: 'closingQty', label: '期末数量', source: 'runtime', tip: `优先取聚水潭「进销存按仓导出」的官方期末;未导入时取实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(存货数量不为 0 的 SKU 汇总)` },
|
||||
{ key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%。月度发货总件数=近 30 天已发货订单件数(jst_OrderItem×jst_Orders,按 send_date,排除 Delete/Cancelled);月度日均库存件数=该仓实时库存(无历史结存表,以当日库存近似)' },
|
||||
{ key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月 且 周转率 30%~199% 的库存合计' },
|
||||
{ key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: 'SKU 级:库龄 < 3 个月且周转率 ≥ 200%,或库龄 > 3 个月且周转率 30%~99% 的库存合计' },
|
||||
{ key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: 'SKU 级:库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的库存合计' },
|
||||
{ key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: 'SKU 级:库龄 > 12 个月 且 周转率 ≥ 200% 的库存合计' },
|
||||
];
|
||||
|
||||
/** 口径说明弹框文案 */
|
||||
export const FORMULA_DESCRIPTION = [
|
||||
'数据来源说明(全部来自数据库或聚水潭官方导出,无人工填数):',
|
||||
'· 聚水潭官方进销存(优先):把聚水潭「进销存按仓导出」的 xlsx 在页面右上角导入后,',
|
||||
' 该业务日的期初、12 个流水列、期末直接采用官方值,恒等式「期初 + 流水 = 期末」逐仓闭合。',
|
||||
' 业务库 JSReportReplica 只能还原 4/12 个流水列,且口径与聚水潭不一致(采购按单据日期 vs 实际入库、',
|
||||
' 销售按订单发货日期 vs 出库单、jst_salesOutboundInfo.qty 全为 0),调拨 / 盘点 / 加工 / 其它出入库更没有对应单据表,',
|
||||
' 因此未导入官方数据时这些列显示「—」或为业务库近似值(单元格标灰)。',
|
||||
'· 期初数量:官方期初优先;未导入时取本地快照表 daily_inventory_snapshot 中「上一业务日」的期末结存。',
|
||||
' 业务库 JSReportReplica 没有按天的历史库存结存表,因此由本系统每天 01:30 把各仓实时库存落一份快照,',
|
||||
' 今日期初 = 昨日快照,逐日自动滚动;导入官方数据时会同步回写快照,让后续推算也对齐聚水潭口径。',
|
||||
'· 流水列(12 列):官方数据优先(导入后 12 列全部出数);未导入时业务库可还原 4 列 ——',
|
||||
' 采购进仓(jst_purchase×Item,po_date,Confirmed/Finished)、采购退货(jst_purchaseout×Item,io_date)、',
|
||||
' 销售出仓(jst_Orders×OrderItem,send_date,与周转率发货口径一致)、销售退货(jst_returnAndRefund×Item,as_date,实收 r_qty)。',
|
||||
' 其余 8 列(加工进仓 / 其它进仓 / 其它退货 / 盘点 / 加工出仓 / 调拨出 / 调拨入 / 其它出库)业务库无单据表,显示「—」。',
|
||||
'· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。',
|
||||
'· 库存周转天数 / 四级库存量:运行时计算,库龄基准时刻为快照日次日 01:00。',
|
||||
'',
|
||||
'算式:',
|
||||
'· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入',
|
||||
' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库',
|
||||
'· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%',
|
||||
' 月度发货总件数 = 该仓近 30 天已发货订单件数',
|
||||
' (jst_OrderItem 关联 jst_Orders,按订单发货仓 wms_co_id 归属,send_date 落在窗口内,排除 Delete / Cancelled)',
|
||||
' 月度日均库存件数 = 该仓聚水潭实时库存 jst_inventoryInfo 汇总',
|
||||
' (业务库无按天的历史库存结存表,无法算真正的日均库存,以当日库存近似月度日均)',
|
||||
' 库龄(天) = 基准时刻 − SKU 建档时间 created;3 个月按 90 天、12 个月按 365 天折算',
|
||||
'· 库存分级(SKU 级,库龄 + 周转率双维度,低于 30% 的不计入任何级别):',
|
||||
' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警',
|
||||
' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流',
|
||||
' 库龄 > 3 个月 且 周转率 30%~99% → 商品慢流',
|
||||
' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞',
|
||||
' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞',
|
||||
' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸',
|
||||
'',
|
||||
'注意:仓库整体周转率按「该仓发货总量 ÷ 该仓库存总量」计算,与四级分级的 SKU 级取舍不是同一个口径,',
|
||||
' 两者不必相等(例如整体周转率 6% 的仓里,仍可以有部分 SKU 周转率超 30% 而被判为预警级)。',
|
||||
];
|
||||
@ -1,91 +0,0 @@
|
||||
/** 拣选库位动销分析 —— 表头与口径说明(粒度:仓位 × 商品编码) */
|
||||
|
||||
export const PICKING_BIN_COLUMNS = [
|
||||
{
|
||||
key: 'warehouse',
|
||||
label: '仓库',
|
||||
tip: '聚水潭主仓(按 wms_co_id 单仓独立统计,不合并分仓)',
|
||||
},
|
||||
{
|
||||
key: 'binName',
|
||||
label: '仓位',
|
||||
tip: '系统仓位编码(jst_packInventoryInfo.bin);同一仓位有多个商品时会出现多行',
|
||||
},
|
||||
{
|
||||
key: 'binType',
|
||||
label: '类型',
|
||||
tip: '统计范围固定为真实拣选仓位(pack_type=Bin 且 bin_group 为空);箱记录和存货位不纳入动销分析',
|
||||
},
|
||||
{
|
||||
key: 'skuId',
|
||||
label: '商品编码',
|
||||
tip: '商品编码(jst_packInventoryInfo.sku_id),一行就是一个「仓位 × 商品」组合',
|
||||
},
|
||||
{
|
||||
key: 'stockQty',
|
||||
label: '库存数量',
|
||||
tip: '该商品在这个仓位上的当前库存件数(同一仓位多行的库存相加 = 仓位库存)',
|
||||
},
|
||||
{
|
||||
key: 'salesQty',
|
||||
label: '商品销量',
|
||||
tip: '近 N 天该商品的销量(整档,不按仓位分摊):含线上订单、组合装子件拆分、全民客户(B2B分销)渠道;同一商品占几个仓位,这几行显示的销量都一样,所以这一列不能直接按行求和',
|
||||
},
|
||||
{
|
||||
key: 'dailySales',
|
||||
label: '商品日销',
|
||||
tip: '商品销量 ÷ 统计天数,动销等级严格按此值划分',
|
||||
},
|
||||
{
|
||||
key: 'gradeKey',
|
||||
label: '动销等级',
|
||||
tip: '按商品日销划分:爆款≥20 / 热卖款 10~20 / 平销款 3~10 / 滞销款-1 1~3 / 滞销款-2 0~1 / 销售=0',
|
||||
},
|
||||
];
|
||||
|
||||
export const PICKING_BIN_WINDOW_OPTIONS = [
|
||||
{ label: '近 7 天', value: 7 },
|
||||
{ label: '近 15 天', value: 15 },
|
||||
{ label: '近 30 天', value: 30 },
|
||||
{ label: '近 60 天', value: 60 },
|
||||
];
|
||||
|
||||
export const PICKING_BIN_DEFAULT_DAYS = 30;
|
||||
|
||||
/** 动销等级展示色(与后端 key 对应) */
|
||||
export const PICKING_BIN_GRADE_COLORS = {
|
||||
hot: 'red',
|
||||
best: 'volcano',
|
||||
normal: 'blue',
|
||||
slow1: 'gold',
|
||||
slow2: 'default',
|
||||
zero: 'default',
|
||||
};
|
||||
|
||||
export const PICKING_BIN_DESCRIPTION = [
|
||||
'【统计范围】只统计真实拣选仓位(pack_type=Bin 且 bin_group 为空);箱记录(pack_type=Pack)和存货位不纳入动销分析。',
|
||||
'',
|
||||
'【粒度】一行 = 一个「仓位 × 商品编码」组合。一个仓位放了 N 个商品就显示 N 行,列表与导出一致。',
|
||||
' 库存数量 = 该商品在这个仓位上的实际库存(同一仓位多行相加 = 仓位总库存)。',
|
||||
'',
|
||||
'【动销等级口径】按「商品日销」分档:商品日销 = 该商品近 N 天销量 ÷ N。',
|
||||
' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;',
|
||||
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内该商品无销量。',
|
||||
' 等级判断的是商品本身的销售速度,与其摆放位置无关 —— 同一件商品占几个仓位,等级都相同。',
|
||||
'默认统计窗口为近 30 天,可切换 7 / 15 / 30 / 60 天。',
|
||||
'',
|
||||
'【为什么不用分摊销量来定等级】',
|
||||
' 同一 SKU 常同时占用多个同类型仓位(崔桥工厂仓实测 3290 个拣选 SKU 中 1431 个跨多个拣选位,最多 24 个)。',
|
||||
' 若把销量先摊到每个仓位再定等级,一件日销 100 的爆款放在 10 个仓位就会被算成日销 10 —— 判断的是「摆法」而不是商品。',
|
||||
'',
|
||||
'【商品销量为什么不能直接按行求和】',
|
||||
' 商品销量取的是整档销量(不除以占用仓位数),同一件商品跨仓位会重复出现,所以该列相加无意义。',
|
||||
' 需要按行求和请用导出文件里的「分摊销量」列:商品销量 ÷ 其占用的同类型仓位数。',
|
||||
' 恒等式已实测校验(差异 0):全部行分摊销量之和 = 有库位记录的那些 SKU 的销量之和。',
|
||||
' 注意它小于全仓销量:jst_packInventoryInfo 只覆盖部分 SKU 的仓位数据,无仓位记录的 SKU 销量不计入任何库位。',
|
||||
'',
|
||||
'数据来源:库存取 rpt.jst_packInventoryInfo(按仓库 + 仓位 + 商品汇总);',
|
||||
'销量按 send_date 窗口统计 4 个来源:① jst_Orders 普通商品行;② jst_Orders 组合装行(ZH-拼接编码)按组合装映射展开为子件;',
|
||||
'③ jst_qmcust_orderlist 全民客户(B2B分销)普通行(排除拆单父单 Split);④ qmcust 组合装行同样展开。',
|
||||
'统计范围:只取 pack_type=Bin 且 bin_group 为空的真实拣选仓位,箱记录(pack_type=Pack)和存货位(bin_group=存货)不参与统计。',
|
||||
];
|
||||
@ -1,21 +1,12 @@
|
||||
const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
|
||||
const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
|
||||
|
||||
export const SIDEBAR_PAGES = [
|
||||
{ pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
|
||||
{ pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'stock_sales_analysis', path: '/planning/operation/stock-sales-analysis', name: '库存动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'picking_bin_sales_analysis', path: '/planning/operation/picking-bin-sales-analysis', name: '拣选库位动销分析', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_cuiqiao', path: '/planning/operation/returns-inventory/cuiqiao', name: '崔桥工厂仓客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_subei', path: '/planning/operation/returns-inventory/subei', name: '苏北客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_suining', path: '/planning/operation/returns-inventory/suining', name: '睢宁客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_shandong', path: '/planning/operation/returns-inventory/shandong', name: '山东客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'returns_inventory_henan', path: '/planning/operation/returns-inventory/henan', name: '河南客退数据监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'bin_replenish_monitor', path: '/planning/operation/bin-replenish-monitor', name: '缺货补货监控', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
];
|
||||
|
||||
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
|
||||
acc[item.pageKey] = item;
|
||||
return acc;
|
||||
}, {});
|
||||
const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false };
|
||||
const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false };
|
||||
|
||||
export const SIDEBAR_PAGES = [
|
||||
{ pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES },
|
||||
{ pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES },
|
||||
];
|
||||
|
||||
export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => {
|
||||
acc[item.pageKey] = item;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
/** 仓库库存动销分析 —— 表头与口径说明(与需求表一致) */
|
||||
|
||||
export const STOCK_SALES_COLUMNS = [
|
||||
{
|
||||
key: 'warehouse',
|
||||
label: '仓库',
|
||||
tip: '聚水潭主仓(按 wms_co_id 单仓独立统计,不合并分仓)',
|
||||
},
|
||||
{
|
||||
key: 'grade',
|
||||
label: '动销等级',
|
||||
tip: '按单个 SKU 的日均销量分档:日销 = 统计窗口内已发货件数 / 窗口天数',
|
||||
},
|
||||
{
|
||||
key: 'skuCount',
|
||||
label: 'SKU数量',
|
||||
tip: '该动销等级下「有库存或有销量」的 SKU 个数',
|
||||
},
|
||||
{
|
||||
key: 'skuRatio',
|
||||
label: 'SKU占比',
|
||||
tip: '该等级 SKU 数量 ÷ 该仓库 SKU 总数',
|
||||
},
|
||||
{
|
||||
key: 'stockQty',
|
||||
label: '库存数量',
|
||||
tip: '该等级 SKU 的实时库存件数(jst_inventoryInfo,按 wms_co_id + sku_id 汇总)',
|
||||
},
|
||||
{
|
||||
key: 'stockRatio',
|
||||
label: '库存占比',
|
||||
tip: '该等级库存数量 ÷ 该仓库库存总件数',
|
||||
},
|
||||
{
|
||||
key: 'salesQty',
|
||||
label: '销量',
|
||||
tip: '统计窗口内该等级 SKU 的已发货件数(jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled)',
|
||||
},
|
||||
];
|
||||
|
||||
export const STOCK_SALES_WINDOW_OPTIONS = [
|
||||
{ label: '近 7 天', value: 7 },
|
||||
{ label: '近 14 天', value: 14 },
|
||||
{ label: '近 30 天', value: 30 },
|
||||
];
|
||||
|
||||
export const STOCK_SALES_DEFAULT_DAYS = 30;
|
||||
|
||||
export const STOCK_SALES_DESCRIPTION = [
|
||||
'动销等级口径:按「单个 SKU 的日均销量(日销)」分档,日销 = 统计窗口内已发货件数 ÷ 窗口天数。',
|
||||
' 爆款:日销 ≥ 20;热卖款:10 ≤ 日销 < 20;平销款:3 ≤ 日销 < 10;',
|
||||
' 滞销款-1:1 ≤ 日销 < 3;滞销款-2:0 < 日销 < 1;销售=0:窗口内无销量的 SKU。',
|
||||
'SKU数量 / 库存数量 / 销量:仓库内按等级汇总;「汇总」行为该仓库六个等级的合计。',
|
||||
'SKU占比 = 该等级 SKU 数量 ÷ 该仓库 SKU 总数;库存占比 = 该等级库存数量 ÷ 该仓库库存总件数。',
|
||||
'统计范围:只统计「有库存或有销量」的 SKU;库存为 0 且窗口内无销量的 SKU 已下架,不计入分母。',
|
||||
'库存取业务库实时库存表;销量取销售出库单(jst_Orders × jst_OrderItem,按 send_date,排除 Delete/Cancelled),与「每日进销存」发货口径一致。',
|
||||
];
|
||||
@ -8,7 +8,6 @@ import {
|
||||
DownOutlined,
|
||||
ExperimentOutlined,
|
||||
FundProjectionScreenOutlined,
|
||||
InboxOutlined,
|
||||
LineChartOutlined,
|
||||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
@ -23,7 +22,6 @@ import {
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
SunOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Dropdown, Switch } from 'antd';
|
||||
import { verifyToken } from '../api/auth';
|
||||
@ -62,7 +60,6 @@ const SIDEBAR_ICON_MAP = {
|
||||
dashboard: DashboardOutlined,
|
||||
desktop: DesktopOutlined,
|
||||
experiment: ExperimentOutlined,
|
||||
inbox: InboxOutlined,
|
||||
'line-chart': LineChartOutlined,
|
||||
message: MessageOutlined,
|
||||
projection: FundProjectionScreenOutlined,
|
||||
@ -71,7 +68,6 @@ const SIDEBAR_ICON_MAP = {
|
||||
shopping: ShoppingOutlined,
|
||||
table: TableOutlined,
|
||||
team: TeamOutlined,
|
||||
warning: WarningOutlined,
|
||||
};
|
||||
|
||||
const SIDEBAR_ROUTE_MAP = {
|
||||
@ -83,31 +79,6 @@ const SIDEBAR_ROUTE_MAP = {
|
||||
},
|
||||
'promotion-module': { path: '/planning/operation/promotion-module' },
|
||||
'product-monitoring': { path: '/planning/operation/product-monitoring' },
|
||||
'returns-inventory-cuiqiao': {
|
||||
path: '/planning/operation/returns-inventory/cuiqiao',
|
||||
activePath: '/planning/operation/returns-inventory/cuiqiao',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'returns-inventory-subei': {
|
||||
path: '/planning/operation/returns-inventory/subei',
|
||||
activePath: '/planning/operation/returns-inventory/subei',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'returns-inventory-suining': {
|
||||
path: '/planning/operation/returns-inventory/suining',
|
||||
activePath: '/planning/operation/returns-inventory/suining',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'returns-inventory-shandong': {
|
||||
path: '/planning/operation/returns-inventory/shandong',
|
||||
activePath: '/planning/operation/returns-inventory/shandong',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'returns-inventory-henan': {
|
||||
path: '/planning/operation/returns-inventory/henan',
|
||||
activePath: '/planning/operation/returns-inventory/henan',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' },
|
||||
'product-analysis': { path: '/planning/operation/product-analysis' },
|
||||
'work-orders': { path: '/planning/operation/work-orders' },
|
||||
@ -121,24 +92,6 @@ const SIDEBAR_ROUTE_MAP = {
|
||||
activePath: '/planning/operation/mkh-bom-data',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'warehouse-ship-timeliness': {
|
||||
path: '/planning/operation/warehouse-ship-timeliness',
|
||||
activePath: '/planning/operation/warehouse-ship-timeliness',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'warehouse-logistics-exception': {
|
||||
path: '/planning/operation/warehouse-logistics-exception',
|
||||
activePath: '/planning/operation/warehouse-logistics-exception',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
'daily-inventory': { path: '/planning/operation/daily-inventory' },
|
||||
'stock-sales-analysis': { path: '/planning/operation/stock-sales-analysis' },
|
||||
'picking-bin-sales-analysis': { path: '/planning/operation/picking-bin-sales-analysis' },
|
||||
'bin-replenish-monitor': {
|
||||
path: '/planning/operation/bin-replenish-monitor',
|
||||
activePath: '/planning/operation/bin-replenish-monitor',
|
||||
matchMode: 'prefix',
|
||||
},
|
||||
settings: { path: '/settings' },
|
||||
'online-desktop-users': { path: '/settings/online-desktop-users' },
|
||||
'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' },
|
||||
@ -148,6 +101,7 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf
|
||||
if (entry.requiresAdmin && !canUseSettings) return [];
|
||||
if (entry.type === 'section') return [{ ...entry }];
|
||||
|
||||
const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {};
|
||||
const routeMatches = (itemRoute = {}, pathname = '') => {
|
||||
const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : [];
|
||||
if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) {
|
||||
@ -165,16 +119,9 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf
|
||||
? pathname === matchPath || pathname.startsWith(`${matchPath}/`)
|
||||
: pathname === matchPath;
|
||||
};
|
||||
|
||||
// 递归构建菜单节点:支持任意层级(分组 -> 子分组 -> 叶子)
|
||||
const buildItem = (item) => {
|
||||
if (!item) return null;
|
||||
if (item.requiresAdmin && !canUseSettings) return null;
|
||||
const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {};
|
||||
const rawChildren = Array.isArray(item.children) ? item.children : null;
|
||||
const children = rawChildren
|
||||
? sortSidebarEntries(rawChildren).map((child) => buildItem(child)).filter(Boolean)
|
||||
: undefined;
|
||||
return {
|
||||
...item,
|
||||
icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined,
|
||||
@ -182,88 +129,25 @@ const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(conf
|
||||
external: Boolean(itemRoute.externalUrl),
|
||||
onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined,
|
||||
match: (path) => routeMatches(itemRoute, path),
|
||||
...(children && children.length ? { children } : (rawChildren ? { children: [] } : {})),
|
||||
};
|
||||
};
|
||||
|
||||
const normalized = buildItem(entry);
|
||||
if (!normalized) return [];
|
||||
normalized.type = entry.type;
|
||||
const normalized = {
|
||||
...entry,
|
||||
icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined,
|
||||
path: route.path,
|
||||
external: Boolean(route.externalUrl),
|
||||
onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined,
|
||||
match: (path) => routeMatches(route, path),
|
||||
};
|
||||
|
||||
if (entry.type === 'group') {
|
||||
normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean);
|
||||
}
|
||||
|
||||
return [normalized];
|
||||
});
|
||||
|
||||
// 递归收集默认展开的菜单 key(支持多级)
|
||||
const collectDefaultExpandedKeys = (entries = []) => entries.flatMap((entry) => [
|
||||
...(entry?.defaultExpanded && entry?.key ? [entry.key] : []),
|
||||
...collectDefaultExpandedKeys(entry?.children || []),
|
||||
]);
|
||||
|
||||
// 递归判断菜单项(含其子孙)是否命中当前路径
|
||||
const isNavEntryActive = (entry, pathname) => {
|
||||
if (!entry) return false;
|
||||
if (entry.match?.(pathname)) return true;
|
||||
return Array.isArray(entry.children) && entry.children.some((child) => isNavEntryActive(child, pathname));
|
||||
};
|
||||
|
||||
// 侧边栏菜单节点(递归渲染,支持三级及以上)
|
||||
const SidebarNavNode = ({
|
||||
item, level, pathname, expandedKeys, onToggle, onLeaf, collapsed,
|
||||
}) => {
|
||||
const Icon = item.icon || AppstoreOutlined;
|
||||
const expandable = Array.isArray(item.children) && item.children.length > 0;
|
||||
const descendantActive = expandable && item.children.some((child) => isNavEntryActive(child, pathname));
|
||||
const selfActive = Boolean(item.match?.(pathname));
|
||||
const active = selfActive || (collapsed && descendantActive);
|
||||
const expanded = expandable && expandedKeys.includes(item.key) && !collapsed;
|
||||
const isTop = level <= 1;
|
||||
const baseClass = isTop ? 'planning-subsystem-nav-item' : 'planning-subsystem-subnav-item';
|
||||
const iconClass = isTop ? 'planning-subsystem-nav-icon' : 'planning-subsystem-subnav-icon';
|
||||
|
||||
return (
|
||||
<div className="planning-subsystem-nav-group">
|
||||
<button
|
||||
type="button"
|
||||
className={`${baseClass}${active ? ' is-active' : ''}${descendantActive ? ' is-descendant-active' : ''}${expandable ? ' is-expandable' : ''}`}
|
||||
style={isTop ? undefined : { paddingLeft: 36 + (level - 2) * 20 }}
|
||||
onClick={() => {
|
||||
if (expandable) {
|
||||
onToggle(item.key);
|
||||
return;
|
||||
}
|
||||
onLeaf(item);
|
||||
}}
|
||||
title={item.label}
|
||||
aria-expanded={expandable ? expanded : undefined}
|
||||
>
|
||||
<Icon className={iconClass} />
|
||||
<span className={isTop ? 'planning-subsystem-nav-label' : 'planning-subsystem-subnav-label'}>{item.label}</span>
|
||||
{expandable ? (
|
||||
<span className="planning-subsystem-nav-chevron">
|
||||
{expanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{expandable && expanded ? (
|
||||
<div className="planning-subsystem-subnav">
|
||||
{item.children.map((child) => (
|
||||
<SidebarNavNode
|
||||
key={child.key}
|
||||
item={child}
|
||||
level={level + 1}
|
||||
pathname={pathname}
|
||||
expandedKeys={expandedKeys}
|
||||
onToggle={onToggle}
|
||||
onLeaf={onLeaf}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function parseJwtPayload(token) {
|
||||
if (!token) {
|
||||
return null;
|
||||
@ -408,7 +292,9 @@ const BasicLayout = () => {
|
||||
setSidebarConfig(nextSidebarConfig);
|
||||
setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault));
|
||||
setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault));
|
||||
setExpandedSidebarKeys(collectDefaultExpandedKeys(nextSidebarConfig.entries));
|
||||
setExpandedSidebarKeys(
|
||||
nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key)
|
||||
);
|
||||
document.title = nextSiteName;
|
||||
|
||||
let link = document.querySelector("link[rel*='icon']");
|
||||
@ -618,6 +504,12 @@ const BasicLayout = () => {
|
||||
},
|
||||
].filter(Boolean);
|
||||
|
||||
const isSidebarEntryActive = (entry) => {
|
||||
if (!entry) return false;
|
||||
if (entry.match?.(pathname)) return true;
|
||||
return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child));
|
||||
};
|
||||
|
||||
const toggleSidebarGroup = (key) => {
|
||||
setSidebarCollapsed(false);
|
||||
setExpandedSidebarKeys((keys) => (
|
||||
@ -675,17 +567,58 @@ const BasicLayout = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = item.icon;
|
||||
const expandable = Array.isArray(item.children) && item.children.length > 0;
|
||||
const descendantActive = expandable && item.children.some((child) => isSidebarEntryActive(child));
|
||||
const selfActive = Boolean(item.match?.(pathname));
|
||||
const active = selfActive || (sidebarCollapsed && descendantActive);
|
||||
const expanded = expandable && expandedSidebarKeys.includes(item.key) && !sidebarCollapsed;
|
||||
|
||||
return (
|
||||
<SidebarNavNode
|
||||
key={item.key}
|
||||
item={item}
|
||||
level={1}
|
||||
pathname={pathname}
|
||||
expandedKeys={expandedSidebarKeys}
|
||||
onToggle={toggleSidebarGroup}
|
||||
onLeaf={handleSidebarLeafClick}
|
||||
collapsed={sidebarCollapsed}
|
||||
/>
|
||||
<div key={item.key} className={`planning-subsystem-nav-group${expanded ? ' is-expanded' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`planning-subsystem-nav-item${active ? ' is-active' : ''}${descendantActive ? ' is-descendant-active' : ''}${expandable ? ' is-expandable' : ''}`}
|
||||
onClick={() => {
|
||||
if (expandable) {
|
||||
toggleSidebarGroup(item.key);
|
||||
return;
|
||||
}
|
||||
handleSidebarLeafClick(item);
|
||||
}}
|
||||
title={item.label}
|
||||
aria-expanded={expandable ? expanded : undefined}
|
||||
>
|
||||
<Icon className="planning-subsystem-nav-icon" />
|
||||
<span className="planning-subsystem-nav-label">{item.label}</span>
|
||||
{expandable ? (
|
||||
<span className="planning-subsystem-nav-chevron">
|
||||
{expanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{expandable && expanded ? (
|
||||
<div className="planning-subsystem-subnav">
|
||||
{item.children.map((child) => {
|
||||
const ChildIcon = child.icon;
|
||||
const childActive = isSidebarEntryActive(child);
|
||||
return (
|
||||
<button
|
||||
key={child.key}
|
||||
type="button"
|
||||
className={`planning-subsystem-subnav-item${childActive ? ' is-active' : ''}`}
|
||||
onClick={() => handleSidebarLeafClick(child)}
|
||||
title={child.label}
|
||||
>
|
||||
<ChildIcon className="planning-subsystem-subnav-icon" />
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@ -1043,22 +976,6 @@ const BasicLayout = () => {
|
||||
background: var(--sidebar-active-bg);
|
||||
}
|
||||
|
||||
.planning-subsystem-subnav-item.is-descendant-active:not(.is-active) {
|
||||
color: var(--sidebar-active-text);
|
||||
}
|
||||
|
||||
.planning-subsystem-subnav-label {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planning-subsystem-subnav-item.is-expandable .planning-subsystem-nav-chevron {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.planning-subsystem-subnav-icon {
|
||||
flex: 0 0 14px;
|
||||
color: var(--sidebar-icon-muted);
|
||||
|
||||
@ -1,419 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Empty, Input, Segmented, Select, Spin, Table, Tag, Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import {
|
||||
DownloadOutlined, InfoCircleOutlined, ReloadOutlined, SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { exportBinReplenish, getBinReplenishList } from '../../api/binReplenish';
|
||||
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import './style.css';
|
||||
|
||||
const num = (value) => (value === null || value === undefined || value === ''
|
||||
? '--'
|
||||
: Number(value).toLocaleString('zh-CN'));
|
||||
|
||||
const STATUS_TAGS = {
|
||||
short: { color: 'red', label: '存储不足' },
|
||||
need: { color: 'gold', label: '需补货' },
|
||||
enough: { color: 'green', label: '拣货位充足' },
|
||||
no_order: { color: 'default', label: '今日无单' },
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: '待补货', value: 'replenish' },
|
||||
{ label: '存储不足', value: 'short' },
|
||||
{ label: '拣货位充足', value: 'enough' },
|
||||
{ label: '今日无单', value: 'no_order' },
|
||||
{ label: '全部', value: 'all' },
|
||||
];
|
||||
|
||||
const BinReplenishMonitor = () => {
|
||||
const [warehouse, setWarehouse] = useState('cuiqiao');
|
||||
const [status, setStatus] = useState('replenish');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(50);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [data, setData] = useState({
|
||||
rows: [], total: 0, summary: {}, warehouse: null, warehouses: [], orderDate: '', updatedAt: '', cacheHit: false,
|
||||
});
|
||||
const keywordTimer = useRef(null);
|
||||
|
||||
const load = useCallback(async (options = {}) => {
|
||||
const isRefresh = options.refresh === true;
|
||||
if (isRefresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await getBinReplenishList({
|
||||
warehouse,
|
||||
status,
|
||||
keyword,
|
||||
page: options.page || page,
|
||||
pageSize: options.pageSize || pageSize,
|
||||
refresh: isRefresh ? 'true' : undefined,
|
||||
});
|
||||
const payload = response?.data?.data || response?.data || {};
|
||||
setData({
|
||||
rows: payload.rows || [],
|
||||
total: payload.total || 0,
|
||||
summary: payload.summary || {},
|
||||
warehouse: payload.warehouse || null,
|
||||
warehouses: payload.warehouses || [],
|
||||
orderDate: payload.orderDate || '',
|
||||
updatedAt: payload.updatedAt || '',
|
||||
cacheHit: Boolean(payload.cacheHit),
|
||||
});
|
||||
if (isRefresh) message.success('已重新回源取数');
|
||||
} catch (err) {
|
||||
setError(err?.message || '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [warehouse, status, keyword, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [warehouse, status, page, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (keywordTimer.current) clearTimeout(keywordTimer.current);
|
||||
keywordTimer.current = setTimeout(() => {
|
||||
setPage(1);
|
||||
load({ page: 1 });
|
||||
}, 400);
|
||||
return () => { if (keywordTimer.current) clearTimeout(keywordTimer.current); };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [keyword]);
|
||||
|
||||
const summary = data.summary;
|
||||
const needCount = (summary.need || 0) + (summary.short || 0);
|
||||
|
||||
const metrics = useMemo(() => ([
|
||||
{
|
||||
key: 'replenish',
|
||||
tone: 'red',
|
||||
label: '待补货 SKU',
|
||||
value: needCount,
|
||||
unit: '个',
|
||||
foot: `建议补货合计 ${num(summary.suggestQtyTotal)} 件`,
|
||||
tip: '当日订单量 > 拣货位库存:拣货位不够今天的订单拣,需要从存储位把货补过来',
|
||||
},
|
||||
{
|
||||
key: 'short',
|
||||
tone: 'grey',
|
||||
label: '其中存储不足',
|
||||
value: summary.short || 0,
|
||||
unit: '个',
|
||||
foot: `存储缺口 ${num(summary.shortfallTotal)} 件`,
|
||||
tip: '拣货位缺货,且存储位也不够补(存储缺口 > 0):光靠库内调拨补不齐,需要采购 / 紧急调拨',
|
||||
},
|
||||
{
|
||||
key: 'exceed',
|
||||
tone: 'orange',
|
||||
label: '超出订单数合计',
|
||||
value: summary.exceedTotal || 0,
|
||||
unit: '件',
|
||||
foot: '今日拣货位缺口订单量',
|
||||
tip: '所有待补货 SKU 的「订单量 − 拣货位库存」之和:这是今天需要从存储位搬到拣货位的总量',
|
||||
},
|
||||
{
|
||||
key: 'order',
|
||||
tone: 'blue',
|
||||
label: '当日有单 SKU',
|
||||
value: summary.orderSkuCount || 0,
|
||||
unit: '个',
|
||||
foot: `当日订单量 ${num(summary.orderQtyTotal)} 件`,
|
||||
tip: '统计日当天下过单的商品种类数(含线上店铺 + 全民客户,组合装已展开为子件)',
|
||||
},
|
||||
]), [summary, needCount]);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await exportBinReplenish({ warehouse, status, keyword });
|
||||
downloadBlobAsFile(response.data, `${(data.warehouse || {}).shortName || '仓库'}缺货补货监控_${data.orderDate || ''}.xlsx`);
|
||||
} catch (error_) {
|
||||
const text = await readBlobErrorMessage(error_);
|
||||
message.error(text || '导出失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
render: (value) => {
|
||||
const tag = STATUS_TAGS[value] || STATUS_TAGS.no_order;
|
||||
return <Tag color={tag.color} className="brp-status-tag">{tag.label}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '商品编码',
|
||||
dataIndex: 'skuId',
|
||||
width: 175,
|
||||
render: (value, row) => (
|
||||
<div className="brp-sku">
|
||||
<span className="brp-sku__code">{value}</span>
|
||||
{row.supplier ? <span className="brp-sku__supplier">{row.supplier}</span> : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '品名', dataIndex: 'productName', width: 260, ellipsis: true },
|
||||
{
|
||||
title: '拣货位库存',
|
||||
dataIndex: 'pickQty',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value, row) => (
|
||||
<span className={Number(value) <= 0 ? 'brp-qty brp-qty--zero' : 'brp-qty'}>
|
||||
{num(value)}{row.pickEmpty ? ' ⚠' : ''}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '存储位库存',
|
||||
dataIndex: 'storageQty',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => <span className="brp-qty">{num(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '当日订单',
|
||||
dataIndex: 'orderQty',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => <span className="brp-qty">{num(value)}</span>,
|
||||
},
|
||||
{
|
||||
title: '超出订单数',
|
||||
dataIndex: 'excess',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => (
|
||||
<span className={Number(value) > 0 ? 'brp-qty brp-qty--warn' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '建议补货量',
|
||||
dataIndex: 'suggestQty',
|
||||
width: 110,
|
||||
align: 'right',
|
||||
render: (value) => (
|
||||
<span className={Number(value) > 0 ? 'brp-qty brp-qty--suggest' : 'brp-qty brp-qty--zero'}>
|
||||
{num(value)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '存储缺口',
|
||||
dataIndex: 'shortfall',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => (
|
||||
<span className={Number(value) > 0 ? 'brp-qty brp-qty--danger' : 'brp-qty brp-qty--zero'}>{num(value)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '现有库存',
|
||||
dataIndex: 'totalQty',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (value) => <span className="brp-qty">{num(value)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SycmPage className="brp-page">
|
||||
<div className="brp-hero">
|
||||
<div className="brp-hero__main">
|
||||
<h1 className="brp-hero__title">缺货补货监控</h1>
|
||||
<p className="brp-hero__desc">
|
||||
盯的是<b>拣货位</b>:今天下的订单,如果拣货位库存不够拣,超出的那部分就要从<b>存储位</b>补到拣货位。
|
||||
库存取自聚水潭「箱及仓位库存」,订单取自崔桥工厂仓当日订单。
|
||||
</p>
|
||||
</div>
|
||||
<div className="brp-hero__meta">
|
||||
<div>
|
||||
仓库:<b>{(data.warehouse || {}).name || '简尚-崔桥工厂仓'}</b>
|
||||
</div>
|
||||
<div>
|
||||
订单日期:<b>{data.orderDate || '--'}</b>
|
||||
<span className="brp-muted">(取该仓最近一个有订单的营业日)</span>
|
||||
</div>
|
||||
<div>
|
||||
取数时间:<b>{data.updatedAt ? new Date(data.updatedAt).toLocaleString('zh-CN') : '--'}</b>
|
||||
{data.cacheHit ? <span className="brp-muted">(缓存)</span> : null}
|
||||
</div>
|
||||
<div>
|
||||
共 <b>{num(summary.skuTotal)}</b> 个 SKU,其中今日无单 <b>{num(summary.noOrder)}</b> 个
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="brp-metrics">
|
||||
{metrics.map((item) => (
|
||||
<div key={item.key} className={`brp-metric brp-metric--${item.tone}`}>
|
||||
<Tooltip title={item.tip}>
|
||||
<div className="brp-metric__label">
|
||||
{item.label}
|
||||
<InfoCircleOutlined className="brp-metric__info" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="brp-metric__value">
|
||||
{num(item.value)}
|
||||
<span className="brp-metric__unit">{item.unit}</span>
|
||||
</div>
|
||||
<div className="brp-metric__foot">{item.foot}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="brp-toolbar">
|
||||
<span className="brp-toolbar__field">
|
||||
<span className="brp-toolbar__label">仓库</span>
|
||||
<Select
|
||||
value={warehouse}
|
||||
onChange={(value) => { setWarehouse(value); setPage(1); }}
|
||||
style={{ width: 210 }}
|
||||
options={(data.warehouses || []).map((item) => ({
|
||||
value: item.key,
|
||||
label: item.name,
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
<span className="brp-toolbar__field">
|
||||
<span className="brp-toolbar__label">范围</span>
|
||||
<Segmented
|
||||
value={status}
|
||||
onChange={(value) => { setStatus(value); setPage(1); }}
|
||||
options={STATUS_OPTIONS.map((item) => ({
|
||||
...item,
|
||||
label: item.value === 'replenish'
|
||||
? `${item.label} ${num(needCount)}`
|
||||
: item.value === 'short'
|
||||
? `${item.label} ${num(summary.short || 0)}`
|
||||
: item.value === 'no_order'
|
||||
? `${item.label} ${num(summary.noOrder || 0)}`
|
||||
: item.label,
|
||||
}))}
|
||||
/>
|
||||
</span>
|
||||
<Input
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜商品编码 / 品名 / 供应商"
|
||||
prefix={<SearchOutlined />}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<span className="brp-toolbar__spacer" />
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport}>导出</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={refreshing}
|
||||
onClick={() => load({ refresh: true })}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="brp-alert"
|
||||
message="货位口径(崔桥工厂仓约定)"
|
||||
description={(
|
||||
<span>
|
||||
拣货库位以 <b>XA / XD / XE</b> 开头,存储库位以 <b>XX / X9 / X5 / X4</b> 开头;其余货位(DB / Y* / Z* 等,占比极小)不计入。
|
||||
判断规则:<b>当日订单量 > 拣货位库存</b> 时,超出的订单数 = 需要从存储位补到拣货位的量(上限为存储位现有库存)。
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
className="brp-alert"
|
||||
message={error}
|
||||
action={<Button size="small" onClick={() => load()}>重试</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Spin
|
||||
spinning={loading}
|
||||
tip="正在聚合仓位库存 + 当日订单,首次约 30~90 秒"
|
||||
wrapperClassName="brp-table-spin"
|
||||
>
|
||||
<Table
|
||||
rowKey="skuId"
|
||||
size="small"
|
||||
className="brp-table"
|
||||
columns={columns}
|
||||
dataSource={data.rows}
|
||||
scroll={{ x: 1500 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
description={status === 'replenish'
|
||||
? '当前没有需要补货的拣货位'
|
||||
: '没有符合条件的商品'}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data.total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['20', '50', '100', '200'],
|
||||
showTotal: (total) => `共 ${total} 个 SKU`,
|
||||
onChange: (nextPage, nextSize) => {
|
||||
setPage(nextPage);
|
||||
setPageSize(nextSize);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
<div className="brp-notes">
|
||||
<div className="brp-notes__title">数据来源与口径说明</div>
|
||||
<ul>
|
||||
<li>
|
||||
<b>库存</b>:聚水潭「箱及仓位库存」(rpt.jst_packInventoryInfo),按商品编码(sku_id)聚合。
|
||||
「拣货位库存」= 货位号以 XA/XD/XE 开头的库存合计;「存储位库存」= 货位号以 XX/X9/X5/X4 开头的库存合计。
|
||||
</li>
|
||||
<li>
|
||||
<b>订单</b>:崔桥工厂仓当日订单 = rpt.jst_Orders(线上店铺)+ rpt.jst_qmcust_orderlist(全民客户 B2B),
|
||||
均按 send_date 落在「订单日期」当天;组合装(sku_type=combine)已按 jst_comboProductItem 展开为子件。
|
||||
</li>
|
||||
<li>
|
||||
<b>判断规则</b>:对每个 SKU,若 <b>当日订单量 > 拣货位库存</b>,超出的订单数即「需要从存储位补到拣货位」的量;
|
||||
「建议补货量」= min(超出订单数, 存储位库存)。存储位也不够时标记「存储不足」,缺口需采购 / 紧急调拨。
|
||||
</li>
|
||||
<li>
|
||||
<b>订单日期</b>:取该仓订单表 send_date 的最大日期(即最近一个有订单的营业日),副本 ETL 有延迟时不会为空;
|
||||
页面顶部明确标注统计的是哪一天。
|
||||
</li>
|
||||
<li>
|
||||
<b>时效</b>:仓位库存视图聚合 + 当日订单 4 分支 union 各需 30s~1min,服务端缓存 10 分钟(启动时已预热);
|
||||
点「刷新数据」会强制回源,需要等 1 分钟左右。
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default BinReplenishMonitor;
|
||||
@ -1,313 +0,0 @@
|
||||
/* 缺货补货监控 —— 页头 / 指标卡 / 工具栏 / 明细表 */
|
||||
|
||||
.brp-page {
|
||||
--brp-gap: 12px;
|
||||
--brp-border: var(--sycm-border-card, #e6e6e6);
|
||||
--brp-radius: var(--sycm-radius-xl, 12px);
|
||||
--brp-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
|
||||
--brp-blue: #2062e6;
|
||||
--brp-red: #e5484d;
|
||||
--brp-orange: #e8890c;
|
||||
--brp-grey: #6b7280;
|
||||
--brp-green: #1bb574;
|
||||
}
|
||||
|
||||
/* ── 页头 ───────────────────────────────────────────────── */
|
||||
.brp-hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px 24px;
|
||||
margin-bottom: var(--brp-gap);
|
||||
}
|
||||
|
||||
.brp-hero__main {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
.brp-hero__title {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.brp-hero__desc {
|
||||
margin: 4px 0 0;
|
||||
max-width: 820px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.brp-hero__meta {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.brp-warn {
|
||||
color: var(--brp-orange);
|
||||
}
|
||||
|
||||
.brp-ok {
|
||||
color: var(--brp-green);
|
||||
}
|
||||
|
||||
/* ── 指标卡 ─────────────────────────────────────────────── */
|
||||
.brp-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--brp-gap);
|
||||
margin-bottom: var(--brp-gap);
|
||||
}
|
||||
|
||||
.brp-metric {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 12px 14px 11px;
|
||||
border: 1px solid var(--brp-border);
|
||||
border-radius: var(--brp-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--brp-shadow);
|
||||
}
|
||||
|
||||
.brp-metric::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
background: var(--brp-tone, var(--brp-blue));
|
||||
}
|
||||
|
||||
.brp-metric--red { --brp-tone: var(--brp-red); }
|
||||
.brp-metric--orange { --brp-tone: var(--brp-orange); }
|
||||
.brp-metric--blue { --brp-tone: var(--brp-blue); }
|
||||
.brp-metric--grey { --brp-tone: var(--brp-grey); }
|
||||
|
||||
.brp-metric__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.brp-metric__info {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.brp-metric__value {
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.brp-metric__unit {
|
||||
margin-left: 4px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.brp-metric__foot {
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── 工具栏 ─────────────────────────────────────────────── */
|
||||
.brp-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--brp-border);
|
||||
border-radius: var(--brp-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--brp-shadow);
|
||||
}
|
||||
|
||||
.brp-toolbar__field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.brp-toolbar__label {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brp-toolbar__spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.brp-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ── 表格 ───────────────────────────────────────────────── */
|
||||
.brp-table-spin {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brp-table .ant-table-thead > tr > th {
|
||||
background: #fafafa;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brp-table .ant-table-tbody > tr > td {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.brp-status-tag {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.brp-sku {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.brp-sku__code {
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.brp-sku__supplier {
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brp-bins {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.brp-bin {
|
||||
margin: 0;
|
||||
padding: 0 6px;
|
||||
border-color: #d9e2f2;
|
||||
background: #f5f8ff;
|
||||
color: #2b4a80;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.brp-bin b {
|
||||
margin-left: 4px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.brp-bin.is-zero {
|
||||
border-color: #f3d2d2;
|
||||
background: #fff6f6;
|
||||
color: #a33a3a;
|
||||
}
|
||||
|
||||
.brp-bin--more {
|
||||
border-color: #eee;
|
||||
background: #fafafa;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.brp-qty {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.brp-qty--zero {
|
||||
color: var(--brp-red);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brp-qty--suggest {
|
||||
color: var(--brp-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brp-qty--warn {
|
||||
color: var(--brp-orange);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brp-qty--danger {
|
||||
color: var(--brp-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brp-qty--muted {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.brp-muted {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
/* ── 口径说明 ───────────────────────────────────────────── */
|
||||
.brp-notes {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--brp-border);
|
||||
border-radius: var(--brp-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.brp-notes__title {
|
||||
margin-bottom: 6px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brp-notes ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.brp-notes li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.brp-metrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.brp-hero__meta {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.brp-metrics {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
@ -1,682 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, DatePicker, Input, Modal, Select, Space, Spin, Switch, Table, Tag, TimePicker,
|
||||
Tooltip, Typography, Upload,
|
||||
} from 'antd';
|
||||
import {
|
||||
BellOutlined, InfoCircleOutlined, PlusOutlined, ReloadOutlined, UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import { getDailyInventory, importDailyInventoryOfficial } from '../../api/dailyInventory';
|
||||
import {
|
||||
getDailyInventoryNotificationConfig,
|
||||
runDailyInventoryNotificationTest,
|
||||
saveDailyInventoryNotificationConfig,
|
||||
} from '../../api/dailyInventoryNotification.js';
|
||||
import {
|
||||
DAILY_INVENTORY_COLUMNS,
|
||||
COLUMN_SOURCE,
|
||||
FORMULA_DESCRIPTION,
|
||||
} from '../../constants/dailyInventory.js';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const TIME_FORMAT = 'HH:mm';
|
||||
|
||||
/** 当前登录用户是否为管理员(决定「通知设置」入口是否可见) */
|
||||
function readIsAdmin() {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem('auth_user') || 'null');
|
||||
if (stored?.role) return stored.role === 'admin';
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const payload = JSON.parse(window.atob(token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return payload?.role === 'admin';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 收件人名单规范化:去空、去重、限长(后端会再校验一次) */
|
||||
function normalizeRecipientList(list = []) {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const raw of list) {
|
||||
const name = String(raw ?? '').trim().slice(0, 50);
|
||||
if (!name || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
result.push(name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const TipTitle = ({ tip, children }) => (
|
||||
<Tooltip title={tip}>
|
||||
<span className="daily-inventory-th-tip">{children}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const int = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
};
|
||||
|
||||
const percent = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return `${Number(value).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}%`;
|
||||
};
|
||||
|
||||
/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */
|
||||
const renderSigned = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n) || n === 0) return <span className="daily-inventory-cell--zero">0</span>;
|
||||
return <span className={n < 0 ? 'daily-inventory-cell--out' : 'daily-inventory-cell--in'}>{int(n)}</span>;
|
||||
};
|
||||
|
||||
const DailyInventory = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [data, setData] = useState(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
|
||||
// ── 定时截图通知设置 ──
|
||||
const isAdmin = useMemo(readIsAdmin, []);
|
||||
const [notifyOpen, setNotifyOpen] = useState(false);
|
||||
const [notifyLoading, setNotifyLoading] = useState(false);
|
||||
const [notifySaving, setNotifySaving] = useState(false);
|
||||
const [notifyTesting, setNotifyTesting] = useState(false);
|
||||
const [notifyConfig, setNotifyConfig] = useState(null);
|
||||
const [notifyResult, setNotifyResult] = useState(null);
|
||||
const [recipientDraft, setRecipientDraft] = useState('');
|
||||
const [notifyForm, setNotifyForm] = useState({ enabled: false, notifyTime: '01:00', recipients: [] });
|
||||
|
||||
// ── 业务日切换 + 聚水潭官方进销存导入 ──
|
||||
const [selectedDate, setSelectedDate] = useState(''); // 空 = 业务库最新业务日
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [importDate, setImportDate] = useState(null);
|
||||
const [importFile, setImportFile] = useState(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState(null);
|
||||
|
||||
const load = useCallback(async (force = false) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params = {};
|
||||
if (selectedDate) params.date = selectedDate;
|
||||
if (force) params.refresh = '1';
|
||||
const res = await getDailyInventory(params);
|
||||
setData(res?.data ?? null);
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
const officialFlowKeys = useMemo(() => new Set(data?.flowColumnsOfficial || []), [data]);
|
||||
|
||||
const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => {
|
||||
const isRuntime = col.source === COLUMN_SOURCE.runtime;
|
||||
const isUnavailable = col.source === COLUMN_SOURCE.unavailable;
|
||||
// 该列已导入聚水潭官方数据时,不再按「无数据来源」置灰
|
||||
const noSource = isUnavailable && !officialFlowKeys.has(col.key);
|
||||
return {
|
||||
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
|
||||
dataIndex: col.key,
|
||||
key: col.key,
|
||||
align: col.key === 'warehouse' ? 'left' : 'right',
|
||||
width: col.key === 'warehouse' ? 260 : 130,
|
||||
fixed: col.key === 'warehouse' ? 'left' : undefined,
|
||||
className: isRuntime
|
||||
? 'daily-inventory-col--runtime'
|
||||
: (noSource ? 'daily-inventory-col--unavailable' : undefined),
|
||||
render: (value, record) => {
|
||||
if (record?.isTotal) {
|
||||
// 合计行:四级库存量累加,周转率按「总发货 / 总库存」重算(后端已给出)
|
||||
if (col.key === 'warehouse') return <span className="daily-inventory-warehouse">{value}</span>;
|
||||
// 该列整体无数据来源时,合计行同样显示「—」,避免误读为 0
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span className="daily-inventory-cell--na">—</span>;
|
||||
}
|
||||
if (col.key === 'turnoverDays') return percent(value);
|
||||
return int(value);
|
||||
}
|
||||
if (col.key === 'warehouse') {
|
||||
return (
|
||||
<span className="daily-inventory-warehouse">
|
||||
{value}
|
||||
{record?.source?.hasOfficial ? (
|
||||
<Tooltip title="该仓已导入聚水潭官方进销存,期初/流水/期末均为官方值">
|
||||
<Tag color="blue" className="daily-inventory-tag">官方</Tag>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title="该仓未导入聚水潭官方进销存,数值为业务库推算(流水列可能缺 8 类单据)">
|
||||
<Tag className="daily-inventory-tag">推算</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span className="daily-inventory-cell--na">—</span>;
|
||||
}
|
||||
// 数据来源标记:官方值正常显示,业务库推算值标灰提示口径差异
|
||||
const cellSource = col.key === 'openingQty' ? record?.source?.opening
|
||||
: col.key === 'closingQty' ? record?.source?.closing
|
||||
: record?.source?.flows?.[col.key];
|
||||
const wrap = (node) => (cellSource === 'derived' ? (
|
||||
<Tooltip title="业务库推算值,口径与聚水潭进销存存在差异;导入该业务日的聚水潭导出后自动切换为官方值">
|
||||
<span className="daily-inventory-cell--derived">{node}</span>
|
||||
</Tooltip>
|
||||
) : node);
|
||||
|
||||
if (col.key === 'openingQty' || col.key === 'openingQtyExtra' || col.key === 'closingQty') {
|
||||
return wrap(int(value));
|
||||
}
|
||||
if (col.key === 'turnoverDays') return percent(value);
|
||||
return wrap(renderSigned(value));
|
||||
},
|
||||
};
|
||||
}), [officialFlowKeys]);
|
||||
|
||||
const tableRows = useMemo(() => {
|
||||
if (!data?.totals) return items;
|
||||
return [...items, { ...data.totals, key: '__total__' }];
|
||||
}, [items, data]);
|
||||
|
||||
const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]);
|
||||
|
||||
/** 业务日下拉:最新业务日 + 已导入聚水潭官方数据的日期 */
|
||||
const dateOptions = useMemo(() => {
|
||||
const opts = [{
|
||||
value: '__latest__',
|
||||
label: `最新业务日${data?.systemBizDate ? `(${data.systemBizDate})` : ''}`,
|
||||
}];
|
||||
(data?.official?.availableDates || [])
|
||||
.filter((d) => d !== data?.systemBizDate)
|
||||
.forEach((d) => opts.push({ value: d, label: `${d}(聚水潭官方)` }));
|
||||
return opts;
|
||||
}, [data]);
|
||||
|
||||
const openImport = useCallback(() => {
|
||||
setImportResult(null);
|
||||
setImportFile(null);
|
||||
setImportDate(data?.latestBizDate ? dayjs(data.latestBizDate) : dayjs());
|
||||
setImportOpen(true);
|
||||
}, [data]);
|
||||
|
||||
const submitImport = useCallback(async () => {
|
||||
if (!importFile) {
|
||||
setImportResult({ type: 'error', text: '请先选择聚水潭导出的 xlsx 文件' });
|
||||
return;
|
||||
}
|
||||
if (!importDate) {
|
||||
setImportResult({ type: 'error', text: '请选择业务日期' });
|
||||
return;
|
||||
}
|
||||
setImporting(true);
|
||||
setImportResult(null);
|
||||
try {
|
||||
const res = await importDailyInventoryOfficial({
|
||||
file: importFile,
|
||||
date: importDate.format('YYYY-MM-DD'),
|
||||
});
|
||||
const result = res?.data ?? {};
|
||||
const bizDate = result.bizDate || importDate.format('YYYY-MM-DD');
|
||||
const notes = [
|
||||
...(result.warnings || []),
|
||||
...(result.unknownWarehouses || []).map((w) => `未匹配到仓库 ID,已按仓库名存档:${w}`),
|
||||
...(result.mismatches || []).map((m) => `${m.warehouse}:期初 + 流水 − 期末 = ${m.diff}`),
|
||||
];
|
||||
setImportResult({
|
||||
type: 'success',
|
||||
text: `${bizDate} 已导入 ${result.imported || 0} 个仓库${result.mismatches?.length ? ',存在恒等式不闭合的仓库' : ',恒等式逐仓闭合'}`,
|
||||
notes,
|
||||
});
|
||||
if (selectedDate === bizDate) await load(true);
|
||||
else setSelectedDate(bizDate);
|
||||
} catch (err) {
|
||||
setImportResult({
|
||||
type: 'error',
|
||||
text: err?.response?.data?.error || err?.message || '导入失败',
|
||||
notes: err?.response?.data?.detail ? [err.response.data.detail] : [],
|
||||
});
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [importFile, importDate, selectedDate, load]);
|
||||
|
||||
// ── 通知设置:读取 / 保存 / 立即测试 ──
|
||||
const applyNotifyConfig = useCallback((config = {}) => {
|
||||
setNotifyConfig(config);
|
||||
setNotifyForm({
|
||||
enabled: config.enabled === true,
|
||||
notifyTime: config.notifyTime || '01:00',
|
||||
recipients: normalizeRecipientList(config.recipients || []),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const loadNotifyConfig = useCallback(async () => {
|
||||
setNotifyLoading(true);
|
||||
try {
|
||||
const res = await getDailyInventoryNotificationConfig();
|
||||
applyNotifyConfig(res?.data ?? {});
|
||||
} catch (err) {
|
||||
setNotifyConfig({ loadError: err?.response?.data?.error || err?.message || '加载通知设置失败' });
|
||||
} finally {
|
||||
setNotifyLoading(false);
|
||||
}
|
||||
}, [applyNotifyConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (notifyOpen) {
|
||||
setNotifyResult(null);
|
||||
loadNotifyConfig();
|
||||
}
|
||||
}, [notifyOpen, loadNotifyConfig]);
|
||||
|
||||
const saveNotifySettings = useCallback(async () => {
|
||||
setNotifySaving(true);
|
||||
setNotifyResult(null);
|
||||
try {
|
||||
const res = await saveDailyInventoryNotificationConfig({
|
||||
enabled: notifyForm.enabled,
|
||||
notifyTime: notifyForm.notifyTime,
|
||||
recipients: normalizeRecipientList(notifyForm.recipients),
|
||||
});
|
||||
applyNotifyConfig(res?.data?.admin ?? res?.data ?? {});
|
||||
setNotifyResult({ type: 'success', text: '通知设置已保存' });
|
||||
} catch (err) {
|
||||
setNotifyResult({
|
||||
type: 'error',
|
||||
text: err?.response?.data?.error || err?.message || '保存通知设置失败',
|
||||
});
|
||||
} finally {
|
||||
setNotifySaving(false);
|
||||
}
|
||||
}, [notifyForm, applyNotifyConfig]);
|
||||
|
||||
const runNotifyTest = useCallback(async () => {
|
||||
setNotifyTesting(true);
|
||||
setNotifyResult(null);
|
||||
try {
|
||||
const res = await runDailyInventoryNotificationTest();
|
||||
const result = res?.data ?? {};
|
||||
if (result.success) {
|
||||
setNotifyResult({
|
||||
type: 'success',
|
||||
text: `测试完成:截图已上传,成功通知 ${(result.recipients || []).filter((r) => r.status === 'sent').length} 人`,
|
||||
url: result.screenshotUrl,
|
||||
});
|
||||
} else {
|
||||
setNotifyResult({ type: 'error', text: `测试未成功:${result.error || result.reason || '未知原因'}` });
|
||||
}
|
||||
loadNotifyConfig();
|
||||
} catch (err) {
|
||||
setNotifyResult({
|
||||
type: 'error',
|
||||
text: err?.response?.data?.error || err?.message || '执行测试失败',
|
||||
});
|
||||
} finally {
|
||||
setNotifyTesting(false);
|
||||
}
|
||||
}, [loadNotifyConfig]);
|
||||
|
||||
const addRecipients = useCallback(() => {
|
||||
const incoming = normalizeRecipientList(String(recipientDraft || '').split(/[,,;;\n]+/));
|
||||
if (!incoming.length) return;
|
||||
setNotifyForm((old) => ({
|
||||
...old,
|
||||
recipients: normalizeRecipientList([...(old.recipients || []), ...incoming]),
|
||||
}));
|
||||
setRecipientDraft('');
|
||||
}, [recipientDraft]);
|
||||
|
||||
const removeRecipient = useCallback((name) => {
|
||||
setNotifyForm((old) => ({
|
||||
...old,
|
||||
recipients: (old.recipients || []).filter((item) => item !== name),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SycmPage className="daily-inventory-page">
|
||||
<div className="daily-inventory-header">
|
||||
<div className="daily-inventory-header__title">
|
||||
<Title level={4} style={{ margin: 0 }}>每日进销存</Title>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => setHelpOpen(true)}
|
||||
className="daily-inventory-header__help"
|
||||
>
|
||||
口径说明
|
||||
</Button>
|
||||
</div>
|
||||
<div className="daily-inventory-header__meta">
|
||||
<span>期初日期:{data?.openingDate || '—'}</span>
|
||||
<span>库存快照日:{data?.latestBizDate || '—'}</span>
|
||||
<span>分级基准时刻:{data?.asOf || '—'}</span>
|
||||
{data?.shipWindow ? (
|
||||
<span>
|
||||
发货统计窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)
|
||||
</span>
|
||||
) : null}
|
||||
{data?.items?.[0]?.closingTakenAt ? (
|
||||
<span>
|
||||
库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tooltip title="切换业务日:导入过聚水潭进销存导出的日期会以官方值显示">
|
||||
<Select
|
||||
style={{ width: 210 }}
|
||||
value={selectedDate || '__latest__'}
|
||||
onChange={(v) => setSelectedDate(v === '__latest__' ? '' : v)}
|
||||
options={dateOptions}
|
||||
/>
|
||||
</Tooltip>
|
||||
{isAdmin ? (
|
||||
<Tooltip title="上传聚水潭「进销存按仓导出」xlsx,按业务日校正期初 / 12 个流水列 / 期末">
|
||||
<Button icon={<UploadOutlined />} onClick={openImport}>导入聚水潭进销存</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{isAdmin ? (
|
||||
<Button icon={<BellOutlined />} onClick={() => setNotifyOpen(true)}>通知设置</Button>
|
||||
) : null}
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load(false)} loading={loading}>刷新</Button>
|
||||
<Tooltip title="跳过 5 分钟缓存,重新扫描订单明细重算周转率与分级(约 15~20 秒)">
|
||||
<Button onClick={() => load(true)} loading={loading}>强制重算</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type={data?.official?.hasData ? 'success' : 'info'}
|
||||
showIcon
|
||||
message={data?.official?.hasData
|
||||
? `已按聚水潭官方进销存校正(${data.latestBizDate}):12 个流水列全部为官方值`
|
||||
: '尚未导入聚水潭官方进销存:流水列为业务库推算值'}
|
||||
description={
|
||||
<span>
|
||||
{data?.official?.hasData ? (
|
||||
<>
|
||||
本页 <b>{data.latestBizDate}</b> 的期初、12 个流水列、期末取自聚水潭「进销存按仓导出」,
|
||||
共 <b>{data.official.warehouseCount}</b> 个仓;
|
||||
{data.official.balanceMismatch?.length
|
||||
? `其中 ${data.official.balanceMismatch.length} 个仓「期初 + 流水 ≠ 期末」,请核对导出文件。`
|
||||
: '「期初 + 流水 = 期末」逐仓闭合。'}
|
||||
{' '}未纳入该导出的仓库仍用业务库推算(仓库列标记为「推算」)。
|
||||
可在右上角切换业务日或重新导入。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
业务库只能还原 4/12 个流水列,且口径与聚水潭进销存不一致(采购按单据日期、销售按订单发货日期),
|
||||
调拨/盘点/加工/其它出入库没有对应单据表。
|
||||
请在右上角「导入聚水潭进销存」上传导出文件,导入后该业务日的期初与 12 个流水列即为官方值。
|
||||
</>
|
||||
)}
|
||||
{data?.staleMetrics ? ` 当前查看的是历史业务日 ${data.latestBizDate},右侧周转率与四级库存量为当前实时口径。` : ''}
|
||||
{noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在聚水潭库存表中无有效库存,周转率与四级库存量显示「—」。` : ''}
|
||||
</span>
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Table
|
||||
className="daily-inventory-table"
|
||||
columns={columns}
|
||||
dataSource={tableRows}
|
||||
rowKey={(record) => record.key || record.warehouse}
|
||||
size="small"
|
||||
bordered
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content', y: 'calc(100vh - 300px)' }}
|
||||
rowClassName={(record) => (record?.isTotal ? 'daily-inventory-row--total' : '')}
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
<Text type="secondary" className="daily-inventory-footer">
|
||||
共 {items.length} 个仓库;蓝色底纹列为运行时实时取数,灰色列为暂无单据来源,其余为本地快照表取值。
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title="定时截图通知"
|
||||
open={notifyOpen}
|
||||
onCancel={() => setNotifyOpen(false)}
|
||||
width={720}
|
||||
destroyOnHidden
|
||||
styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }}
|
||||
footer={(
|
||||
<Space wrap>
|
||||
<Button type="primary" loading={notifySaving} disabled={notifyLoading} onClick={saveNotifySettings}>
|
||||
保存设置
|
||||
</Button>
|
||||
<Tooltip title="真实执行一次截图、上传与通知;测试不会占用正式推送的幂等记录">
|
||||
<Button loading={notifyTesting} disabled={notifyLoading} onClick={runNotifyTest}>
|
||||
立即测试
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onClick={() => setNotifyOpen(false)}>关闭</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Spin spinning={notifyLoading}>
|
||||
{notifyConfig?.loadError ? (
|
||||
<Alert type="error" showIcon message={notifyConfig.loadError} style={{ marginBottom: 12 }} />
|
||||
) : null}
|
||||
|
||||
{notifyConfig?.blockingReasons?.length ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="当前不会自动推送,原因如下"
|
||||
description={<span>{notifyConfig.blockingReasons.join(';')}</span>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="daily-inventory-notify-form">
|
||||
<div className="daily-inventory-notify-row">
|
||||
<span className="daily-inventory-notify-label">启用每日截图通知</span>
|
||||
<Switch
|
||||
checked={notifyForm.enabled}
|
||||
onChange={(checked) => setNotifyForm((old) => ({ ...old, enabled: checked }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="daily-inventory-notify-row">
|
||||
<span className="daily-inventory-notify-label">通知时间</span>
|
||||
<TimePicker
|
||||
format={TIME_FORMAT}
|
||||
minuteStep={5}
|
||||
allowClear={false}
|
||||
value={dayjs(`2026-01-01T${notifyForm.notifyTime}:00`)}
|
||||
onChange={(value) => setNotifyForm((old) => ({
|
||||
...old,
|
||||
notifyTime: value ? value.format(TIME_FORMAT) : '01:00',
|
||||
}))}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<Text type="secondary">({notifyConfig?.timezone || 'Asia/Shanghai'},默认 01:00)</Text>
|
||||
</div>
|
||||
|
||||
<div className="daily-inventory-notify-row">
|
||||
<span className="daily-inventory-notify-label">通知用户</span>
|
||||
<Input
|
||||
allowClear
|
||||
placeholder="输入通知中心中的姓名或别名,回车添加;多个可用逗号分隔"
|
||||
value={recipientDraft}
|
||||
onChange={(e) => setRecipientDraft(e.target.value)}
|
||||
onPressEnter={addRecipients}
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
<Button icon={<PlusOutlined />} onClick={addRecipients} disabled={!String(recipientDraft || '').trim()}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="daily-inventory-notify-recipients">
|
||||
{notifyForm.recipients.length ? (
|
||||
notifyForm.recipients.map((name) => (
|
||||
<Tag key={name} closable onClose={() => removeRecipient(name)}>{name}</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">尚未添加通知用户</Text>
|
||||
)}
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
自动去除空值与重复姓名,最多 {notifyConfig?.maxRecipients || 20} 人。
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{notifyResult ? (
|
||||
<Alert
|
||||
type={notifyResult.type}
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message={notifyResult.text}
|
||||
description={notifyResult.url ? (
|
||||
<a href={notifyResult.url} target="_blank" rel="noreferrer">{notifyResult.url}</a>
|
||||
) : null}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{notifyConfig && !notifyConfig.loadError ? (
|
||||
<div className="daily-inventory-notify-meta">
|
||||
<div>
|
||||
统一通知中心:
|
||||
<Text type={notifyConfig.notificationCenter?.configured ? 'success' : 'warning'} strong>
|
||||
{notifyConfig.notificationCenter?.configured ? '已配置' : '未配置'}
|
||||
</Text>
|
||||
{notifyConfig.notificationCenter?.baseUrl ? (
|
||||
<Text type="secondary">{` ${notifyConfig.notificationCenter.baseUrl}`}</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
对象存储:
|
||||
<Text type={notifyConfig.objectStorage?.configured ? 'success' : 'warning'} strong>
|
||||
{notifyConfig.objectStorage?.configured ? '已配置' : '未配置'}
|
||||
</Text>
|
||||
{notifyConfig.objectStorage?.bucket ? (
|
||||
<Text type="secondary">{` 桶 ${notifyConfig.objectStorage.bucket}`}</Text>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
上次执行:
|
||||
<b>{notifyConfig.lastRunAt ? new Date(notifyConfig.lastRunAt).toLocaleString('zh-CN') : '—'}</b>
|
||||
{notifyConfig.lastRunSuccess === null ? '' : (
|
||||
<Text type={notifyConfig.lastRunSuccess ? 'success' : 'danger'}>
|
||||
{` ${notifyConfig.lastRunSuccess ? '成功' : '失败'}`}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notifyConfig?.runs?.length ? (
|
||||
<div className="daily-inventory-notify-runs">
|
||||
<Text strong>最近执行记录</Text>
|
||||
{notifyConfig.runs.slice(0, 5).map((run, index) => (
|
||||
<div key={`${run.runAt}-${index}`} className="daily-inventory-notify-run">
|
||||
<span>{new Date(run.runAt).toLocaleString('zh-CN')}</span>
|
||||
<Text type={run.success ? 'success' : 'danger'}>
|
||||
{run.success ? '成功' : `失败(${run.error || run.reason || '未知原因'})`}
|
||||
</Text>
|
||||
{run.screenshotUrl ? (
|
||||
<a href={run.screenshotUrl} target="_blank" rel="noreferrer">截图</a>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</Spin>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="导入聚水潭进销存(按仓)"
|
||||
open={importOpen}
|
||||
onCancel={() => setImportOpen(false)}
|
||||
width={620}
|
||||
destroyOnHidden
|
||||
footer={(
|
||||
<Space wrap>
|
||||
<Button type="primary" loading={importing} onClick={submitImport}>开始导入</Button>
|
||||
<Button onClick={() => setImportOpen(false)}>关闭</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<div className="daily-inventory-import">
|
||||
<div className="daily-inventory-import-row">
|
||||
<span className="daily-inventory-import-label">业务日期</span>
|
||||
<DatePicker
|
||||
value={importDate}
|
||||
onChange={(v) => setImportDate(v)}
|
||||
allowClear={false}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Text type="secondary">导出文件对应的进销存日期</Text>
|
||||
</div>
|
||||
<div className="daily-inventory-import-row">
|
||||
<span className="daily-inventory-import-label">导出文件</span>
|
||||
<Upload
|
||||
accept=".xlsx,.xls"
|
||||
maxCount={1}
|
||||
fileList={importFile ? [{ uid: 'import', name: importFile.name, status: 'done' }] : []}
|
||||
beforeUpload={(file) => { setImportFile(file); return false; }}
|
||||
onRemove={() => { setImportFile(null); return true; }}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>选择文件</Button>
|
||||
</Upload>
|
||||
</div>
|
||||
<Text type="secondary">
|
||||
需包含「仓库 / 期初数量 / 12 个流水列 / 期末数量」表头;同一业务日重复导入会整体覆盖。
|
||||
导入后会自动回写本地库存快照,使「今日期初 = 昨日结存」的推算也对齐聚水潭口径。
|
||||
</Text>
|
||||
{importResult ? (
|
||||
<Alert
|
||||
type={importResult.type}
|
||||
showIcon
|
||||
style={{ marginTop: 12 }}
|
||||
message={importResult.text}
|
||||
description={importResult.notes?.length
|
||||
? <span>{importResult.notes.join(';')}</span>
|
||||
: null}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="计算口径说明"
|
||||
open={helpOpen}
|
||||
onCancel={() => setHelpOpen(false)}
|
||||
footer={null}
|
||||
width={720}
|
||||
>
|
||||
<div className="daily-inventory-help">
|
||||
{FORMULA_DESCRIPTION.map((line, i) => (
|
||||
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default DailyInventory;
|
||||
@ -1,184 +0,0 @@
|
||||
.daily-inventory-page {
|
||||
padding: 16px 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.daily-inventory-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.daily-inventory-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.daily-inventory-header__help {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.daily-inventory-header__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.daily-inventory-th-tip {
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
|
||||
cursor: help;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daily-inventory-table .ant-table-thead > tr > th {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.daily-inventory-table .ant-table-tbody > tr > td {
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 运行时取数的列:加淡蓝底纹,与基准值区分 */
|
||||
.daily-inventory-table .daily-inventory-col--runtime {
|
||||
background: rgba(22, 119, 255, 0.04);
|
||||
}
|
||||
|
||||
/* 暂无单据来源的列:淡灰底纹,提示该列待 DBA 补表 */
|
||||
.daily-inventory-table .daily-inventory-col--unavailable {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.daily-inventory-cell--na {
|
||||
color: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.daily-inventory-warehouse {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daily-inventory-cell--out {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.daily-inventory-cell--in {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
|
||||
.daily-inventory-cell--zero {
|
||||
color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* 业务库推算值:标灰 + 斜体,提示与聚水潭官方口径存在差异 */
|
||||
.daily-inventory-cell--derived {
|
||||
color: rgba(0, 0, 0, 0.42);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* 仓库名后的来源标签 */
|
||||
.daily-inventory-tag {
|
||||
margin-left: 6px;
|
||||
transform: scale(0.85);
|
||||
transform-origin: left center;
|
||||
}
|
||||
|
||||
/* 导入聚水潭进销存弹窗 */
|
||||
.daily-inventory-import {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.daily-inventory-import-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-inventory-import-label {
|
||||
width: 72px;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.daily-inventory-row--total > td {
|
||||
font-weight: 600;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* 定时截图通知设置弹窗 */
|
||||
.daily-inventory-notify-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-label {
|
||||
width: 120px;
|
||||
flex: none;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.daily-inventory-notify-recipients {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-left: 128px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-meta {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-runs {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.daily-inventory-notify-run {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.daily-inventory-footer {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.daily-inventory-help p {
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.7;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.daily-inventory-help p.is-indent {
|
||||
padding-left: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Progress, Segmented, Select, Space, Spin, Switch,
|
||||
Alert, Button, Card, Collapse, Dropdown, Input, Modal, Segmented, Select, Space, Spin, Switch,
|
||||
Table, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import {
|
||||
@ -17,8 +17,6 @@ import {
|
||||
checkMkhReplenishAlert,
|
||||
syncMkhBom,
|
||||
} from '../../api/mkhBom';
|
||||
import { createRequestGate } from '../../utils/requestGate';
|
||||
import { requireMkhFreshContract } from '../../utils/mkhFreshContract';
|
||||
import { exportTableFile } from '../../utils/tableExport';
|
||||
import './style.css';
|
||||
|
||||
@ -34,22 +32,6 @@ const number = (value, digits = 0) => (value === null || value === undefined ||
|
||||
const text = (value) => (value === null || value === undefined || value === '' ? '--' : value);
|
||||
|
||||
const EMPTY_FILTERS = { keyword: '', scope: 'warning' };
|
||||
// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询
|
||||
// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。
|
||||
const AUTO_REFRESH_MS = 2 * 60 * 1000;
|
||||
|
||||
const getRefreshProgress = (elapsedSeconds) => {
|
||||
if (elapsedSeconds < 3) {
|
||||
return { percent: 12 + elapsedSeconds * 8, stage: '正在读取实时库存、箱表和加工单…' };
|
||||
}
|
||||
if (elapsedSeconds < 15) {
|
||||
return { percent: 36 + (elapsedSeconds - 3) * 3, stage: '正在核对待发货订单和近15天销量…' };
|
||||
}
|
||||
return {
|
||||
percent: Math.min(95, 72 + Math.floor((elapsedSeconds - 15) / 2)),
|
||||
stage: '正在合并最新数据与 BOM 配件结果…',
|
||||
};
|
||||
};
|
||||
|
||||
// 预警等级 → 标签配色
|
||||
const WARNING_TONE = {
|
||||
@ -165,18 +147,6 @@ const ALERT_RECIPIENT_SOURCE_TEXT = {
|
||||
none: '未找到可用的收件人',
|
||||
};
|
||||
|
||||
// 「现有库存」列的取值来源说明(后端 stockSource 字段):
|
||||
// realtime = 品牌仓实时库存表(jst_inventoryInfo,日更)
|
||||
// pack = 品牌仓箱/货位表(jst_packInventoryInfo,小时级)——
|
||||
// 实时库存表当天还没同步到这条货(当日入库)时用它兜底
|
||||
// monitor = 两个实时源都没有记录,退回监控快照库存
|
||||
const STOCK_SOURCE_TEXT = {
|
||||
realtime: '品牌仓实时库存',
|
||||
pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)',
|
||||
monitor: '品牌仓实时库存缺失,取监控快照库存',
|
||||
'live-missing': '实时库存表和箱/货位库存表均无记录,实时口径按 0 计算',
|
||||
};
|
||||
|
||||
// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位
|
||||
// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」),
|
||||
// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。
|
||||
@ -280,7 +250,6 @@ const MkhBomData = () => {
|
||||
// 导出(当前筛选条件下的全部数据)
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [refreshProgress, setRefreshProgress] = useState({ open: false, elapsedSeconds: 0, title: '' });
|
||||
|
||||
// 补货预警推送设置
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
@ -293,37 +262,8 @@ const MkhBomData = () => {
|
||||
const [centerSaving, setCenterSaving] = useState(false);
|
||||
|
||||
const tableWrapRef = useRef(null);
|
||||
const listRequestGateRef = useRef(createRequestGate());
|
||||
const detailGenerationRef = useRef(0);
|
||||
const refreshProgressGenerationRef = useRef(0);
|
||||
const lastListRefreshAtRef = useRef(0);
|
||||
const autoRefreshPendingRef = useRef(false);
|
||||
const [bodyHeight, setBodyHeight] = useState(420);
|
||||
|
||||
const beginRefreshProgress = useCallback((title) => {
|
||||
refreshProgressGenerationRef.current += 1;
|
||||
const generation = refreshProgressGenerationRef.current;
|
||||
setRefreshProgress({ open: true, elapsedSeconds: 0, title, generation });
|
||||
return generation;
|
||||
}, []);
|
||||
|
||||
const endRefreshProgress = useCallback((generation) => {
|
||||
if (generation !== refreshProgressGenerationRef.current) return;
|
||||
setRefreshProgress((current) => ({ ...current, open: false }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!refreshProgress.open) return undefined;
|
||||
const startedAt = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setRefreshProgress((current) => ({
|
||||
...current,
|
||||
elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)),
|
||||
}));
|
||||
}, 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshProgress.open]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = tableWrapRef.current;
|
||||
if (!el) return undefined;
|
||||
@ -358,72 +298,25 @@ const MkhBomData = () => {
|
||||
return params;
|
||||
}, [page, pageSize, sorter, filters]);
|
||||
|
||||
const load = useCallback(async ({ background = false } = {}) => {
|
||||
const requestGeneration = listRequestGateRef.current.begin();
|
||||
let showProgress = false;
|
||||
let progressGeneration = null;
|
||||
if (!background) setLoading(true);
|
||||
if (!background) setError('');
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const now = Date.now();
|
||||
const forceFresh = !lastListRefreshAtRef.current
|
||||
|| now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS;
|
||||
showProgress = forceFresh && !background;
|
||||
if (showProgress) progressGeneration = beginRefreshProgress('正在更新最新业务数据');
|
||||
// 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。
|
||||
// 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。
|
||||
const result = await getMkhBomList({
|
||||
...query,
|
||||
live: 1,
|
||||
...(forceFresh ? { fresh: 1 } : null),
|
||||
_refresh: now,
|
||||
});
|
||||
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
|
||||
const result = await getMkhBomList(query);
|
||||
if (!result || result.success === false) {
|
||||
setError(result?.error || '加载 MKH-BOM 资料失败');
|
||||
return;
|
||||
}
|
||||
const snapshot = result?.data || null;
|
||||
try {
|
||||
requireMkhFreshContract(snapshot, { requireApplied: forceFresh });
|
||||
} catch (contractError) {
|
||||
setData({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false });
|
||||
throw contractError;
|
||||
}
|
||||
setData(snapshot);
|
||||
if (forceFresh) lastListRefreshAtRef.current = Date.now();
|
||||
setError('');
|
||||
setData(result?.data || { rows: [], total: 0, page: 1, pageSize: 50 });
|
||||
} catch (requestError) {
|
||||
if (!listRequestGateRef.current.isCurrent(requestGeneration)) return;
|
||||
setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败');
|
||||
} finally {
|
||||
if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false);
|
||||
if (showProgress) endRefreshProgress(progressGeneration);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [beginRefreshProgress, endRefreshProgress, query]);
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。
|
||||
// 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。
|
||||
useEffect(() => {
|
||||
const refreshIfStale = () => {
|
||||
if (document.visibilityState !== 'visible' || syncing || autoRefreshPendingRef.current) return;
|
||||
if (Date.now() - lastListRefreshAtRef.current < AUTO_REFRESH_MS) return;
|
||||
autoRefreshPendingRef.current = true;
|
||||
load({ background: true }).finally(() => {
|
||||
autoRefreshPendingRef.current = false;
|
||||
});
|
||||
};
|
||||
|
||||
const timer = window.setInterval(refreshIfStale, AUTO_REFRESH_MS);
|
||||
document.addEventListener('visibilitychange', refreshIfStale);
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', refreshIfStale);
|
||||
};
|
||||
}, [load, syncing]);
|
||||
|
||||
const changeFilter = (key) => (value) => {
|
||||
setFilters((old) => ({ ...old, [key]: value || '' }));
|
||||
setPage(1);
|
||||
@ -578,23 +471,18 @@ const MkhBomData = () => {
|
||||
|
||||
const loadDetail = useCallback(async (skuId) => {
|
||||
if (detailCache[skuId] || detailLoading[skuId]) return;
|
||||
const detailGeneration = detailGenerationRef.current;
|
||||
setDetailLoading((old) => ({ ...old, [skuId]: true }));
|
||||
try {
|
||||
const result = await getMkhBomDetail(skuId);
|
||||
if (detailGeneration !== detailGenerationRef.current) return;
|
||||
if (!result || result.success === false) {
|
||||
message.error(result?.error || '加载配件明细失败');
|
||||
return;
|
||||
}
|
||||
setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } }));
|
||||
} catch (e) {
|
||||
if (detailGeneration !== detailGenerationRef.current) return;
|
||||
message.error(e?.response?.data?.error || e?.message || '加载配件明细失败');
|
||||
} finally {
|
||||
if (detailGeneration === detailGenerationRef.current) {
|
||||
setDetailLoading((old) => ({ ...old, [skuId]: false }));
|
||||
}
|
||||
setDetailLoading((old) => ({ ...old, [skuId]: false }));
|
||||
}
|
||||
}, [detailCache, detailLoading]);
|
||||
|
||||
@ -655,18 +543,13 @@ const MkhBomData = () => {
|
||||
const text = Number(value) > 0 ? number(value, 2) : <span className="mkh-muted">0</span>;
|
||||
// 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。
|
||||
// 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。
|
||||
if (!String(row.dailySaleSource || '').startsWith('orders')) return text;
|
||||
const liveMode = row.dailySaleSource === 'orders-live';
|
||||
if (row.dailySaleSource !== 'orders') return text;
|
||||
return (
|
||||
<Tooltip
|
||||
title={[
|
||||
liveMode
|
||||
? `实时口径未采用延迟快照(快照值 ${number(row.snapshotDailySale, 2)})`
|
||||
: `上游近15天日销快照为 ${number(row.snapshotDailySale, 2)}(漏算)`,
|
||||
`上游近15天日销快照为 ${number(row.snapshotDailySale, 2)}(漏算)`,
|
||||
`此处按订单实算:近15天已发货 ${number(row.recentShippedQty)} 件 ÷ 15 天`,
|
||||
liveMode
|
||||
? '已排除样品/测试专用店铺;点击同步会重新读取当前订单和库存数据'
|
||||
: '已排除样品/测试专用店铺;上游修好后会自动以上游数字为准',
|
||||
'已排除样品/测试专用店铺;上游修好后会自动以上游数字为准',
|
||||
].join(';')}
|
||||
>
|
||||
<span className="mkh-sale-fallback">{text}</span>
|
||||
@ -681,7 +564,9 @@ const MkhBomData = () => {
|
||||
align: 'right',
|
||||
render: (value, row) => (
|
||||
<Tooltip
|
||||
title={STOCK_SOURCE_TEXT[row.stockSource] || STOCK_SOURCE_TEXT.monitor}
|
||||
title={row.stockSource === 'realtime'
|
||||
? '品牌仓实时库存'
|
||||
: '品牌仓实时库存缺失,取监控快照库存'}
|
||||
>
|
||||
<span>{number(value)}</span>
|
||||
</Tooltip>
|
||||
@ -897,63 +782,24 @@ const MkhBomData = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 手动同步:服务端清缓存后在同一次请求中直连副本库返回新快照。
|
||||
// 请求代际会阻止同步前尚未完成的旧请求覆盖这个新快照。
|
||||
// 手动同步:清掉服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 仓位),
|
||||
// 再拉一次列表即可直连副本库取最新快照;可随时点击,不必等凌晨的定时刷新
|
||||
const handleSync = async () => {
|
||||
setSyncing(true);
|
||||
setLoading(true);
|
||||
const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据');
|
||||
// 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。
|
||||
listRequestGateRef.current.invalidate();
|
||||
detailGenerationRef.current += 1;
|
||||
setDetailCache({});
|
||||
setDetailLoading({});
|
||||
setExpandedKeys({});
|
||||
const syncGeneration = listRequestGateRef.current.begin();
|
||||
try {
|
||||
const result = await syncMkhBom(query);
|
||||
if (!listRequestGateRef.current.isCurrent(syncGeneration)) return;
|
||||
if (!result || result.success === false || !result?.data?.snapshot) {
|
||||
throw new Error(result?.error || '同步接口未返回数据库快照');
|
||||
}
|
||||
setData(requireMkhFreshContract(result.data.snapshot, { requireApplied: true }));
|
||||
lastListRefreshAtRef.current = Date.now();
|
||||
setError('');
|
||||
message.success('已实时读取最新订单、库存、BOM 与仓位数据');
|
||||
await syncMkhBom();
|
||||
setDetailCache({});
|
||||
await load();
|
||||
message.success('已同步最新数据');
|
||||
} catch (syncError) {
|
||||
if (listRequestGateRef.current.isCurrent(syncGeneration)) {
|
||||
setError(syncError?.response?.data?.error || syncError?.message || '同步失败');
|
||||
}
|
||||
message.error(syncError?.response?.data?.error || syncError?.message || '同步失败');
|
||||
} finally {
|
||||
if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false);
|
||||
setSyncing(false);
|
||||
endRefreshProgress(progressGeneration);
|
||||
}
|
||||
};
|
||||
|
||||
const progressView = getRefreshProgress(refreshProgress.elapsedSeconds);
|
||||
|
||||
return (
|
||||
<SycmPage className="mkh-page">
|
||||
<Modal
|
||||
open={refreshProgress.open}
|
||||
title={refreshProgress.title}
|
||||
footer={null}
|
||||
closable={false}
|
||||
keyboard={false}
|
||||
maskClosable={false}
|
||||
centered
|
||||
width={480}
|
||||
>
|
||||
<Space direction="vertical" size={14} style={{ width: '100%' }}>
|
||||
<Progress percent={progressView.percent} status="active" strokeColor="#1677ff" />
|
||||
<Text>{progressView.stage}</Text>
|
||||
<Text type="secondary">已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。</Text>
|
||||
<Text type="secondary">进度为查询阶段估算,实际完成以服务端返回为准。</Text>
|
||||
</Space>
|
||||
</Modal>
|
||||
|
||||
<header className="mkh-header">
|
||||
<div className="mkh-header__heading">
|
||||
<Title level={3}>MKH-BOM 资料</Title>
|
||||
@ -966,8 +812,8 @@ const MkhBomData = () => {
|
||||
无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo,
|
||||
优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。
|
||||
<br />
|
||||
补货预警:近15天日销按已出库订单实时计算,现有库存取品牌仓当前库存,并用箱/货位库存补充当日入库;
|
||||
实时查询不回退延迟监控快照。<b>结余库存 = 现有库存 − 待发货订单</b>(已下单未发货的部分已被占用,不足 0 取 0),
|
||||
补货预警:近15天日销取聚水潭库存监控快照(监控日 {data.monitorDate || '—'}),现有库存优先取品牌仓实时库存、
|
||||
该仓无记录时回退监控快照库存;<b>结余库存 = 现有库存 − 待发货订单</b>(已下单未发货的部分已被占用,不足 0 取 0),
|
||||
可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货;
|
||||
建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。
|
||||
无日销且无订单的商品不参与预警。
|
||||
@ -1008,8 +854,8 @@ const MkhBomData = () => {
|
||||
]}
|
||||
/>
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={handleSync} loading={syncing}>刷新数据</Button>
|
||||
<Tooltip title="立即清除服务端缓存,实时重查当前订单、库存、BOM 与仓位数据">
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新数据</Button>
|
||||
<Tooltip title="立即重新拉取副本库最新数据(清服务端缓存后重查),可随时点击,不必等凌晨的定时刷新">
|
||||
<Button type="primary" icon={<SyncOutlined />} onClick={handleSync} loading={syncing}>同步最新数据</Button>
|
||||
</Tooltip>
|
||||
<Dropdown
|
||||
@ -1023,7 +869,7 @@ const MkhBomData = () => {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={handleSync}>重试</Button>} /> : null}
|
||||
{error ? <Alert type="error" showIcon message={error} action={<Button size="small" onClick={load}>重试</Button>} /> : null}
|
||||
|
||||
{!loading && !error && data.workOrderDegraded ? (
|
||||
<Alert
|
||||
@ -1031,7 +877,7 @@ const MkhBomData = () => {
|
||||
type="warning"
|
||||
showIcon
|
||||
message="加工单数据源暂时不可用,在途加工量按 0 处理,预警结果可能偏多,请稍后点击「刷新数据」重试。"
|
||||
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
|
||||
action={<Button size="small" onClick={load}>刷新数据</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -1041,7 +887,7 @@ const MkhBomData = () => {
|
||||
type="warning"
|
||||
showIcon
|
||||
message="待发货订单数据源暂时不可用,待发货量按 0 处理,可能遗漏「近15天无日销但有订单」的商品,请稍后点击「刷新数据」重试。"
|
||||
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
|
||||
action={<Button size="small" onClick={load}>刷新数据</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -1051,7 +897,7 @@ const MkhBomData = () => {
|
||||
type="warning"
|
||||
showIcon
|
||||
message="配件库存数据源暂时不可用,本次未做「配件不足」判定,可能存在配件凑不齐、补不出来的商品,请稍后点击「刷新数据」重试。"
|
||||
action={<Button size="small" onClick={handleSync}>刷新数据</Button>}
|
||||
action={<Button size="small" onClick={load}>刷新数据</Button>}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@ -1063,7 +909,7 @@ const MkhBomData = () => {
|
||||
message={`补货预警:${data.warningCount} 个 MKH 商品需要补货`}
|
||||
description={(
|
||||
<span>
|
||||
按近 {data.saleWindowDays || 15} 天已出库订单实时核算:
|
||||
按近 {data.saleWindowDays || 15} 天日销核算(监控日 {data.monitorDate || '—'}):
|
||||
其中 <b>{data.outOfStockCount || 0}</b> 个已缺货,
|
||||
<b>{Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))}</b> 个可用天数不足 {data.replenishDaysThreshold || 3} 天。
|
||||
建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。
|
||||
|
||||
@ -271,10 +271,7 @@ const AccessoryStockPanel = () => {
|
||||
: int(value)),
|
||||
},
|
||||
{
|
||||
title: <TipTitle
|
||||
title="实时库存"
|
||||
tip="优先取目标仓 jst_inventoryInfo;该表为 0 或缺少记录、但箱及仓位库存已有正库存时,用同仓 jst_packInventoryInfo 合计补齐"
|
||||
/>,
|
||||
title: '实时库存',
|
||||
dataIndex: 'realtimeStock',
|
||||
width: 96,
|
||||
align: 'right',
|
||||
@ -284,7 +281,7 @@ const AccessoryStockPanel = () => {
|
||||
{
|
||||
title: <TipTitle
|
||||
title="采购在途数量"
|
||||
tip="采购单中『已下单 − 已入库』的数量,取自 jst_purchaseItem.qty − inQty;它与实时库存独立,上游采购到货字段滞后时可能同时显示库存和在途"
|
||||
tip="该商品(配件)全部采购单中『已下单 − 已入库』的未到货数量合计,取自 jst_purchaseItem.qty − inQty"
|
||||
/>,
|
||||
dataIndex: 'purchaseTransit',
|
||||
width: 120,
|
||||
|
||||
@ -1,288 +0,0 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Col, Input, Modal, Row, Select, Space, Spin, Statistic, Table, Tag, Tooltip, Typography, message,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import {
|
||||
exportPickingBinSalesAnalysis,
|
||||
getPickingBinSalesAnalysis,
|
||||
} from '../../api/pickingBinSalesAnalysis';
|
||||
import { downloadBlobAsFile, readBlobErrorMessage } from '../../utils/tableExport';
|
||||
import {
|
||||
PICKING_BIN_COLUMNS,
|
||||
PICKING_BIN_WINDOW_OPTIONS,
|
||||
PICKING_BIN_DEFAULT_DAYS,
|
||||
PICKING_BIN_GRADE_COLORS,
|
||||
PICKING_BIN_DESCRIPTION,
|
||||
} from '../../constants/pickingBinSalesAnalysis';
|
||||
import './style.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const int = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
};
|
||||
|
||||
const num = (value, digits = 2) => {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return Number(value).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
});
|
||||
};
|
||||
|
||||
const TipTitle = ({ tip, children }) => (
|
||||
<Tooltip title={tip}>
|
||||
<span className="picking-bin-th-tip">{children}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const PickingBinSalesAnalysis = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [data, setData] = useState(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [days, setDays] = useState(PICKING_BIN_DEFAULT_DAYS);
|
||||
const [warehouse, setWarehouse] = useState('');
|
||||
const [grades, setGrades] = useState([]);
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const params = useMemo(() => ({
|
||||
days,
|
||||
...(warehouse ? { wmsCoId: warehouse } : {}),
|
||||
}), [days, warehouse]);
|
||||
|
||||
const load = useCallback(async (options = {}) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await getPickingBinSalesAnalysis({
|
||||
...(options.params ?? params),
|
||||
...(options.force ? { refresh: '1' } : {}),
|
||||
});
|
||||
setData(res?.data ?? null);
|
||||
} catch (err) {
|
||||
setError(err?.response?.data?.error || err?.message || '拣选库位动销分析数据加载失败');
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const items = useMemo(() => data?.items ?? [], [data]);
|
||||
const gradeMap = useMemo(() => new Map((data?.grades ?? []).map((g) => [g.key, g])), [data]);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
return items.filter((it) => {
|
||||
if (grades.length > 0 && !grades.includes(it.gradeKey)) return false;
|
||||
if (kw
|
||||
&& !String(it.binName).toLowerCase().includes(kw)
|
||||
&& !String(it.skuId).toLowerCase().includes(kw)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [items, grades, keyword]);
|
||||
|
||||
const columns = useMemo(() => PICKING_BIN_COLUMNS.map((col) => ({
|
||||
title: <TipTitle tip={col.tip}>{col.label}</TipTitle>,
|
||||
key: col.key,
|
||||
dataIndex: col.key,
|
||||
align: ['warehouse', 'binName', 'binType', 'skuId', 'gradeKey'].includes(col.key) ? 'left' : 'right',
|
||||
width: col.key === 'warehouse' ? 240
|
||||
: (col.key === 'gradeKey' ? 170 : (col.key === 'skuId' ? 150 : 110)),
|
||||
sorter: ['stockQty', 'salesQty', 'dailySales'].includes(col.key)
|
||||
? (a, b) => (a[col.key] ?? 0) - (b[col.key] ?? 0)
|
||||
: undefined,
|
||||
render: (value) => {
|
||||
if (col.key === 'gradeKey') {
|
||||
const g = gradeMap.get(value);
|
||||
return <Tag color={PICKING_BIN_GRADE_COLORS[value] || 'default'}>{g?.label || value}</Tag>;
|
||||
}
|
||||
if (col.key === 'binType' || col.key === 'skuId') return <Text>{value || '—'}</Text>;
|
||||
if (col.key === 'dailySales') return num(value, 2);
|
||||
if (col.key === 'salesQty') return num(value, 2);
|
||||
if (col.key === 'stockQty') return int(value);
|
||||
return value || '—';
|
||||
},
|
||||
})), [gradeMap]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const blob = await exportPickingBinSalesAnalysis({
|
||||
...params,
|
||||
...(grades.length > 0 ? { grade: grades.join(',') } : {}),
|
||||
format: 'xlsx',
|
||||
});
|
||||
const dateKey = (data?.latestBizDate || '').replace(/-/g, '') || '未知日期';
|
||||
const fileName = `拣选库位动销分析-${dateKey}.xlsx`;
|
||||
await downloadBlobAsFile(blob, fileName);
|
||||
message.success(`已开始下载:${fileName}`);
|
||||
} catch (exportError) {
|
||||
message.error(await readBlobErrorMessage(exportError, '库位动销明细导出失败'));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [params, grades, data?.latestBizDate]);
|
||||
|
||||
const toggleGrade = useCallback((key) => {
|
||||
setGrades((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||
}, []);
|
||||
|
||||
const summary = data?.summary ?? [];
|
||||
const total = data?.total ?? null;
|
||||
|
||||
return (
|
||||
<SycmPage className="picking-bin-page">
|
||||
<div className="picking-bin-header">
|
||||
<div className="picking-bin-header__title">
|
||||
<Title level={4} style={{ margin: 0 }}>拣选库位动销分析</Title>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => setHelpOpen(true)}
|
||||
className="picking-bin-header__help"
|
||||
>
|
||||
口径说明
|
||||
</Button>
|
||||
</div>
|
||||
<div className="picking-bin-header__meta">
|
||||
<span>统计基准日:{data?.latestBizDate || '—'}</span>
|
||||
{data?.shipWindow ? (
|
||||
<span>销量窗口:{data.shipWindow.shipFrom} ~ {data.shipWindow.shipTo}(近 {data.shipWindow.days} 天)</span>
|
||||
) : null}
|
||||
{data?.takenAt ? (
|
||||
<span>仓位数据时间:{new Date(data.takenAt).toLocaleString('zh-CN', { hour12: false })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Space size={4}>
|
||||
<Text type="secondary">统计天数</Text>
|
||||
<Select
|
||||
value={days}
|
||||
options={PICKING_BIN_WINDOW_OPTIONS}
|
||||
onChange={setDays}
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
</Space>
|
||||
<Select
|
||||
allowClear
|
||||
value={warehouse || undefined}
|
||||
options={(data?.warehouses ?? []).map((w) => ({ label: w.warehouse, value: w.wmsCoId }))}
|
||||
onChange={(v) => setWarehouse(v || '')}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ minWidth: 240 }}
|
||||
placeholder="全部仓库"
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={keyword}
|
||||
onChange={(e) => setKeyword(e.target.value)}
|
||||
placeholder="搜索仓位 / 商品编码"
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => load()} loading={loading}>刷新</Button>
|
||||
<Tooltip title="跳过 5 分钟缓存,重新扫描订单与仓位明细重算(约 5~20 秒)">
|
||||
<Button onClick={() => load({ force: true })} loading={loading}>强制重算</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="导出当前筛选下的明细(仓库 / 仓位 / 类型 / 商品编码 / 库存数量 / 仓位SKU数 / 商品销量 / 分摊销量 / 商品日销 / 动销等级),仅含拣选位。按「仓位 × 商品编码」拆行;商品销量为整档销量(同一商品跨仓位重复,不可直接求和),需要求和请用「分摊销量」列;动销等级按商品日销判定">
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出明细</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`按「商品日均销量(商品日销)」划分 ${summary.length} 个动销等级,一行 = 一个「仓位 × 商品编码」组合`}
|
||||
description="商品日销 = 该商品近 N 天销量 ÷ N,不做仓位分摊,因此同一件商品放在几个仓位都是同一等级。商品销量跨仓位重复显示,不能直接按行求和(需要求和请导出用「分摊销量」列)。点击下方等级卡片可筛选。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
|
||||
{error ? <Alert type="error" showIcon message={error} style={{ marginBottom: 12 }} /> : null}
|
||||
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={[8, 8]} className="picking-bin-summary">
|
||||
{summary.map((s) => (
|
||||
<Col key={s.key} flex="1 1 150px">
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => toggleGrade(s.key)}
|
||||
className={grades.includes(s.key) ? 'picking-bin-card is-active' : 'picking-bin-card'}
|
||||
>
|
||||
<Tooltip title={`${s.tip};明细 ${int(s.rowCount)} 行,库存合计 ${int(s.stockQty)} 件,日销合计 ${num(s.dailySales, 1)}(销量按去重商品计)`}>
|
||||
<Statistic
|
||||
title={s.label}
|
||||
value={s.skuCount}
|
||||
suffix="品"
|
||||
valueStyle={{ fontSize: 18 }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className="picking-bin-card__meta">
|
||||
明细 {int(s.rowCount)} 行 · 库存 {int(s.stockQty)} · 日销 {num(s.dailySales, 1)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
{total ? (
|
||||
<Col flex="1 1 150px">
|
||||
<Card size="small" className="picking-bin-card is-total">
|
||||
<Tooltip title={`去重商品 ${int(total.skuCount)} 个,涉及「仓位 × 商品」组合 ${int(total.rowCount)} 行`}>
|
||||
<Statistic title={total.label} value={total.skuCount} suffix="品" valueStyle={{ fontSize: 18 }} />
|
||||
</Tooltip>
|
||||
<div className="picking-bin-card__meta">
|
||||
明细 {int(total.rowCount)} 行 · 库存 {int(total.stockQty)} · 日销 {num(total.dailySales, 1)}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
) : null}
|
||||
</Row>
|
||||
|
||||
<Table
|
||||
className="picking-bin-table"
|
||||
columns={columns}
|
||||
dataSource={filteredRows}
|
||||
rowKey={(r) => `${r.wmsCoId}::${r.binName}::${r.skuId}`}
|
||||
size="small"
|
||||
bordered
|
||||
pagination={{
|
||||
pageSize: 50,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [50, 100, 200],
|
||||
showTotal: (t) => `共 ${t} 条明细`,
|
||||
}}
|
||||
scroll={{ x: 'max-content', y: 'calc(100vh - 420px)' }}
|
||||
/>
|
||||
</Spin>
|
||||
|
||||
<Text type="secondary" className="picking-bin-footer">
|
||||
当前筛选出 {filteredRows.length} 条明细
|
||||
{items.length !== filteredRows.length ? `(共 ${items.length} 条)` : ''};
|
||||
去重商品 {int(data?.total?.skuCount ?? 0)} 个;统计范围:仅拣选位。
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title="计算口径说明"
|
||||
open={helpOpen}
|
||||
onCancel={() => setHelpOpen(false)}
|
||||
footer={null}
|
||||
width={760}
|
||||
>
|
||||
<div className="picking-bin-help">
|
||||
{PICKING_BIN_DESCRIPTION.map((line, i) => (
|
||||
<p key={i} className={line.startsWith(' ') ? 'is-indent' : ''}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default PickingBinSalesAnalysis;
|
||||
@ -1,95 +0,0 @@
|
||||
.picking-bin-page {
|
||||
padding: 16px 20px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.picking-bin-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.picking-bin-header__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.picking-bin-header__help {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.picking-bin-header__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
font-size: 13px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.picking-bin-th-tip {
|
||||
border-bottom: 1px dashed rgba(0, 0, 0, 0.35);
|
||||
cursor: help;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.picking-bin-summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.picking-bin-card .ant-statistic-title {
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.picking-bin-card.is-active {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.picking-bin-card.is-total {
|
||||
background: rgba(22, 119, 255, 0.06);
|
||||
}
|
||||
|
||||
.picking-bin-card__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.picking-bin-table .ant-table-thead > tr > th {
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.picking-bin-table .ant-table-tbody > tr > td {
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.picking-bin-footer {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.picking-bin-help p {
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.7;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.picking-bin-help p.is-indent {
|
||||
padding-left: 16px;
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import React from 'react';
|
||||
import { SycmPage } from '../../components/Common';
|
||||
import StockWarning from './StockWarning';
|
||||
import { resolveWarehouseConfig } from './warehouses';
|
||||
import './monitor.css';
|
||||
|
||||
/**
|
||||
* 客退数据监控(按仓库出数)
|
||||
*
|
||||
* 同一个页面支撑 5 张看板:崔桥工厂仓 / 苏北 / 睢宁 / 山东 / 河南。
|
||||
* 结构完全一致,差异只有「取哪个仓」——由路由传入的 warehouseKey 决定,
|
||||
* 页面本身不拼任何仓库相关的 SQL 或口径。
|
||||
*
|
||||
* 页面已收敛为单一「退货库存预警」视图:
|
||||
* 原先并列的「退货入库明细 / 超期清单 / 按商品编码汇总 / 差异清单」四个页签已下线,
|
||||
* 因此这里不再保留页签容器,也不再拉取核销看板数据(对应接口保留在服务端,未删除)。
|
||||
*/
|
||||
const Monitor = ({ warehouseKey }) => {
|
||||
const warehouse = resolveWarehouseConfig(warehouseKey);
|
||||
|
||||
return (
|
||||
<SycmPage className="rim-page">
|
||||
<div className="rim-hero">
|
||||
<div>
|
||||
<h1 className="rim-hero__title">{warehouse.title}</h1>
|
||||
<p className="rim-hero__desc">
|
||||
数据取自聚水潭「箱及仓位库存 · {warehouse.warehouseName} · {warehouse.returnBinName}」:
|
||||
在库天数 = 今天 − 创建时间,超过预警天数标红。
|
||||
创建时间、件数口径与退厂判定说明见页面底部「数据来源与口径说明」。
|
||||
</p>
|
||||
{warehouse.hasReturnStock && warehouse.returnSlot !== 'customize_qty_1' ? (
|
||||
<p className="rim-hero__desc" style={{ marginTop: 4 }}>
|
||||
注:该仓位在业务库里占用的是 <b>{warehouse.returnSlot}</b>
|
||||
(崔桥工厂仓 / 苏北 / 睢宁用的是 customize_qty_1),页面已按这个位取数。
|
||||
</p>
|
||||
) : null}
|
||||
{!warehouse.hasReturnStock ? (
|
||||
<p className="rim-hero__desc" style={{ marginTop: 4 }}>
|
||||
提示:该仓位当前在业务库副本里没有客退在库数据(三个自定义库存位全为 0),表格会是空的;
|
||||
等聚水潭侧有客退库存同步进来后会自动出现内容,也可以用「导入」上传聚水潭导出的文件。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StockWarning warehouse={warehouse.key} />
|
||||
</SycmPage>
|
||||
);
|
||||
};
|
||||
|
||||
export default Monitor;
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,485 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Empty, InputNumber, Tooltip } from 'antd';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { sycmEchartsBase } from '../../design-system/chartTheme';
|
||||
|
||||
/**
|
||||
* 客退数据监控 · 可视化分析(折叠区内容)
|
||||
*
|
||||
* 只负责「看」:单价预警阈值 + 可点击的单价预警汇总条 + 4 张分布图。
|
||||
* 数据全部由父组件(StockWarning.jsx)传进来,这里不做任何请求。
|
||||
*
|
||||
* 页面的指标卡与「退厂要求」提示条已上移到父组件的主视图里,
|
||||
* 这里保留的都是在默认视图下折叠收起的内容,保证主视图足够干净。
|
||||
*/
|
||||
|
||||
const num = (value) => Number(value || 0).toLocaleString('zh-CN');
|
||||
const money = (value) => Number(value || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
/** 图表轴上的金额压缩显示:过万折成「万」,避免坐标轴被长数字撑爆 */
|
||||
const compactMoney = (value) => {
|
||||
const parsed = Number(value || 0);
|
||||
if (Math.abs(parsed) >= 10000) return `${(parsed / 10000).toFixed(1)}万`;
|
||||
return parsed.toLocaleString('zh-CN', { maximumFractionDigits: 0 });
|
||||
};
|
||||
|
||||
// 单价预警四挡(与后端 returnStockWarningService.PRICE_WARNING_LABELS 保持一致)
|
||||
const PRICE_ORDER = ['high', 'focus', 'normal', 'unknown'];
|
||||
const PRICE_TONES = {
|
||||
high: { label: '重点关注', short: '重点关注', color: '#ff4d4f', tone: 'red', dot: '#ff4d4f' },
|
||||
focus: { label: '关注', short: '关注', color: '#ec6c00', tone: 'orange', dot: '#ec6c00' },
|
||||
normal: { label: '正常', short: '正常', color: '#1bb574', tone: 'green', dot: '#1bb574' },
|
||||
unknown: { label: '无单价', short: '无单价', color: '#94a3b8', tone: 'grey', dot: '#94a3b8' },
|
||||
};
|
||||
|
||||
// 图表基座:pie 不需要 grid / xAxis / yAxis,单独取一份干净的基础配置
|
||||
const pieBase = {
|
||||
color: sycmEchartsBase.color,
|
||||
textStyle: sycmEchartsBase.textStyle,
|
||||
tooltip: sycmEchartsBase.tooltip,
|
||||
legend: sycmEchartsBase.legend,
|
||||
};
|
||||
|
||||
const chartHeight = 236;
|
||||
|
||||
/** 库龄分档:以预警线为基准逐级放大,阈值改了分档标签跟着变,不会出现空档 */
|
||||
const buildAgeTiers = (focusDays, alertDays) => {
|
||||
const safeAlert = Math.max(alertDays, focusDays + 1);
|
||||
return [
|
||||
{ label: `0 ~ ${focusDays} 天`, from: 0, to: focusDays, color: '#1bb574', hint: '库龄正常' },
|
||||
{ label: `${focusDays + 1} ~ ${safeAlert} 天`, from: focusDays + 1, to: safeAlert, color: '#ec6c00', hint: '库龄关注' },
|
||||
{ label: `${safeAlert + 1} ~ ${safeAlert * 2} 天`, from: safeAlert + 1, to: safeAlert * 2, color: '#ff7a45', hint: '红色预警' },
|
||||
{ label: `${safeAlert * 2 + 1} ~ ${safeAlert * 4} 天`, from: safeAlert * 2 + 1, to: safeAlert * 4, color: '#ff4d4f', hint: '红色预警' },
|
||||
{ label: `> ${safeAlert * 4} 天`, from: safeAlert * 4 + 1, to: Infinity, color: '#a8071a', hint: '红色预警·严重' },
|
||||
];
|
||||
};
|
||||
|
||||
/**
|
||||
* 指标卡(父组件主视图也用,因此具名导出)
|
||||
* 白底 + 左侧色条 + 大数字,和商品明细页的卡片同一种视觉语言
|
||||
*/
|
||||
export const MetricCard = ({ tone, label, value, unit, foot, tip }) => (
|
||||
<div className="rim-metric" data-tone={tone}>
|
||||
<div className="rim-metric__head">
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="rim-metric__value">
|
||||
{value}
|
||||
{unit ? <span className="rim-metric__unit">{unit}</span> : null}
|
||||
</div>
|
||||
<div className="rim-metric__foot">
|
||||
{tip ? <Tooltip title={tip}><span>{foot}</span></Tooltip> : foot}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const StockWarningDashboard = ({
|
||||
rows = [],
|
||||
thresholds = {},
|
||||
priceThresholds = {},
|
||||
onPriceFocusChange,
|
||||
onPriceHighChange,
|
||||
priceLevel = 'all',
|
||||
onPriceLevelToggle,
|
||||
returnLimitDays = 7,
|
||||
onReturnLimitChange,
|
||||
loading = false,
|
||||
}) => {
|
||||
const focusDays = Number(thresholds.focusDays || 0);
|
||||
const alertDays = Number(thresholds.alertDays || 0);
|
||||
const priceFocusMin = Number(priceThresholds.priceFocusMin || 0);
|
||||
const priceHighMin = Number(priceThresholds.priceHighMin || 0);
|
||||
const returnLimit = Number(returnLimitDays || 0);
|
||||
|
||||
// ── 库龄分档统计 ───────────────────────────────────────────
|
||||
const ageStats = useMemo(() => {
|
||||
const tiers = buildAgeTiers(focusDays, alertDays).map((tier) => ({
|
||||
...tier, codes: 0, qty: 0, amount: 0,
|
||||
}));
|
||||
let unknownCodes = 0;
|
||||
(rows || []).forEach((row) => {
|
||||
const days = row.stockDays;
|
||||
if (days === null || days === undefined) {
|
||||
unknownCodes += 1;
|
||||
return;
|
||||
}
|
||||
const tier = tiers.find((item) => days >= item.from && days <= item.to);
|
||||
if (!tier) return;
|
||||
tier.codes += 1;
|
||||
tier.qty += Number(row.inboundQty || 0);
|
||||
tier.amount += Number(row.amount || 0);
|
||||
});
|
||||
return { tiers, unknownCodes };
|
||||
}, [rows, focusDays, alertDays]);
|
||||
|
||||
// ── 单价预警统计 ───────────────────────────────────────────
|
||||
const priceStats = useMemo(() => {
|
||||
const stats = {
|
||||
high: { codes: 0, qty: 0, amount: 0 },
|
||||
focus: { codes: 0, qty: 0, amount: 0 },
|
||||
normal: { codes: 0, qty: 0, amount: 0 },
|
||||
unknown: { codes: 0, qty: 0, amount: 0 },
|
||||
};
|
||||
(rows || []).forEach((row) => {
|
||||
const level = PRICE_TONES[row.unitPriceLevel] ? row.unitPriceLevel : 'unknown';
|
||||
stats[level].codes += 1;
|
||||
stats[level].qty += Number(row.inboundQty || 0);
|
||||
stats[level].amount += Number(row.amount || 0);
|
||||
});
|
||||
return stats;
|
||||
}, [rows]);
|
||||
|
||||
// ── 供应商 / 编码 按库存金额 TOP10 ─────────────────────────
|
||||
const topSuppliers = useMemo(() => {
|
||||
const map = new Map();
|
||||
(rows || []).forEach((row) => {
|
||||
const name = String(row.supplier || '').trim() || '未填写供应商';
|
||||
const current = map.get(name) || { codes: 0, qty: 0, amount: 0 };
|
||||
current.codes += 1;
|
||||
current.qty += Number(row.inboundQty || 0);
|
||||
current.amount += Number(row.amount || 0);
|
||||
map.set(name, current);
|
||||
});
|
||||
return Array.from(map, ([name, value]) => ({ name, ...value }))
|
||||
.filter((item) => item.amount > 0)
|
||||
.sort((a, b) => b.amount - a.amount)
|
||||
.slice(0, 10);
|
||||
}, [rows]);
|
||||
|
||||
const topCodes = useMemo(() => (rows || [])
|
||||
.filter((row) => Number(row.amount || 0) > 0)
|
||||
.slice()
|
||||
.sort((a, b) => Number(b.amount || 0) - Number(a.amount || 0))
|
||||
.slice(0, 10), [rows]);
|
||||
|
||||
// ── 图表配置 ───────────────────────────────────────────────
|
||||
const ageChartOption = useMemo(() => ({
|
||||
...sycmEchartsBase,
|
||||
grid: { ...sycmEchartsBase.grid, top: 44, right: 62, bottom: 30, left: 52 },
|
||||
legend: { ...sycmEchartsBase.legend, data: ['编码数', '库存金额'] },
|
||||
xAxis: {
|
||||
...sycmEchartsBase.xAxis,
|
||||
type: 'category',
|
||||
data: ageStats.tiers.map((tier) => tier.label),
|
||||
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, interval: 0, fontSize: 11 },
|
||||
},
|
||||
yAxis: [
|
||||
{ ...sycmEchartsBase.yAxis, type: 'value', name: '编码数', nameTextStyle: { color: '#8c8c8c', fontSize: 11 } },
|
||||
{
|
||||
...sycmEchartsBase.yAxis,
|
||||
type: 'value',
|
||||
name: '库存金额',
|
||||
nameTextStyle: { color: '#8c8c8c', fontSize: 11 },
|
||||
axisLabel: { ...sycmEchartsBase.yAxis.axisLabel, formatter: compactMoney },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
tooltip: {
|
||||
...sycmEchartsBase.tooltip,
|
||||
formatter: (params) => {
|
||||
const list = Array.isArray(params) ? params : [params];
|
||||
if (!list.length) return '';
|
||||
const index = list[0].dataIndex;
|
||||
const tier = ageStats.tiers[index];
|
||||
if (!tier) return '';
|
||||
const rowsHtml = list.map((item) => {
|
||||
const isMoney = item.seriesName === '库存金额';
|
||||
const text = isMoney ? `¥${money(item.value)}` : `${num(item.value)} 个编码`;
|
||||
return `<div style="display:flex;align-items:center;gap:6px;line-height:20px">${item.marker}<span>${item.seriesName}</span><b style="margin-left:auto">${text}</b></div>`;
|
||||
}).join('');
|
||||
return `<div style="font-weight:600;margin-bottom:4px">${tier.label}(${tier.hint})</div>${rowsHtml}
|
||||
<div style="margin-top:4px;opacity:.78">在库 ${num(tier.qty)} 件</div>`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '编码数',
|
||||
type: 'bar',
|
||||
barMaxWidth: 34,
|
||||
data: ageStats.tiers.map((tier) => ({
|
||||
value: tier.codes,
|
||||
itemStyle: { color: tier.color, borderRadius: [4, 4, 0, 0] },
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: '库存金额',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 2.5, color: '#5980ff' },
|
||||
itemStyle: { color: '#5980ff' },
|
||||
data: ageStats.tiers.map((tier) => Math.round(tier.amount * 100) / 100),
|
||||
},
|
||||
],
|
||||
}), [ageStats]);
|
||||
|
||||
const priceChartOption = useMemo(() => {
|
||||
const data = PRICE_ORDER
|
||||
.map((key) => ({
|
||||
name: PRICE_TONES[key].label,
|
||||
value: priceStats[key].codes,
|
||||
itemStyle: { color: PRICE_TONES[key].color },
|
||||
}))
|
||||
.filter((item) => item.value > 0);
|
||||
const total = data.reduce((acc, item) => acc + item.value, 0);
|
||||
return {
|
||||
...pieBase,
|
||||
tooltip: {
|
||||
...pieBase.tooltip,
|
||||
trigger: 'item',
|
||||
formatter: (param) => `<div style="font-weight:600;margin-bottom:4px">${param.marker}${param.name}</div>
|
||||
<div>${num(param.value)} 个编码 · 占比 ${param.percent}%</div>`,
|
||||
},
|
||||
legend: {
|
||||
...pieBase.legend,
|
||||
orient: 'vertical',
|
||||
right: 4,
|
||||
top: 'middle',
|
||||
itemWidth: 8,
|
||||
itemHeight: 8,
|
||||
icon: 'circle',
|
||||
},
|
||||
title: {
|
||||
text: String(total),
|
||||
subtext: '个编码',
|
||||
left: '33%',
|
||||
top: '38%',
|
||||
textAlign: 'center',
|
||||
textStyle: { fontSize: 24, fontWeight: 600, color: '#262626' },
|
||||
subtextStyle: { fontSize: 12, color: '#8c8c8c' },
|
||||
},
|
||||
series: [{
|
||||
type: 'pie',
|
||||
radius: ['56%', '76%'],
|
||||
center: ['35%', '50%'],
|
||||
avoidLabelOverlap: true,
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
emphasis: { scale: true, scaleSize: 4 },
|
||||
data,
|
||||
}],
|
||||
};
|
||||
}, [priceStats]);
|
||||
|
||||
const supplierChartOption = useMemo(() => {
|
||||
const source = topSuppliers.slice().reverse();
|
||||
return {
|
||||
...sycmEchartsBase,
|
||||
grid: { top: 10, right: 74, bottom: 6, left: 8, containLabel: true },
|
||||
tooltip: {
|
||||
...sycmEchartsBase.tooltip,
|
||||
trigger: 'item',
|
||||
formatter: (param) => {
|
||||
const item = source[param.dataIndex];
|
||||
if (!item) return '';
|
||||
return `<div style="font-weight:600;margin-bottom:4px">${item.name}</div>
|
||||
<div>库存金额 <b>¥${money(item.amount)}</b></div>
|
||||
<div style="opacity:.78">${num(item.codes)} 个编码 · ${num(item.qty)} 件</div>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
...sycmEchartsBase.xAxis,
|
||||
type: 'value',
|
||||
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, formatter: compactMoney },
|
||||
splitLine: { lineStyle: { color: '#eef0f4', type: 'dashed' } },
|
||||
},
|
||||
yAxis: {
|
||||
...sycmEchartsBase.yAxis,
|
||||
type: 'category',
|
||||
data: source.map((item) => item.name),
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: '#595959', fontSize: 12, width: 130, overflow: 'truncate' },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
barMaxWidth: 14,
|
||||
itemStyle: { color: '#5980ff', borderRadius: [0, 4, 4, 0] },
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#595959',
|
||||
fontSize: 11,
|
||||
formatter: (param) => `¥${compactMoney(param.value)}`,
|
||||
},
|
||||
data: source.map((item) => Math.round(item.amount * 100) / 100),
|
||||
}],
|
||||
};
|
||||
}, [topSuppliers]);
|
||||
|
||||
const codeChartOption = useMemo(() => {
|
||||
const source = topCodes.slice().reverse();
|
||||
return {
|
||||
...sycmEchartsBase,
|
||||
grid: { top: 10, right: 74, bottom: 6, left: 8, containLabel: true },
|
||||
tooltip: {
|
||||
...sycmEchartsBase.tooltip,
|
||||
trigger: 'item',
|
||||
formatter: (param) => {
|
||||
const row = source[param.dataIndex];
|
||||
if (!row) return '';
|
||||
const tone = PRICE_TONES[row.unitPriceLevel] || PRICE_TONES.unknown;
|
||||
return `<div style="font-weight:600;margin-bottom:4px">${row.productCode}</div>
|
||||
<div>库存金额 <b>¥${money(row.amount)}</b></div>
|
||||
<div style="opacity:.78">单价 ¥${money(row.unitPrice)} · ${tone.label} · ${num(row.inboundQty)} 件</div>
|
||||
<div style="opacity:.78">供应商 ${row.supplier || '—'}</div>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
...sycmEchartsBase.xAxis,
|
||||
type: 'value',
|
||||
axisLabel: { ...sycmEchartsBase.xAxis.axisLabel, formatter: compactMoney },
|
||||
splitLine: { lineStyle: { color: '#eef0f4', type: 'dashed' } },
|
||||
},
|
||||
yAxis: {
|
||||
...sycmEchartsBase.yAxis,
|
||||
type: 'category',
|
||||
data: source.map((row) => row.productCode),
|
||||
splitLine: { show: false },
|
||||
axisLabel: { color: '#595959', fontSize: 12, width: 140, overflow: 'truncate' },
|
||||
},
|
||||
series: [{
|
||||
type: 'bar',
|
||||
barMaxWidth: 14,
|
||||
data: source.map((row) => ({
|
||||
value: Math.round(Number(row.amount || 0) * 100) / 100,
|
||||
itemStyle: {
|
||||
color: (PRICE_TONES[row.unitPriceLevel] || PRICE_TONES.unknown).color,
|
||||
borderRadius: [0, 4, 4, 0],
|
||||
},
|
||||
})),
|
||||
label: {
|
||||
show: true,
|
||||
position: 'right',
|
||||
color: '#595959',
|
||||
fontSize: 11,
|
||||
formatter: (param) => `¥${compactMoney(param.value)}`,
|
||||
},
|
||||
}],
|
||||
};
|
||||
}, [topCodes]);
|
||||
|
||||
const priceBar = PRICE_ORDER.map((key) => ({
|
||||
key,
|
||||
tone: PRICE_TONES[key].tone,
|
||||
label: PRICE_TONES[key].label,
|
||||
hint: key === 'high'
|
||||
? `单价 > ${priceHighMin} 元`
|
||||
: key === 'focus'
|
||||
? `单价 ${priceFocusMin} ~ ${priceHighMin} 元`
|
||||
: key === 'normal'
|
||||
? `单价 < ${priceFocusMin} 元`
|
||||
: '商品资料里没有成本价 / 采购价',
|
||||
codes: priceStats[key].codes,
|
||||
qty: priceStats[key].qty,
|
||||
amount: priceStats[key].amount,
|
||||
}));
|
||||
|
||||
const hasRows = (rows || []).length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rsw-price">
|
||||
<div className="rsw-price__thresholds">
|
||||
<span className="rim-toolbar__label">关注 ≥</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={priceFocusMin}
|
||||
onChange={onPriceFocusChange}
|
||||
style={{ width: 100 }}
|
||||
addonAfter="元"
|
||||
/>
|
||||
<span className="rim-toolbar__label" style={{ marginLeft: 6 }}>重点关注 ></span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={priceHighMin}
|
||||
onChange={onPriceHighChange}
|
||||
style={{ width: 100 }}
|
||||
addonAfter="元"
|
||||
/>
|
||||
<Tooltip title={`单价高于重点关注线的编码,要求入库后这么多天内完成库存退厂;在库天数 > ${returnLimit} 天即为「已超期」`}>
|
||||
<span className="rim-toolbar__label rsw-price__limit-label">退厂时限</span>
|
||||
</Tooltip>
|
||||
<InputNumber
|
||||
min={0}
|
||||
value={returnLimit}
|
||||
onChange={onReturnLimitChange}
|
||||
style={{ width: 96 }}
|
||||
addonAfter="天"
|
||||
/>
|
||||
</div>
|
||||
<div className="rsw-warn">
|
||||
{priceBar.map((item) => (
|
||||
<Tooltip key={item.key} title={`${item.hint}|点击${priceLevel === item.key ? '取消' : '只'}看这类编码`}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-tone={item.tone}
|
||||
className={`rsw-warn__item${priceLevel === item.key ? ' is-active' : ''}`}
|
||||
onClick={() => onPriceLevelToggle && onPriceLevelToggle(item.key)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
if (onPriceLevelToggle) onPriceLevelToggle(item.key);
|
||||
}}
|
||||
>
|
||||
<span className="rsw-warn__label">{item.label}</span>
|
||||
<span className="rsw-warn__value">{num(item.codes)}<small>个编码</small></span>
|
||||
<span className="rsw-warn__foot">{num(item.qty)} 件 · ¥{money(item.amount)}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasRows ? (
|
||||
<div className="rsw-charts">
|
||||
<section className="rsw-chart">
|
||||
<h3 className="rsw-chart__title">
|
||||
库龄分布
|
||||
<span className="rsw-chart__hint">分档跟随上方「关注 / 预警」天数自动调整</span>
|
||||
</h3>
|
||||
<ReactECharts option={ageChartOption} style={{ height: chartHeight }} notMerge lazyUpdate />
|
||||
</section>
|
||||
|
||||
<section className="rsw-chart">
|
||||
<h3 className="rsw-chart__title">
|
||||
单价预警分布
|
||||
<span className="rsw-chart__hint">按编码数占比</span>
|
||||
</h3>
|
||||
<ReactECharts option={priceChartOption} style={{ height: chartHeight }} notMerge lazyUpdate />
|
||||
</section>
|
||||
|
||||
<section className="rsw-chart">
|
||||
<h3 className="rsw-chart__title">
|
||||
供应商库存金额 TOP10
|
||||
<span className="rsw-chart__hint">压资金最多的供应商</span>
|
||||
</h3>
|
||||
{topSuppliers.length ? (
|
||||
<ReactECharts option={supplierChartOption} style={{ height: chartHeight + 60 }} notMerge lazyUpdate />
|
||||
) : <Empty description="没有可统计的供应商金额" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</section>
|
||||
|
||||
<section className="rsw-chart">
|
||||
<h3 className="rsw-chart__title">
|
||||
商品编码库存金额 TOP10
|
||||
<span className="rsw-chart__hint">柱子颜色 = 该编码的单价预警等级</span>
|
||||
</h3>
|
||||
{topCodes.length ? (
|
||||
<ReactECharts option={codeChartOption} style={{ height: chartHeight + 60 }} notMerge lazyUpdate />
|
||||
) : <Empty description="没有可统计的编码金额" image={Empty.PRESENTED_IMAGE_SIMPLE} />}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<Empty
|
||||
description={loading ? '正在加载数据…' : '当前筛选条件下没有数据'}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StockWarningDashboard;
|
||||
@ -1,730 +0,0 @@
|
||||
/* 横林二仓客退数据监控 —— 页头 / 工具栏 / 指标卡 / 库龄分布 / 数据区 */
|
||||
|
||||
.rim-page {
|
||||
--rim-gap: 12px;
|
||||
--rim-card-border: var(--sycm-border-card, #e6e6e6);
|
||||
--rim-card-radius: var(--sycm-radius-xl, 12px);
|
||||
--rim-card-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
|
||||
--rim-blue: #2062e6;
|
||||
--rim-green: #1bb574;
|
||||
--rim-orange: #ec6c00;
|
||||
--rim-red: #ff4d4f;
|
||||
--rim-cyan: #0ea5e9;
|
||||
--rim-violet: #7c5cf0;
|
||||
}
|
||||
|
||||
/* ── 页头(坐在页面渐变背景上) ───────────────────────────── */
|
||||
.rim-hero {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 12px 20px;
|
||||
margin-bottom: var(--rim-gap);
|
||||
}
|
||||
|
||||
.rim-hero__title {
|
||||
margin: 0;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.rim-hero__desc {
|
||||
margin: 4px 0 0;
|
||||
max-width: 900px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.rim-hero__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.rim-hero__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rim-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: 9999px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rim-chip b {
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-chip.is-ok {
|
||||
border-color: var(--sycm-color-success-border, #c8f3dd);
|
||||
background: var(--sycm-color-success-bg, #e8fff4);
|
||||
}
|
||||
|
||||
.rim-chip.is-warn {
|
||||
border-color: var(--sycm-color-warning-border, #ffe3b8);
|
||||
background: var(--sycm-color-warning-bg, #fff8e6);
|
||||
}
|
||||
|
||||
.rim-chip.is-danger {
|
||||
border-color: var(--sycm-color-danger-border, #ffd0d0);
|
||||
background: var(--sycm-color-danger-bg, #fff1f0);
|
||||
}
|
||||
|
||||
/* ── 退货库存预警:库龄预警汇总条 ─────────────────────────── */
|
||||
.rsw-warn {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rsw-warn__item {
|
||||
--rsw-tone: #595959;
|
||||
--rsw-tone-soft: rgba(0, 0, 0, 0.02);
|
||||
|
||||
flex: 1 1 190px;
|
||||
min-width: 180px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--sycm-border-card, #e6e6e6);
|
||||
border-left: 4px solid var(--rsw-tone);
|
||||
border-radius: 10px;
|
||||
background: var(--rsw-tone-soft);
|
||||
cursor: pointer;
|
||||
transition: box-shadow 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.rsw-warn__item:hover {
|
||||
box-shadow: var(--sycm-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.035));
|
||||
}
|
||||
|
||||
.rsw-warn__item.is-active {
|
||||
border-color: var(--rsw-tone);
|
||||
box-shadow: 0 0 0 2px var(--rsw-tone-soft);
|
||||
}
|
||||
|
||||
.rsw-warn__item[data-tone='red'] {
|
||||
--rsw-tone: #ff4d4f;
|
||||
--rsw-tone-soft: rgba(255, 77, 79, 0.08);
|
||||
}
|
||||
|
||||
.rsw-warn__item[data-tone='orange'] {
|
||||
--rsw-tone: #ec6c00;
|
||||
--rsw-tone-soft: rgba(236, 108, 0, 0.08);
|
||||
}
|
||||
|
||||
.rsw-warn__item[data-tone='green'] {
|
||||
--rsw-tone: #1bb574;
|
||||
--rsw-tone-soft: rgba(27, 181, 116, 0.08);
|
||||
}
|
||||
|
||||
.rsw-warn__item[data-tone='grey'] {
|
||||
--rsw-tone: #94a3b8;
|
||||
--rsw-tone-soft: rgba(148, 163, 184, 0.1);
|
||||
}
|
||||
|
||||
.rsw-warn__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
}
|
||||
|
||||
.rsw-warn__value {
|
||||
margin-top: 2px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
color: var(--rsw-tone);
|
||||
}
|
||||
|
||||
.rsw-warn__value small {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--sycm-text-secondary, #8c8c8c);
|
||||
}
|
||||
|
||||
.rsw-warn__foot {
|
||||
font-size: 12px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rsw-warn__item {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 状态页签(替代原来的三色预警大卡) ───────────────────── */
|
||||
.rim-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 12px;
|
||||
margin-bottom: var(--rim-gap);
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-tabs__item {
|
||||
--rim-tab-tone: #595959;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 12px 8px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.rim-tabs__item[data-tone='red'] { --rim-tab-tone: var(--rim-red); }
|
||||
.rim-tabs__item[data-tone='orange'] { --rim-tab-tone: var(--rim-orange); }
|
||||
.rim-tabs__item[data-tone='green'] { --rim-tab-tone: var(--rim-green); }
|
||||
.rim-tabs__item[data-tone='grey'] { --rim-tab-tone: #94a3b8; }
|
||||
.rim-tabs__item[data-tone='blue'] { --rim-tab-tone: var(--rim-blue); }
|
||||
|
||||
.rim-tabs__item:hover {
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
}
|
||||
|
||||
.rim-tabs__item.is-active {
|
||||
border-bottom-color: var(--rim-tab-tone);
|
||||
color: var(--rim-tab-tone);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-tabs__dot {
|
||||
flex: none;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--rim-tab-tone);
|
||||
}
|
||||
|
||||
.rim-tabs__count {
|
||||
color: var(--rim-tab-tone);
|
||||
font-size: 15px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-tabs__qty {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 已选筛选回显 ─────────────────────────────────────────── */
|
||||
.rim-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: -4px 0 10px;
|
||||
}
|
||||
|
||||
.rim-filters .ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── 折叠区(可视化分析 / 口径说明) ──────────────────────── */
|
||||
.rim-fold {
|
||||
margin-bottom: var(--rim-gap);
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rim-fold__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.rim-fold__head:hover {
|
||||
background: var(--sycm-bg-subtle, #f5f7fa);
|
||||
}
|
||||
|
||||
.rim-fold__title {
|
||||
flex: none;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-fold__hint {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rim-fold__icon {
|
||||
flex: none;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-fold__body {
|
||||
padding: 2px 16px 14px;
|
||||
}
|
||||
|
||||
.rim-fold__list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
/* ── 通用白卡 ─────────────────────────────────────────────── */
|
||||
.rim-panel {
|
||||
padding: 12px 16px;
|
||||
margin-bottom: var(--rim-gap);
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-panel__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rim-panel__hint {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 表格卡标题右侧的一行元信息(仓库 / 统计日 / 库存更新时间 / 最长在库 …) */
|
||||
.rim-panel__meta {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── 筛选工具栏 ───────────────────────────────────────────── */
|
||||
.rim-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: var(--rim-gap);
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-toolbar__field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rim-toolbar__label {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.rim-toolbar__spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.rim-toolbar__group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* 次级筛选行(「更多筛选」展开后出现):浅底,不抢视觉 */
|
||||
.rim-toolbar--sub {
|
||||
margin-top: -6px;
|
||||
background: var(--sycm-bg-subtle, #fafbfc);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.rim-toolbar__more {
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
}
|
||||
|
||||
.rim-toolbar__more.is-open {
|
||||
color: var(--rim-blue);
|
||||
}
|
||||
|
||||
/* ── 指标卡 ───────────────────────────────────────────────── */
|
||||
.rim-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--rim-gap);
|
||||
margin-bottom: var(--rim-gap);
|
||||
}
|
||||
|
||||
.rim-metric {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 12px 14px 11px;
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-metric::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 3px;
|
||||
background: var(--rim-tone, var(--rim-blue));
|
||||
}
|
||||
|
||||
.rim-metric__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-metric__icon {
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
background: var(--rim-tone-soft, rgba(32, 98, 230, 0.08));
|
||||
color: var(--rim-tone, var(--rim-blue));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-metric__value {
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 24px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.rim-metric__unit {
|
||||
margin-left: 2px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rim-metric__foot {
|
||||
margin-top: 4px;
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-metric[data-tone='blue'] { --rim-tone: var(--rim-blue); --rim-tone-soft: rgba(32, 98, 230, 0.1); }
|
||||
.rim-metric[data-tone='cyan'] { --rim-tone: var(--rim-cyan); --rim-tone-soft: rgba(14, 165, 233, 0.1); }
|
||||
.rim-metric[data-tone='green'] { --rim-tone: var(--rim-green); --rim-tone-soft: rgba(27, 181, 116, 0.1); }
|
||||
.rim-metric[data-tone='orange'] { --rim-tone: var(--rim-orange); --rim-tone-soft: rgba(236, 108, 0, 0.1); }
|
||||
.rim-metric[data-tone='red'] { --rim-tone: var(--rim-red); --rim-tone-soft: rgba(255, 77, 79, 0.1); }
|
||||
.rim-metric[data-tone='violet'] { --rim-tone: var(--rim-violet); --rim-tone-soft: rgba(124, 92, 240, 0.1); }
|
||||
|
||||
/* ── 库龄分布 ─────────────────────────────────────────────── */
|
||||
.rim-age {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: var(--rim-gap);
|
||||
}
|
||||
|
||||
.rim-age__item {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--sycm-border-light, #f0f0f0);
|
||||
border-radius: var(--sycm-radius-lg, 8px);
|
||||
background: var(--sycm-bg-subtle, #f5f7fa);
|
||||
}
|
||||
|
||||
.rim-age__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
color: var(--sycm-text-secondary, #595959);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rim-age__dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.rim-age__value {
|
||||
margin-top: 6px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 20px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.rim-age__value small {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.rim-age__track {
|
||||
height: 6px;
|
||||
margin: 8px 0 6px;
|
||||
border-radius: 9999px;
|
||||
background: #e9edf5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rim-age__fill {
|
||||
display: block;
|
||||
min-width: 2px;
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.rim-age__foot {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* 退货库存预警:总金额(库存金额)单元格,跟随金额口径强调 */
|
||||
.rsw-amount {
|
||||
color: var(--sycm-color-danger, #cf1322);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── 退货库存预警:单价预警(阈值 + 可点击汇总条) ─────────── */
|
||||
.rsw-price__thresholds {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.rsw-price .rsw-warn {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* ── 退货库存预警:重点关注商品「退厂要求」提示条 ─────────── */
|
||||
.rsw-policy {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #ffe0a3;
|
||||
border-left: 3px solid #faad14;
|
||||
border-radius: 8px;
|
||||
background: #fffbe6;
|
||||
}
|
||||
|
||||
/* 有超期编码时整条转红,提醒优先处理 */
|
||||
.rsw-policy.is-overdue {
|
||||
border-color: #ffccc7;
|
||||
border-left-color: #ff4d4f;
|
||||
background: #fff1f0;
|
||||
}
|
||||
|
||||
.rsw-policy__icon {
|
||||
flex: none;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
color: #d48806;
|
||||
}
|
||||
|
||||
.rsw-policy.is-overdue .rsw-policy__icon {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.rsw-policy__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rsw-policy__title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
color: #874d00;
|
||||
}
|
||||
|
||||
.rsw-policy.is-overdue .rsw-policy__title {
|
||||
color: #a8071a;
|
||||
}
|
||||
|
||||
.rsw-policy__days {
|
||||
margin: 0 3px;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rsw-policy__desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #8c6b2f;
|
||||
}
|
||||
|
||||
.rsw-policy.is-overdue .rsw-policy__desc {
|
||||
color: #a15c5c;
|
||||
}
|
||||
|
||||
.rsw-policy__desc b {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rsw-policy__danger {
|
||||
color: #cf1322;
|
||||
}
|
||||
|
||||
.rsw-price__limit-label {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* 表格「退厂时限」列:超期标签加粗,扫一眼就能找到 */
|
||||
.rsw-deadline-tag {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 表格「最晚退厂时间」列:数据源已接入但该供应商无日期时的占位符 */
|
||||
.rsw-exit-date-empty {
|
||||
color: var(--sycm-text-quaternary, #bfbfbf);
|
||||
}
|
||||
|
||||
/* ── 退货库存预警:图表区 ─────────────────────────────────── */
|
||||
.rsw-charts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--rim-gap, 12px);
|
||||
}
|
||||
|
||||
.rsw-chart {
|
||||
padding: 10px 12px 4px;
|
||||
border: 1px solid var(--sycm-border-light, #f0f0f0);
|
||||
border-radius: var(--sycm-radius-lg, 8px);
|
||||
background: var(--sycm-bg-subtle, #f5f7fa);
|
||||
}
|
||||
|
||||
.rsw-chart__title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 4px;
|
||||
color: var(--sycm-text-strong, #262626);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rsw-chart__hint {
|
||||
color: var(--sycm-text-tertiary, #8c8c8c);
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.rsw-charts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 数据区 ───────────────────────────────────────────────── */
|
||||
.rim-data {
|
||||
padding: 8px 16px 16px;
|
||||
border: 1px solid var(--rim-card-border);
|
||||
border-radius: var(--rim-card-radius);
|
||||
background: var(--sycm-bg-container, #fff);
|
||||
box-shadow: var(--rim-card-shadow);
|
||||
}
|
||||
|
||||
.rim-data .ant-tabs-nav {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rim-data .ant-table {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rim-data__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── 响应式 ───────────────────────────────────────────────── */
|
||||
@media (max-width: 1440px) {
|
||||
.rim-metrics {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.rim-metrics,
|
||||
.rim-age {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user