Compare commits
No commits in common. "main" and "codex/remove-commerce-pages" have entirely different histories.
main
...
codex/remo
9
.gitignore
vendored
9
.gitignore
vendored
@ -58,12 +58,3 @@ tmall-board-speed-check.json
|
||||
.remote-access.backup-*/
|
||||
.workbuddy/
|
||||
**/node_modules
|
||||
|
||||
# Local verification artifacts
|
||||
/outputs/
|
||||
.git-status.txt
|
||||
|
||||
# 本地一次性探查/调试产物(不进仓库)
|
||||
_probe_*.json
|
||||
_probe_*.js
|
||||
_work-scratch-*/
|
||||
|
||||
@ -6,9 +6,8 @@
|
||||
- 保留他人的未提交修改和已有提交,仅暂存本任务内容;不得清理或覆盖未知工作。
|
||||
- 推送前同步远端,逐项检查最终差异并验证;被拒绝后先查明并合并更新,禁止任何形式的强推。
|
||||
- 不改写已发布历史,不批量选择冲突的某一方;不明确的行为取舍需要负责人确认。
|
||||
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;默认由非提交者审核。仓库所有者或管理员明确授权某个 PR 合并时,该书面授权可以代替独立审核,但仍必须同步最新主分支、验证最终提交并保留授权记录。
|
||||
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;独立审核与最新内容的验证通过后由维护者合并。
|
||||
- 不提交凭据、私钥、环境秘密或真实业务数据;不把合并授权当成生产部署授权。
|
||||
- 新建或修改数据表、映射表、查询和同步任务时,必须按实际过滤、关联、排序和唯一性设计索引,用执行计划和有代表性数据验证负载;禁止无边界全表扫描、无节制并发和重复全量同步。
|
||||
- 开发机器使用中央系统数据库和公网 TLS Redis,无需安装本地数据库;生产部署必须先将代码通过 PR 合入 `main`,并在 `/opt/cangku/deploy.lock` 锁内从服务器 Git 工作区快进拉取和验证。
|
||||
- 最终交付报告实际分支、提交 SHA、验证结果及 PR 状态,不能将“已推送”报告为“已合并”。
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
公开的拼多多业务精简版。欢迎所有人克隆、修改、分发代码并提交 Pull Request,代码按 MIT 许可证开放。
|
||||
|
||||
保留商品监控、仪表盘、激励看板、营销活动、推广、商品分析、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
|
||||
保留仪表盘、工单以及在线客户端与执行任务等现有模块。部分菜单仍沿用原项目的占位页面。
|
||||
|
||||
已删除市场数据、店铺管理和运营工具整组页面、Claw 对话、skills 管理、平台切换与其他平台演示页,以及对应路由、页面资源、专属配置接口和权限项。首页为仪表盘。保留业务仍使用的模型配置、商品数据查询、桌面会话和自动上架公共服务继续可用;Claw 补全与图搜备用通道已经移除,缺失资料需使用业务接口或手工补充。
|
||||
|
||||
@ -18,8 +18,6 @@ npm run remote:dev
|
||||
|
||||
首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。
|
||||
|
||||
拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。
|
||||
|
||||
## 验证与构建
|
||||
|
||||
```bash
|
||||
@ -29,7 +27,7 @@ npm --prefix frontend run check
|
||||
|
||||
前端构建输出位于 `frontend/dist`。部署时需为单页应用配置路由回退,并代理 `/api` 与 `/cubejs-api` 到后端。
|
||||
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。迁移仅作用于实际配置给此实例的系统数据库。
|
||||
|
||||
## 参与修改
|
||||
|
||||
|
||||
@ -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 . .
|
||||
|
||||
|
||||
1
backend/package-lock.json
generated
1
backend/package-lock.json
generated
@ -28,7 +28,6 @@
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.6.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "8.17.2",
|
||||
"playwright": "^1.60.0",
|
||||
"sharp": "^0.33.5",
|
||||
"uuid": "^9.0.1",
|
||||
|
||||
@ -9,9 +9,7 @@
|
||||
"syntax": "node scripts/check-syntax.js",
|
||||
"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"
|
||||
"alert:check": "node src/services/alertChecker.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.975.0",
|
||||
@ -33,7 +31,6 @@
|
||||
"multer": "^2.1.1",
|
||||
"mysql2": "^3.6.5",
|
||||
"node-cron": "^3.0.3",
|
||||
"pg": "8.17.2",
|
||||
"playwright": "^1.60.0",
|
||||
"sharp": "^0.33.5",
|
||||
"uuid": "^9.0.1",
|
||||
|
||||
@ -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,404 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 从《公司SABC标签配置.xlsx》导入商品标签配置到系统库。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=/path/to/公司SABC标签配置.xlsx
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --print-sql # 只打印 SQL,不写库
|
||||
* node scripts/seed_product_tag_config_from_xlsx.js --file=... --dry-run # 只解析并打印摘要
|
||||
*
|
||||
* 解析口径(2026-09-11 评审确认):
|
||||
* - 区块① A3:F150 → 规模标签阈值(148 行,37 品类 × 4 档)
|
||||
* - 区块② H4:L28 → SABC 映射(25 行,5 规模标签 × 5 利润标签)
|
||||
* - 阈值统一四舍五入保留 2 位小数(决策 5,2026-09-11 修订);品类为 '-' 或空的行忽略
|
||||
*
|
||||
* 前置:三张表需已存在(DBA 执行 sql/20260911_create_product_tag_config_system_mysql.sql,
|
||||
* 或由 ensureProductTagConfigSchema() 自动建表)。
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const XLSX = require('xlsx');
|
||||
const mysql = require('mysql2/promise');
|
||||
const database = require('../src/config/database');
|
||||
|
||||
const SCALE_TAG_ORDER = ['爆款', '热卖款', '动销款', '低动销款'];
|
||||
const IGNORED_CATEGORIES = ['-', ''];
|
||||
const PROFIT_LEVELS = ['一级', '二级', '三级', '四级', '五级'];
|
||||
|
||||
// 配置表聚合类目 → DB 类目 的兜底映射(按用户 2026-09-11 推测确认)。
|
||||
// xlsx 里「其他沙发类」「办公家具」本身是聚合类目,DB 中没有同名完整类目;
|
||||
// 其子品类在 DB 中独立存在,且未被别的配置名占用(懒人沙发/文具边柜已单独配置,故排除)。
|
||||
// 用「家族规则」而非逐个枚举叶子名,可自动覆盖 DB 中实际存在的全部子类目,避免名字写错产生幽灵行。
|
||||
// family: 'sofa' → 所有短名含「沙发」的类目,排除 exclude 列表
|
||||
// family: 'office'→ 所有「商业/办公家具 -」前缀的类目,排除 exclude 列表
|
||||
const MANUAL_CATEGORY_MAP = {
|
||||
'其他沙发类': { family: 'sofa', exclude: ['懒人沙发'] },
|
||||
'办公家具': { family: 'office', exclude: ['文具边柜'] },
|
||||
};
|
||||
|
||||
// 区块① / 区块② 在 sheet 中的位置(1-based,与 Excel 界面一致)
|
||||
const BLOCK1 = { startRow: 3, endRow: 150, cols: { category: 0, pMin: 1, pMax: 2, sMin: 3, sMax: 4, scaleTag: 5 } };
|
||||
const BLOCK2 = { startRow: 4, endRow: 28, cols: { scaleTag: 7, profitLevel: 8, profitText: 9, sabc: 11 } };
|
||||
|
||||
// 去掉「住宅家具 - 」这类一级类目前缀,只留叶子品类名
|
||||
function stripCategoryPrefix(value) {
|
||||
return String(value).replace(/^.*?\s+-\s+/, '').trim();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { file: '', printSql: false, dryRun: false };
|
||||
argv.slice(2).forEach((item) => {
|
||||
if (item.startsWith('--file=')) args.file = item.slice('--file='.length);
|
||||
else if (item === '--print-sql') args.printSql = true;
|
||||
else if (item === '--dry-run') args.dryRun = true;
|
||||
});
|
||||
return args;
|
||||
}
|
||||
|
||||
function text(value) {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).trim();
|
||||
}
|
||||
|
||||
// 决策 5(2026-09-11 11:12 修订):所有阈值统一「四舍五入保留 2 位小数」。
|
||||
// - 0.5 → 0.5(合法业务阈值,不变;不会变成 1)
|
||||
// - 溢出值 7916.34311148132 → 7916.34;2063.13142776357 → 2063.13
|
||||
// - 整数 12000 → 12000
|
||||
function normalizeThreshold(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return Math.round(num * 100) / 100;
|
||||
}
|
||||
|
||||
function sqlText(value) {
|
||||
if (value === null || value === undefined) return 'NULL';
|
||||
return `'${String(value).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||
}
|
||||
|
||||
function sqlNumber(value) {
|
||||
return value === null || value === undefined ? 'NULL' : String(value);
|
||||
}
|
||||
|
||||
function readWorkbook(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new Error(`文件不存在:${filePath}`);
|
||||
}
|
||||
const workbook = XLSX.readFile(filePath);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1, blankrows: true, defval: null });
|
||||
return { sheetName, rows };
|
||||
}
|
||||
|
||||
function parseScaleThresholds(rows) {
|
||||
const list = [];
|
||||
const skipped = [];
|
||||
for (let rowNo = BLOCK1.startRow; rowNo <= BLOCK1.endRow; rowNo += 1) {
|
||||
const row = rows[rowNo - 1] || [];
|
||||
const category = text(row[BLOCK1.cols.category]);
|
||||
if (IGNORED_CATEGORIES.includes(category)) {
|
||||
if (category) skipped.push({ rowNo, reason: `品类为 "${category}",按决策 6 忽略` });
|
||||
continue;
|
||||
}
|
||||
const scaleTag = text(row[BLOCK1.cols.scaleTag]);
|
||||
if (!SCALE_TAG_ORDER.includes(scaleTag)) {
|
||||
skipped.push({ rowNo, reason: `规模标签 "${scaleTag}" 不在枚举内` });
|
||||
continue;
|
||||
}
|
||||
const raw = {
|
||||
performanceMin: row[BLOCK1.cols.pMin],
|
||||
performanceMax: row[BLOCK1.cols.pMax],
|
||||
salesMin: row[BLOCK1.cols.sMin],
|
||||
salesMax: row[BLOCK1.cols.sMax],
|
||||
};
|
||||
const rounded = {
|
||||
performanceMin: normalizeThreshold(raw.performanceMin),
|
||||
performanceMax: normalizeThreshold(raw.performanceMax),
|
||||
salesMin: normalizeThreshold(raw.salesMin),
|
||||
salesMax: normalizeThreshold(raw.salesMax),
|
||||
};
|
||||
// 只记录「确实被取整」的溢出值,便于核对(决策 5)
|
||||
Object.entries(raw).forEach(([key, value]) => {
|
||||
const before = Number(value);
|
||||
if (!Number.isFinite(before)) return;
|
||||
if (rounded[key] !== before) {
|
||||
skipped.push({ rowNo, reason: `字段 ${key} 原值 ${value} 疑似计算溢出,已取整为 ${rounded[key]}(决策 5)`, rounding: true });
|
||||
}
|
||||
});
|
||||
list.push({
|
||||
// 配置表里是短名(如「电脑桌/书桌」),稍后会展开成 jst_sku.category 的完整值
|
||||
displayName: category,
|
||||
scaleTag,
|
||||
...rounded,
|
||||
priority: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||
sort: SCALE_TAG_ORDER.indexOf(scaleTag) + 1,
|
||||
});
|
||||
}
|
||||
return { list, skipped };
|
||||
}
|
||||
|
||||
// 读取 jst_sku.category 的全部真实取值(企划库与业务库同表,优先用可达的企划库)
|
||||
async function loadDbCategories() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: process.env.PLANNING_PUBLIC_DB_HOST,
|
||||
port: Number(process.env.PLANNING_PUBLIC_DB_PORT),
|
||||
user: process.env.PLANNING_PUBLIC_DB_USER,
|
||||
password: process.env.PLANNING_PUBLIC_DB_PASSWORD,
|
||||
database: process.env.PLANNING_PUBLIC_DB_NAME,
|
||||
connectTimeout: 20000,
|
||||
});
|
||||
const [rows] = await connection.query(
|
||||
`SELECT TRIM(category) AS category, COUNT(*) AS skuCount
|
||||
FROM jst_sku
|
||||
WHERE category IS NOT NULL AND TRIM(category) <> ''
|
||||
GROUP BY TRIM(category)`
|
||||
);
|
||||
await connection.end();
|
||||
return rows.map((row) => ({
|
||||
full: row.category,
|
||||
short: stripCategoryPrefix(row.category),
|
||||
skuCount: Number(row.skuCount),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「配置短名」展开为「数据库完整品类名」。
|
||||
* 一个短名可能对应多个 DB 品类(如「电脑桌/书桌」→ 住宅家具-电脑桌 + 住宅家具-书桌),
|
||||
* 展开后每个 DB 品类生成一行,displayName 保留原短名用于展示分组。
|
||||
*/
|
||||
function expandCategories(configRows, dbCategories) {
|
||||
const matched = [];
|
||||
const ambiguous = [];
|
||||
const missing = [];
|
||||
|
||||
configRows.forEach((row) => {
|
||||
const shortName = row.displayName;
|
||||
|
||||
// 1) 手工映射优先:聚合类目 → DB 类目(家族规则,自动展开子类目)
|
||||
if (MANUAL_CATEGORY_MAP[shortName]) {
|
||||
const rule = MANUAL_CATEGORY_MAP[shortName];
|
||||
let resolved = [];
|
||||
if (Array.isArray(rule)) {
|
||||
// 兼容:显式 DB 短名列表
|
||||
rule.forEach((targetShort) => {
|
||||
const hits = dbCategories.filter((item) => item.short === targetShort);
|
||||
if (hits.length) hits.forEach((hit) => resolved.push(hit));
|
||||
else console.warn(` ⚠️ 手工映射目标「${targetShort}」在 jst_sku 中找不到对应类目,已跳过(请核对 MANUAL_CATEGORY_MAP)`);
|
||||
});
|
||||
} else if (rule && rule.family === 'sofa') {
|
||||
resolved = dbCategories.filter((item) => item.short.includes('沙发') && !rule.exclude.includes(item.short));
|
||||
} else if (rule && rule.family === 'office') {
|
||||
resolved = dbCategories.filter((item) => /^商业\/办公家具\s*-\s*/.test(item.full) && !rule.exclude.includes(item.short));
|
||||
}
|
||||
if (!resolved.length) {
|
||||
missing.push(row);
|
||||
} else {
|
||||
resolved.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) 剥离前缀后同名
|
||||
let hits = dbCategories.filter((item) => item.short === shortName);
|
||||
// 3) 包含匹配兜底
|
||||
if (!hits.length) {
|
||||
hits = dbCategories.filter((item) => (
|
||||
item.short.includes(shortName) || shortName.includes(item.short)
|
||||
));
|
||||
}
|
||||
|
||||
if (!hits.length) {
|
||||
missing.push(row);
|
||||
} else {
|
||||
hits.forEach((hit) => matched.push({ ...row, category: hit.full }));
|
||||
if (hits.length > 1) ambiguous.push({ shortName, targets: hits });
|
||||
}
|
||||
});
|
||||
|
||||
return { matched, ambiguous, missing };
|
||||
}
|
||||
|
||||
function parseSabcMappings(rows) {
|
||||
const list = [];
|
||||
// H 列(规模标签)是纵向合并单元格(H4:H8、H9:H13 …),
|
||||
// XLSX.utils.sheet_to_json 只在合并区左上角单元格保留值,其余为 null,
|
||||
// 因此需要向下填充,否则只能解析出 5 条而不是 25 条。
|
||||
let carriedScaleTag = '';
|
||||
for (let rowNo = BLOCK2.startRow; rowNo <= BLOCK2.endRow; rowNo += 1) {
|
||||
const row = rows[rowNo - 1] || [];
|
||||
const ownScaleTag = text(row[BLOCK2.cols.scaleTag]);
|
||||
if (ownScaleTag) carriedScaleTag = ownScaleTag;
|
||||
|
||||
const scaleTag = carriedScaleTag;
|
||||
const profitLevel = text(row[BLOCK2.cols.profitLevel]);
|
||||
const sabcTag = text(row[BLOCK2.cols.sabc]);
|
||||
if (!scaleTag || !profitLevel || !sabcTag) continue;
|
||||
list.push({ scaleTag, profitLevel, sabcTag, sort: list.length + 1 });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
function buildScaleInsertSql(list) {
|
||||
if (!list.length) return '-- (无规模阈值数据)';
|
||||
const values = list.map((row) => ` (${sqlText(row.category)}, ${sqlText(row.displayName)}, ${sqlText(row.scaleTag)}, `
|
||||
+ `${sqlNumber(row.performanceMin)}, ${sqlNumber(row.performanceMax)}, `
|
||||
+ `${sqlNumber(row.salesMin)}, ${sqlNumber(row.salesMax)}, ${row.priority}, ${row.sort}, 1)`);
|
||||
return `INSERT INTO product_scale_thresholds\n`
|
||||
+ ' (category, display_name, scale_tag, performance_min, performance_max, sales_min, sales_max, priority, sort, enabled)\nVALUES\n'
|
||||
+ `${values.join(',\n')}\nON DUPLICATE KEY UPDATE\n`
|
||||
+ ' display_name = VALUES(display_name),\n'
|
||||
+ ' performance_min = VALUES(performance_min), performance_max = VALUES(performance_max),\n'
|
||||
+ ' sales_min = VALUES(sales_min), sales_max = VALUES(sales_max),\n'
|
||||
+ ' priority = VALUES(priority), sort = VALUES(sort), enabled = VALUES(enabled);';
|
||||
}
|
||||
|
||||
const PROFIT_LEVEL_RATE = {
|
||||
一级: 0.18,
|
||||
二级: 0.16,
|
||||
三级: 0.12,
|
||||
四级: 0.08,
|
||||
五级: 0.08,
|
||||
};
|
||||
|
||||
async function writeScaleThresholds(list) {
|
||||
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||
if (!list.length) return 0;
|
||||
const values = [];
|
||||
const placeholders = list.map((row) => {
|
||||
values.push(
|
||||
row.category, row.displayName, row.scaleTag,
|
||||
row.performanceMin, row.performanceMax, row.salesMin, row.salesMax,
|
||||
row.priority, row.sort, 1
|
||||
);
|
||||
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_scale_thresholds
|
||||
(category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function writeProfitLevels() {
|
||||
await database.local.query('DELETE FROM product_profit_levels');
|
||||
const values = [];
|
||||
const placeholders = PROFIT_LEVELS.map((levelCode, index) => {
|
||||
const rate = PROFIT_LEVEL_RATE[levelCode];
|
||||
values.push(levelCode, rate, `营业利润率≤${rate.toFixed(2)}`, index + 1, 1);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return PROFIT_LEVELS.length;
|
||||
}
|
||||
|
||||
async function writeSabcMappings(list) {
|
||||
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||
if (!list.length) return 0;
|
||||
const values = [];
|
||||
const placeholders = list.map((row) => {
|
||||
values.push(row.scaleTag, row.profitLevel, row.sabcTag, row.sort, 1);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
await database.local.query(
|
||||
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (!args.file) {
|
||||
console.error('缺少 --file=<xlsx 路径> 参数');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const { sheetName, rows } = readWorkbook(args.file);
|
||||
console.log(`读取工作簿:${args.file}`);
|
||||
console.log(`Sheet:${sheetName},共 ${rows.length} 行`);
|
||||
|
||||
const { list: configRows, skipped } = parseScaleThresholds(rows);
|
||||
const mappings = parseSabcMappings(rows);
|
||||
|
||||
console.log(`\n解析配置表:`);
|
||||
console.log(` 规模阈值配置行:${configRows.length}(按「配置短名 × 规模标签」计)`);
|
||||
console.log(` SABC 映射 :${mappings.length} 条`);
|
||||
|
||||
if (skipped.length) {
|
||||
console.log(`\n跳过 / 修正明细(${skipped.length} 条):`);
|
||||
skipped.forEach((item) => console.log(` 第 ${item.rowNo} 行:${item.reason}`));
|
||||
}
|
||||
|
||||
// ---- 品类名展开:配置短名 → jst_sku.category 完整名 ----
|
||||
const dbCategories = await loadDbCategories();
|
||||
const { matched: thresholds, ambiguous, missing } = expandCategories(configRows, dbCategories);
|
||||
|
||||
console.log(`\n品类名映射:`);
|
||||
console.log(` 数据库品类数:${dbCategories.length}`);
|
||||
console.log(` 展开后阈值行:${thresholds.length}(配置行 ${configRows.length} → DB 品类行)`);
|
||||
if (ambiguous.length) {
|
||||
// 同一个短名会出现在多个规模标签下,按短名去重后再报告
|
||||
const byShortName = new Map();
|
||||
ambiguous.forEach((item) => {
|
||||
if (!byShortName.has(item.shortName)) byShortName.set(item.shortName, item.targets);
|
||||
});
|
||||
console.log(`\n ⚠️ 一对多(${byShortName.size} 个配置名对应多个 DB 品类,已全部归入同一档阈值):`);
|
||||
byShortName.forEach((targets, shortName) => {
|
||||
console.log(` ${shortName}`);
|
||||
targets.forEach((t) => console.log(` → ${t.full} (${t.skuCount} SKU)`));
|
||||
});
|
||||
}
|
||||
if (missing.length) {
|
||||
console.log(`\n ❌ 匹配不到(${missing.length} 个,这些配置行不会生效,需在 MANUAL_CATEGORY_MAP 中补映射):`);
|
||||
[...new Set(missing.map((m) => m.displayName))].forEach((name) => console.log(` - ${name}`));
|
||||
}
|
||||
|
||||
const byTag = SCALE_TAG_ORDER.map((tag) => ({
|
||||
tag,
|
||||
count: thresholds.filter((row) => row.scaleTag === tag).length,
|
||||
}));
|
||||
console.log(`\n 按规模标签分布:${byTag.map((item) => `${item.tag} ${item.count}`).join(' / ')}`);
|
||||
console.log(` 展开后品类数:${new Set(thresholds.map((row) => row.category)).size}`);
|
||||
|
||||
if (args.printSql) {
|
||||
console.log('\n---------- 规模标签阈值 SQL ----------');
|
||||
console.log(buildScaleInsertSql(thresholds));
|
||||
console.log('\n(利润标签与 SABC 映射的 SQL 见 sql/20260911_create_product_tag_config_system_mysql.sql)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('\n--dry-run:未写入数据库。');
|
||||
return;
|
||||
}
|
||||
|
||||
const writtenThresholds = await writeScaleThresholds(thresholds);
|
||||
const writtenLevels = await writeProfitLevels();
|
||||
const writtenMappings = await writeSabcMappings(mappings);
|
||||
console.log('\n✅ 导入完成:');
|
||||
console.log(` product_scale_thresholds : ${writtenThresholds} 行`);
|
||||
console.log(` product_profit_levels : ${writtenLevels} 行`);
|
||||
console.log(` product_sabc_mappings : ${writtenMappings} 条`);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => database.close && database.close())
|
||||
.catch((error) => {
|
||||
console.error('导入失败:', error.message);
|
||||
process.exitCode = 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,218 +0,0 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const sql = require('mssql');
|
||||
|
||||
const batchSize = Math.max(100, Number.parseInt(process.env.PRODUCT_ANALYSIS_SYNC_BATCH_SIZE || '5000', 10));
|
||||
const targetSchema = /^[A-Za-z_][A-Za-z0-9_]*$/.test(process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA || '')
|
||||
? process.env.PRODUCT_ANALYSIS_REPLICA_SCHEMA
|
||||
: 'app';
|
||||
|
||||
const sourceConfig = {
|
||||
server: process.env.JIAN_SUN_DB_HOST,
|
||||
port: Number(process.env.JIAN_SUN_DB_PORT || 1433),
|
||||
database: process.env.JIAN_SUN_DB_NAME || 'JianSun',
|
||||
user: process.env.JIAN_SUN_DB_USER,
|
||||
password: process.env.JIAN_SUN_DB_PASSWORD,
|
||||
options: { encrypt: false, trustServerCertificate: true },
|
||||
connectionTimeout: 120000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||
};
|
||||
|
||||
const targetConfig = {
|
||||
server: process.env.CLOUD_DB_PUBLIC_HOST,
|
||||
port: Number(process.env.CLOUD_DB_PUBLIC_PORT || 1433),
|
||||
database: process.env.CLOUD_DB_PUBLIC_NAME || 'JSReportReplica',
|
||||
user: process.env.CLOUD_DB_PUBLIC_USER,
|
||||
password: process.env.CLOUD_DB_PUBLIC_PASSWORD,
|
||||
options: {
|
||||
encrypt: String(process.env.CLOUD_DB_PUBLIC_SSL || '').toLowerCase() === 'true',
|
||||
trustServerCertificate: true,
|
||||
},
|
||||
connectionTimeout: 120000,
|
||||
requestTimeout: 120000,
|
||||
pool: { max: 4, min: 0, idleTimeoutMillis: 120000 },
|
||||
};
|
||||
|
||||
const tables = [
|
||||
{
|
||||
name: 'pdd_DataActivityRecords',
|
||||
createColumns: `
|
||||
[id] int NOT NULL,
|
||||
[shopName] varchar(40) NULL,
|
||||
[createTime] varchar(20) NULL,
|
||||
[business_type_str] varchar(100) NULL,
|
||||
[activity_name] varchar(100) NULL,
|
||||
[activity_id] int NULL,
|
||||
[goods_name] varchar(100) NULL,
|
||||
[goods_id] bigint NULL,
|
||||
[hd_thumb_url] varchar(100) NULL,
|
||||
[enroll_time ] bigint NULL,
|
||||
[min_activity_sku_price] decimal(38, 2) NULL,
|
||||
[max_activity_sku_price] decimal(38, 2) NULL,
|
||||
[activity_goods_start_time] bigint NULL,
|
||||
[activity_goods_end_time] bigint NULL,
|
||||
[status] int NULL`,
|
||||
columns: [
|
||||
['id', sql.Int, false],
|
||||
['shopName', sql.VarChar(40), true],
|
||||
['createTime', sql.VarChar(20), true],
|
||||
['business_type_str', sql.VarChar(100), true],
|
||||
['activity_name', sql.VarChar(100), true],
|
||||
['activity_id', sql.Int, true],
|
||||
['goods_name', sql.VarChar(100), true],
|
||||
['goods_id', sql.BigInt, true],
|
||||
['hd_thumb_url', sql.VarChar(100), true],
|
||||
['enroll_time ', sql.BigInt, true],
|
||||
['min_activity_sku_price', sql.Decimal(38, 2), true],
|
||||
['max_activity_sku_price', sql.Decimal(38, 2), true],
|
||||
['activity_goods_start_time', sql.BigInt, true],
|
||||
['activity_goods_end_time', sql.BigInt, true],
|
||||
['status', sql.Int, true],
|
||||
],
|
||||
indexColumn: 'goods_id',
|
||||
},
|
||||
{
|
||||
name: 'pdd_GoodsQualities',
|
||||
createColumns: `
|
||||
[id] int NOT NULL,
|
||||
[avgDescRevScr1m] float NULL,
|
||||
[qltyRfndOrdrCntRto1m] float NULL,
|
||||
[ptHelpPzRate30d] float NULL,
|
||||
[midRto30d] float NULL,
|
||||
[goodsId] bigint NULL,
|
||||
[displayMallScoreRk] int NULL,
|
||||
[rankAvgDescRevScr1mStage] int NULL,
|
||||
[rankAvgDescRevScr1mStplStage] int NULL,
|
||||
[rankQltyRfndOrdrCntRto1mStage] int NULL,
|
||||
[rankQltyRfndOrdrCntRto1mStplStage] int NULL,
|
||||
[rankPtHelpPzRate30dStage] int NULL,
|
||||
[rankPtHelpPzRate30dStplStage] int NULL,
|
||||
[rankComscoreStage ] int NULL,
|
||||
[rankComscoreStplStage] int NULL,
|
||||
[goodsScoreRkStage] int NULL,
|
||||
[goodsScoreRkStplStage] int NULL,
|
||||
[createTime] varchar(20) NULL,
|
||||
[shopName] varchar(40) NULL`,
|
||||
columns: [
|
||||
['id', sql.Int, false],
|
||||
['avgDescRevScr1m', sql.Float, true],
|
||||
['qltyRfndOrdrCntRto1m', sql.Float, true],
|
||||
['ptHelpPzRate30d', sql.Float, true],
|
||||
['midRto30d', sql.Float, true],
|
||||
['goodsId', sql.BigInt, true],
|
||||
['displayMallScoreRk', sql.Int, true],
|
||||
['rankAvgDescRevScr1mStage', sql.Int, true],
|
||||
['rankAvgDescRevScr1mStplStage', sql.Int, true],
|
||||
['rankQltyRfndOrdrCntRto1mStage', sql.Int, true],
|
||||
['rankQltyRfndOrdrCntRto1mStplStage', sql.Int, true],
|
||||
['rankPtHelpPzRate30dStage', sql.Int, true],
|
||||
['rankPtHelpPzRate30dStplStage', sql.Int, true],
|
||||
['rankComscoreStage ', sql.Int, true],
|
||||
['rankComscoreStplStage', sql.Int, true],
|
||||
['goodsScoreRkStage', sql.Int, true],
|
||||
['goodsScoreRkStplStage', sql.Int, true],
|
||||
['createTime', sql.VarChar(20), true],
|
||||
['shopName', sql.VarChar(40), true],
|
||||
],
|
||||
indexColumn: 'goodsId',
|
||||
},
|
||||
];
|
||||
|
||||
function qualified(schema, tableName) {
|
||||
return `[${schema}].[${tableName}]`;
|
||||
}
|
||||
|
||||
function assertConfig(config, label) {
|
||||
const missing = ['server', 'database', 'user', 'password'].filter((key) => !config[key]);
|
||||
if (missing.length) throw new Error(`${label} missing configuration: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
async function ensureTargetTable(pool, definition) {
|
||||
const tableName = qualified(targetSchema, definition.name);
|
||||
const pkName = `PK_${definition.name}`;
|
||||
const lookupIndex = `IX_${definition.name}_${definition.indexColumn}_createTime`;
|
||||
await pool.request().batch(`
|
||||
IF OBJECT_ID(N'${tableName}', N'U') IS NULL
|
||||
BEGIN
|
||||
CREATE TABLE ${tableName} (
|
||||
${definition.createColumns},
|
||||
CONSTRAINT [${pkName}] PRIMARY KEY CLUSTERED ([id])
|
||||
);
|
||||
END;
|
||||
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE object_id = OBJECT_ID(N'${tableName}') AND name = N'${lookupIndex}')
|
||||
BEGIN
|
||||
CREATE INDEX [${lookupIndex}] ON ${tableName} ([${definition.indexColumn}], [createTime]);
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
function createBulkTable(definition) {
|
||||
const table = new sql.Table(definition.name);
|
||||
table.schema = targetSchema;
|
||||
table.create = false;
|
||||
definition.columns.forEach(([name, type, nullable]) => table.columns.add(name, type, { nullable }));
|
||||
return table;
|
||||
}
|
||||
|
||||
async function syncTable(sourcePool, targetPool, definition) {
|
||||
await ensureTargetTable(targetPool, definition);
|
||||
const sourceName = qualified('dbo', definition.name);
|
||||
const targetName = qualified(targetSchema, definition.name);
|
||||
const columnSql = definition.columns.map(([name]) => `[${name}]`).join(', ');
|
||||
const targetState = await targetPool.request().query(`SELECT COALESCE(MAX([id]), 0) AS maxId FROM ${targetName}`);
|
||||
let lastId = Number(targetState.recordset[0].maxId || 0);
|
||||
let copied = 0;
|
||||
|
||||
while (true) {
|
||||
const result = await sourcePool.request()
|
||||
.input('lastId', sql.Int, lastId)
|
||||
.query(`SELECT TOP (${batchSize}) ${columnSql} FROM ${sourceName} WHERE [id] > @lastId ORDER BY [id]`);
|
||||
const rows = result.recordset || [];
|
||||
if (!rows.length) break;
|
||||
|
||||
const bulkTable = createBulkTable(definition);
|
||||
rows.forEach((row) => bulkTable.rows.add(...definition.columns.map(([name]) => row[name])));
|
||||
await targetPool.request().bulk(bulkTable);
|
||||
lastId = Number(rows[rows.length - 1].id);
|
||||
copied += rows.length;
|
||||
console.log(`[product-analysis-sync] ${definition.name}: copied ${copied}, target max id ${lastId}`);
|
||||
}
|
||||
|
||||
const [sourceStats, targetStats] = await Promise.all([
|
||||
sourcePool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${sourceName}`),
|
||||
targetPool.request().query(`SELECT COUNT_BIG(*) AS total, MAX([id]) AS maxId, MAX(TRY_CONVERT(date, [createTime])) AS maxDate FROM ${targetName}`),
|
||||
]);
|
||||
return { copied, source: sourceStats.recordset[0], target: targetStats.recordset[0] };
|
||||
}
|
||||
|
||||
async function syncProductAnalysisReplica() {
|
||||
assertConfig(sourceConfig, 'JianSun source');
|
||||
assertConfig(targetConfig, 'JSReportReplica target');
|
||||
const [sourcePool, targetPool] = await Promise.all([
|
||||
new sql.ConnectionPool(sourceConfig).connect(),
|
||||
new sql.ConnectionPool(targetConfig).connect(),
|
||||
]);
|
||||
|
||||
try {
|
||||
const summary = {};
|
||||
for (const definition of tables) {
|
||||
summary[definition.name] = await syncTable(sourcePool, targetPool, definition);
|
||||
}
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return summary;
|
||||
} finally {
|
||||
await Promise.all([sourcePool.close(), targetPool.close()]);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
syncProductAnalysisReplica().catch((error) => {
|
||||
console.error('[product-analysis-sync] failed:', error.stack || error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncProductAnalysisReplica,
|
||||
};
|
||||
@ -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,55 +0,0 @@
|
||||
-- 商品监控配套:部门商品标签规则 + 部门SKU负责人配置
|
||||
-- 目标库:系统库 shujuku3_system(MySQL / MariaDB)
|
||||
-- 创建日期:2026-09-11
|
||||
--
|
||||
-- 说明:
|
||||
-- 1) product_tag_rules 部门标签 G-S/G-A/G-B/G-C 的阈值规则(tag_scope='department')
|
||||
-- 与公司 SABC(tag_scope='company')共用一张表,靠 tag_scope 区分。
|
||||
-- ⚠️ 公司 SABC 目前由「商品标签配置」页的三张表承载,本表仅启用 department 分支。
|
||||
-- 2) department_sku_owners 部门 × SKU/SPU → 负责人 的手工维护映射
|
||||
--
|
||||
-- 本文件与 services/localSchema.js 的 ensureProductMonitoringSchema() 等价,
|
||||
-- 服务启动时会自动 CREATE TABLE IF NOT EXISTS,本文件供 DBA 手工建表使用。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS product_tag_rules (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
|
||||
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签:G-S/G-A/G-B/G-C(company 分支预留 S/A/B/C/D)',
|
||||
metric_key VARCHAR(64) NOT NULL COMMENT '阈值指标:sales_qty_7d(近7天销售件数)/ sales_amount_7d(近7天销售额)',
|
||||
threshold_min DECIMAL(18,4) NULL COMMENT '区间起点(含)',
|
||||
threshold_max DECIMAL(18,4) NULL COMMENT '区间终点(不含)',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '同 scope 内升序命中即停',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
|
||||
KEY idx_scope_enabled (tag_scope, enabled),
|
||||
KEY idx_metric (metric_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)';
|
||||
|
||||
-- 初始数据:部门标签四档,默认口径为近 7 天销售件数(sales_qty_7d)
|
||||
-- ⚠️ 阈值为占位默认值,需在「系统设置 → 部门商品标签配置」中按业务口径调整后生效。
|
||||
INSERT INTO product_tag_rules (tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES
|
||||
('department', 'G-S', 'sales_qty_7d', 500, NULL, 1, 1),
|
||||
('department', 'G-A', 'sales_qty_7d', 200, 500, 2, 1),
|
||||
('department', 'G-B', 'sales_qty_7d', 50, 200, 3, 1),
|
||||
('department', 'G-C', 'sales_qty_7d', 0, 50, 4, 1)
|
||||
ON DUPLICATE KEY UPDATE tag_scope = tag_scope;
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS department_sku_owners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
department_id BIGINT UNSIGNED NOT NULL COMMENT '关联 departments.id',
|
||||
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
|
||||
user_id BIGINT UNSIGNED NOT NULL COMMENT '关联 users.id',
|
||||
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人;填值则为该 SPU 的负责人',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
|
||||
KEY idx_dept (department_id),
|
||||
KEY idx_spu (spu),
|
||||
KEY idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置';
|
||||
@ -1479,13 +1479,6 @@ function getDefaultPortByDriver(driver) {
|
||||
return 3306;
|
||||
}
|
||||
|
||||
function normalizeDatabaseDriver(driver) {
|
||||
const normalized = String(driver || '').trim().toLowerCase();
|
||||
if (normalized === 'postgresql' || normalized === 'postgres') return 'postgres';
|
||||
if (normalized === 'mssql') return 'mssql';
|
||||
return 'mysql';
|
||||
}
|
||||
|
||||
async function getStoredDatabaseConfig(configName) {
|
||||
const normalizedName = normalizeDatabaseConfigName(configName);
|
||||
let configs = await database.local.query(
|
||||
@ -1624,9 +1617,7 @@ router.put('/database', async (req, res) => {
|
||||
}
|
||||
|
||||
// 根据数据库类型设置默认端口
|
||||
const resolvedDriver = normalizeDatabaseDriver(
|
||||
driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql')
|
||||
);
|
||||
const resolvedDriver = driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql');
|
||||
const defaultPort = getDefaultPortByDriver(resolvedDriver);
|
||||
|
||||
const configData = {
|
||||
@ -1720,8 +1711,6 @@ router.post('/database/test', async (req, res) => {
|
||||
return res.status(400).json({ success: false, error: '请完整填写数据库连接信息' });
|
||||
}
|
||||
|
||||
driver = normalizeDatabaseDriver(driver);
|
||||
|
||||
if (driver === 'mssql') {
|
||||
// SQL Server 连接测试
|
||||
const sql = require('mssql');
|
||||
@ -1741,26 +1730,6 @@ router.post('/database/test', async (req, res) => {
|
||||
const pool = await sql.connect(config);
|
||||
await pool.request().query('SELECT 1 as test');
|
||||
await pool.close();
|
||||
} else if (driver === 'postgres') {
|
||||
const { Client } = require('pg');
|
||||
const client = new Client({
|
||||
host,
|
||||
port: port || 5432,
|
||||
database: dbName,
|
||||
user: username,
|
||||
password,
|
||||
ssl: ssl ? { rejectUnauthorized: false } : false,
|
||||
connectionTimeoutMillis: 10000,
|
||||
query_timeout: 10000,
|
||||
statement_timeout: 10000,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.query('SELECT 1 AS test');
|
||||
} finally {
|
||||
await client.end().catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// MySQL 连接测试
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
@ -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;
|
||||
364
backend/src/api/dashboard.js
Normal file
364
backend/src/api/dashboard.js
Normal file
@ -0,0 +1,364 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
const DASHBOARD_PERIOD_DAYS = Object.freeze({ day: 1, week: 7, month: 30 });
|
||||
const DASHBOARD_CACHE_TTL_MS = 60 * 1000;
|
||||
const DASHBOARD_QUERY_TIMEOUT_MS = Math.max(Number(process.env.DASHBOARD_QUERY_TIMEOUT_MS) || 180000, 30000);
|
||||
const dashboardCache = new Map();
|
||||
const dashboardInFlight = new Map();
|
||||
|
||||
function toNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function normalizeDate(value) {
|
||||
const text = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(text) ? text : '';
|
||||
}
|
||||
|
||||
function toDateString(value) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function addDays(value, offset) {
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
date.setUTCDate(date.getUTCDate() + offset);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function inclusiveDayCount(startDate, endDate) {
|
||||
const start = new Date(`${startDate}T00:00:00Z`);
|
||||
const end = new Date(`${endDate}T00:00:00Z`);
|
||||
return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1;
|
||||
}
|
||||
|
||||
function percentChange(current, previous) {
|
||||
if (!Number.isFinite(current) || !Number.isFinite(previous) || previous === 0) return null;
|
||||
return ((current - previous) * 100) / Math.abs(previous);
|
||||
}
|
||||
|
||||
function sumRows(rows) {
|
||||
return rows.reduce((result, row) => {
|
||||
result.payAmount += toNumber(row.payAmount);
|
||||
result.buyerCount += toNumber(row.buyerCount);
|
||||
result.visitorCount += toNumber(row.visitorCount);
|
||||
result.goodsPv += toNumber(row.goodsPv);
|
||||
result.payGoodsQty += toNumber(row.payGoodsQty);
|
||||
result.payOrderCount += toNumber(row.payOrderCount);
|
||||
result.favoriteCount += toNumber(row.favoriteCount);
|
||||
result.impressionCount += toNumber(row.impressionCount);
|
||||
return result;
|
||||
}, {
|
||||
payAmount: 0,
|
||||
buyerCount: 0,
|
||||
visitorCount: 0,
|
||||
goodsPv: 0,
|
||||
payGoodsQty: 0,
|
||||
payOrderCount: 0,
|
||||
favoriteCount: 0,
|
||||
impressionCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async function runDashboardQuery(name, sqlText, params = {}) {
|
||||
try {
|
||||
return {
|
||||
name,
|
||||
rows: await jianSunDb.query(sqlText, params, { requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }),
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`[dashboard] ${name} query failed:`, error.message);
|
||||
return { name, rows: [], error };
|
||||
}
|
||||
}
|
||||
|
||||
function buildDashboardMetrics(current, previous) {
|
||||
const conversionRate = current.visitorCount > 0
|
||||
? (current.buyerCount * 100) / current.visitorCount
|
||||
: null;
|
||||
const previousConversionRate = previous.visitorCount > 0
|
||||
? (previous.buyerCount * 100) / previous.visitorCount
|
||||
: null;
|
||||
return [
|
||||
{ key: 'payAmount', label: '支付金额', value: current.payAmount, previous: previous.payAmount, change: percentChange(current.payAmount, previous.payAmount), format: 'money' },
|
||||
{ key: 'payOrderCount', label: '支付订单数', value: Math.round(current.payOrderCount), previous: Math.round(previous.payOrderCount), change: percentChange(current.payOrderCount, previous.payOrderCount), format: 'integer' },
|
||||
{ key: 'visitorCount', label: '访客数', value: Math.round(current.visitorCount), previous: Math.round(previous.visitorCount), change: percentChange(current.visitorCount, previous.visitorCount), format: 'integer' },
|
||||
{ key: 'buyerCount', label: '支付买家数', value: Math.round(current.buyerCount), previous: Math.round(previous.buyerCount), change: percentChange(current.buyerCount, previous.buyerCount), format: 'integer' },
|
||||
{ key: 'conversionRate', label: '支付转化率', value: conversionRate, previous: previousConversionRate, change: percentChange(conversionRate, previousConversionRate), format: 'percent' },
|
||||
{ key: 'payGoodsQty', label: '支付件数', value: Math.round(current.payGoodsQty), previous: Math.round(previous.payGoodsQty), change: percentChange(current.payGoodsQty, previous.payGoodsQty), format: 'integer' },
|
||||
{ key: 'favoriteCount', label: '收藏人数', value: Math.round(current.favoriteCount), previous: Math.round(previous.favoriteCount), change: percentChange(current.favoriteCount, previous.favoriteCount), format: 'integer' },
|
||||
{ key: 'goodsPv', label: '商品浏览量', value: Math.round(current.goodsPv), previous: Math.round(previous.goodsPv), change: percentChange(current.goodsPv, previous.goodsPv), format: 'integer' },
|
||||
];
|
||||
}
|
||||
|
||||
function buildEmptyDashboardMetrics() {
|
||||
return [
|
||||
{ key: 'payAmount', label: '支付金额', value: null, previous: null, change: null, format: 'money' },
|
||||
{ key: 'payOrderCount', label: '支付订单数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'visitorCount', label: '访客数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'buyerCount', label: '支付买家数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'conversionRate', label: '支付转化率', value: null, previous: null, change: null, format: 'percent' },
|
||||
{ key: 'payGoodsQty', label: '支付件数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'favoriteCount', label: '收藏人数', value: null, previous: null, change: null, format: 'integer' },
|
||||
{ key: 'goodsPv', label: '商品浏览量', value: null, previous: null, change: null, format: 'integer' },
|
||||
];
|
||||
}
|
||||
|
||||
router.get('/meta', async (_req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT shopName
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
ORDER BY shopName`,
|
||||
{},
|
||||
{ requestTimeout: DASHBOARD_QUERY_TIMEOUT_MS }
|
||||
);
|
||||
res.json({ success: true, data: { platform: 'pdd', shops: rows.map((row) => row.shopName) } });
|
||||
} catch (error) {
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
let settleDashboard = null;
|
||||
let activeCacheKey = '';
|
||||
try {
|
||||
const requestedStartDate = normalizeDate(req.query.startDate);
|
||||
const requestedEndDate = normalizeDate(req.query.endDate);
|
||||
const hasCustomDate = Boolean(requestedStartDate || requestedEndDate);
|
||||
if (hasCustomDate && (!requestedStartDate || !requestedEndDate)) {
|
||||
return res.status(400).json({ success: false, error: '自定义日期需要同时选择开始日期和结束日期' });
|
||||
}
|
||||
const customDays = hasCustomDate ? inclusiveDayCount(requestedStartDate, requestedEndDate) : 0;
|
||||
if (hasCustomDate && customDays < 1) {
|
||||
return res.status(400).json({ success: false, error: '开始日期不能晚于结束日期' });
|
||||
}
|
||||
if (hasCustomDate && customDays > 90) {
|
||||
return res.status(400).json({ success: false, error: '自定义日期范围不能超过90天' });
|
||||
}
|
||||
const requestedPeriod = hasCustomDate
|
||||
? 'custom'
|
||||
: DASHBOARD_PERIOD_DAYS[req.query.period] ? req.query.period : 'week';
|
||||
const shopName = String(req.query.shopName || '').trim();
|
||||
const cacheKey = JSON.stringify({
|
||||
period: requestedPeriod,
|
||||
startDate: requestedStartDate,
|
||||
endDate: requestedEndDate,
|
||||
shopName,
|
||||
});
|
||||
activeCacheKey = cacheKey;
|
||||
const cached = dashboardCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return res.json({ success: true, data: cached.data });
|
||||
}
|
||||
const pending = dashboardInFlight.get(cacheKey);
|
||||
if (pending) {
|
||||
const result = await pending;
|
||||
if (result.error) throw result.error;
|
||||
return res.json({ success: true, data: result.data });
|
||||
}
|
||||
|
||||
dashboardInFlight.set(cacheKey, new Promise((resolve) => {
|
||||
settleDashboard = resolve;
|
||||
}));
|
||||
|
||||
const period = requestedPeriod;
|
||||
const days = hasCustomDate ? customDays : DASHBOARD_PERIOD_DAYS[period];
|
||||
const [salesResult, serviceResult, starResult] = await Promise.all([
|
||||
runDashboardQuery(
|
||||
'sales',
|
||||
`WITH source_bounds AS (
|
||||
SELECT TOP (1) LEFT(createTime, 10) AS maxDate
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE createTime >= '2000-01-01'
|
||||
AND createTime < '2100-01-01'
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
ORDER BY createTime DESC
|
||||
),
|
||||
daily_coverage AS (
|
||||
SELECT LEFT(g.createTime, 10) AS statDate, COUNT(DISTINCT g.shopName) AS shopCount
|
||||
FROM dbo.pdd_DataGoods g
|
||||
CROSS JOIN source_bounds b
|
||||
WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, -45, TRY_CONVERT(date, b.maxDate)), 23)
|
||||
AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, b.maxDate)), 23)
|
||||
AND g.shopName IS NOT NULL
|
||||
AND LTRIM(RTRIM(g.shopName)) <> ''
|
||||
AND (@shopName = '' OR g.shopName = @shopName)
|
||||
GROUP BY LEFT(g.createTime, 10)
|
||||
),
|
||||
coverage_target AS (
|
||||
SELECT MAX(shopCount) AS maxShopCount FROM daily_coverage
|
||||
),
|
||||
available_end AS (
|
||||
SELECT MAX(c.statDate) AS latestDate
|
||||
FROM daily_coverage c
|
||||
CROSS JOIN coverage_target t
|
||||
WHERE c.shopCount >= t.maxShopCount * 0.8
|
||||
),
|
||||
selected_end AS (
|
||||
SELECT CASE
|
||||
WHEN @requestedEndDate <> '' AND @requestedEndDate < latestDate THEN @requestedEndDate
|
||||
ELSE latestDate
|
||||
END AS latestDate
|
||||
FROM available_end
|
||||
)
|
||||
SELECT
|
||||
LEFT(g.createTime, 10) AS statDate,
|
||||
e.latestDate AS latestDate,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrAmt), 0)) AS payAmount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrUsrCnt), 0)) AS buyerCount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsUv), 0)) AS visitorCount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsPv), 0)) AS goodsPv,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrGoodsQty), 0)) AS payGoodsQty,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.payOrdrCnt), 0)) AS payOrderCount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.goodsFavCnt), 0)) AS favoriteCount,
|
||||
SUM(COALESCE(TRY_CONVERT(decimal(38, 6), g.imprUsrCnt), 0)) AS impressionCount
|
||||
FROM dbo.pdd_DataGoods g
|
||||
CROSS JOIN selected_end e
|
||||
WHERE g.createTime >= CONVERT(varchar(10), DATEADD(DAY, 1 - @historyDays, TRY_CONVERT(date, e.latestDate)), 23)
|
||||
AND g.createTime < CONVERT(varchar(10), DATEADD(DAY, 1, TRY_CONVERT(date, e.latestDate)), 23)
|
||||
AND (@shopName = '' OR g.shopName = @shopName)
|
||||
GROUP BY LEFT(g.createTime, 10), e.latestDate
|
||||
ORDER BY LEFT(g.createTime, 10)`,
|
||||
{ historyDays: days * 2, requestedEndDate: requestedEndDate || '', shopName }
|
||||
),
|
||||
runDashboardQuery(
|
||||
'service-score',
|
||||
`WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber
|
||||
FROM dbo.pdd_MallServiceScore
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
AND createTime LIKE '[12][0-9][0-9][0-9]-%'
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
)
|
||||
SELECT
|
||||
MAX(LEFT(createTime, 10)) AS createTime,
|
||||
COUNT(*) AS shopCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), cstmrServScore)) AS cstmrServScore,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), spLmScore)) AS spLmScore,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), fhLmScore)) AS fhLmScore,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), wlLmScore)) AS wlLmScore
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1`,
|
||||
{ shopName }
|
||||
),
|
||||
runDashboardQuery(
|
||||
'shop-stars',
|
||||
`WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY shopName ORDER BY LEFT(createTime, 10) DESC, id DESC) AS rowNumber
|
||||
FROM dbo.pdd_ShopStars
|
||||
WHERE shopName IS NOT NULL AND LTRIM(RTRIM(shopName)) <> ''
|
||||
AND createTime LIKE '[12][0-9][0-9][0-9]-%'
|
||||
AND (@shopName = '' OR shopName = @shopName)
|
||||
)
|
||||
SELECT
|
||||
MAX(LEFT(createTime, 10)) AS createTime,
|
||||
COUNT(*) AS shopCount,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), mallStarTomms)) AS mallStarTomms,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), gmvRk)) AS gmvRk,
|
||||
AVG(TRY_CONVERT(decimal(18, 6), scoreRk)) AS scoreRk
|
||||
FROM ranked
|
||||
WHERE rowNumber = 1`,
|
||||
{ shopName }
|
||||
),
|
||||
]);
|
||||
const salesRows = salesResult.rows;
|
||||
const serviceRows = serviceResult.rows;
|
||||
const starRows = starResult.rows;
|
||||
const warnings = [salesResult, serviceResult, starResult]
|
||||
.filter((result) => result.error)
|
||||
.map((result) => result.name);
|
||||
const normalizedSales = salesRows
|
||||
.map((row) => ({
|
||||
date: String(row.statDate || ''),
|
||||
payAmount: toNumber(row.payAmount),
|
||||
buyerCount: toNumber(row.buyerCount),
|
||||
visitorCount: toNumber(row.visitorCount),
|
||||
goodsPv: toNumber(row.goodsPv),
|
||||
payGoodsQty: toNumber(row.payGoodsQty),
|
||||
payOrderCount: toNumber(row.payOrderCount),
|
||||
favoriteCount: toNumber(row.favoriteCount),
|
||||
impressionCount: toNumber(row.impressionCount),
|
||||
}))
|
||||
.filter((row) => normalizeDate(row.date));
|
||||
const latestDate = String(salesRows[0]?.latestDate || normalizedSales.at(-1)?.date || '');
|
||||
const currentStart = hasCustomDate ? requestedStartDate : latestDate ? addDays(latestDate, 1 - days) : '';
|
||||
const previousEnd = currentStart ? addDays(currentStart, -1) : '';
|
||||
const previousStart = previousEnd ? addDays(previousEnd, 1 - days) : '';
|
||||
const currentRows = normalizedSales.filter((row) => row.date >= currentStart && row.date <= latestDate);
|
||||
const previousRows = normalizedSales.filter((row) => row.date >= previousStart && row.date <= previousEnd);
|
||||
const current = sumRows(currentRows);
|
||||
const previous = sumRows(previousRows);
|
||||
const metrics = latestDate && !salesResult.error
|
||||
? buildDashboardMetrics(current, previous)
|
||||
: buildEmptyDashboardMetrics();
|
||||
|
||||
const spend = null;
|
||||
const gmv = null;
|
||||
const roi = null;
|
||||
|
||||
const responseData = {
|
||||
platform: 'pdd',
|
||||
source: 'jiansun_database',
|
||||
scope: shopName ? 'single-shop' : 'all-shops',
|
||||
shopName: shopName || '全部店铺',
|
||||
warnings,
|
||||
period,
|
||||
days,
|
||||
latestDate,
|
||||
startDate: currentStart,
|
||||
previousStart,
|
||||
previousEnd,
|
||||
metrics,
|
||||
trend: currentRows,
|
||||
promotion: {
|
||||
spend,
|
||||
gmv,
|
||||
roi,
|
||||
clickCount: null,
|
||||
orderCount: null,
|
||||
},
|
||||
refunds: {
|
||||
amount: null,
|
||||
rate: null,
|
||||
products: [],
|
||||
},
|
||||
store: {
|
||||
serviceDate: toDateString(serviceRows[0]?.createTime),
|
||||
starDate: toDateString(starRows[0]?.createTime),
|
||||
shopCount: Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount)),
|
||||
star: starRows[0]?.mallStarTomms == null ? null : toNumber(starRows[0].mallStarTomms),
|
||||
peerDescription: shopName
|
||||
? shopName
|
||||
: `已汇总 ${Math.max(toNumber(serviceRows[0]?.shopCount), toNumber(starRows[0]?.shopCount))} 家店铺`,
|
||||
activityRank: starRows[0]?.gmvRk == null ? null : toNumber(starRows[0].gmvRk),
|
||||
scoreRank: starRows[0]?.scoreRk == null ? null : toNumber(starRows[0].scoreRk),
|
||||
scores: [
|
||||
{ key: 'experience', label: '服务体验', value: serviceRows[0]?.cstmrServScore == null ? null : toNumber(serviceRows[0].cstmrServScore) },
|
||||
{ key: 'product', label: '商品服务', value: serviceRows[0]?.spLmScore == null ? null : toNumber(serviceRows[0].spLmScore) },
|
||||
{ key: 'logistics', label: '物流服务', value: serviceRows[0]?.wlLmScore == null ? null : toNumber(serviceRows[0].wlLmScore) },
|
||||
{ key: 'shipping', label: '发货服务', value: serviceRows[0]?.fhLmScore == null ? null : toNumber(serviceRows[0].fhLmScore) },
|
||||
],
|
||||
},
|
||||
};
|
||||
dashboardCache.set(cacheKey, { data: responseData, expiresAt: Date.now() + DASHBOARD_CACHE_TTL_MS });
|
||||
settleDashboard({ data: responseData });
|
||||
dashboardInFlight.delete(cacheKey);
|
||||
res.json({ success: true, data: responseData });
|
||||
} catch (error) {
|
||||
settleDashboard?.({ error });
|
||||
if (activeCacheKey) dashboardInFlight.delete(activeCacheKey);
|
||||
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
module.exports = router;
|
||||
@ -1,268 +0,0 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function safeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeSortField(field) {
|
||||
const allowed = new Set([
|
||||
'id', 'shopName', 'createTime', 'activity_name', 'activity_id',
|
||||
'goods_name', 'goods_id', 'min_activity_sku_price', 'max_activity_sku_price',
|
||||
'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||
]);
|
||||
return allowed.has(field) ? field : 'activity_goods_start_time';
|
||||
}
|
||||
|
||||
function normalizeSortOrder(order) {
|
||||
return String(order || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
}
|
||||
|
||||
function escapeColumn(field) {
|
||||
const allowed = new Set([
|
||||
'id', 'shopName', 'createTime', 'business_type_str', 'activity_name', 'activity_id',
|
||||
'goods_name', 'goods_id', 'hd_thumb_url', 'enroll_time', 'min_activity_sku_price',
|
||||
'max_activity_sku_price', 'activity_goods_start_time', 'activity_goods_end_time', 'status',
|
||||
]);
|
||||
if (!allowed.has(field)) return '[id]';
|
||||
return field === 'enroll_time' ? '[enroll_time]' : `[${field}]`;
|
||||
}
|
||||
|
||||
async function getSecondLatestDate() {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
if (rows.length >= 2) return rows[1].createDate;
|
||||
if (rows.length === 1) return rows[0].createDate;
|
||||
return '';
|
||||
}
|
||||
|
||||
router.get('/latest-date', async (req, res) => {
|
||||
try {
|
||||
const date = await getSecondLatestDate();
|
||||
res.json({ success: true, data: { date } });
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询最新数据日期失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'activity_goods_start_time',
|
||||
sortOrder = 'DESC',
|
||||
shopName,
|
||||
activityType,
|
||||
activityName,
|
||||
activityId,
|
||||
goodsName,
|
||||
goodsId,
|
||||
status,
|
||||
date,
|
||||
} = req.query;
|
||||
|
||||
const currentPage = safeInt(page, 1);
|
||||
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
const sortColumn = normalizeSortField(sortBy);
|
||||
const sortDir = normalizeSortOrder(sortOrder);
|
||||
|
||||
const whereParts = [];
|
||||
const params = {};
|
||||
|
||||
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||
if (filterDate) {
|
||||
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||
params.filterDate = filterDate;
|
||||
}
|
||||
|
||||
if (shopName && shopName.trim()) {
|
||||
whereParts.push('[shopName] LIKE @shopName');
|
||||
params.shopName = `%${shopName.trim()}%`;
|
||||
}
|
||||
if (activityType && activityType.trim()) {
|
||||
whereParts.push('[business_type_str] LIKE @activityType');
|
||||
params.activityType = `%${activityType.trim()}%`;
|
||||
}
|
||||
if (activityName && activityName.trim()) {
|
||||
whereParts.push('[activity_name] LIKE @activityName');
|
||||
params.activityName = `%${activityName.trim()}%`;
|
||||
}
|
||||
if (activityId && String(activityId).trim()) {
|
||||
whereParts.push('[activity_id] = @activityId');
|
||||
params.activityId = Number(activityId);
|
||||
}
|
||||
if (goodsName && goodsName.trim()) {
|
||||
whereParts.push('[goods_name] LIKE @goodsName');
|
||||
params.goodsName = `%${goodsName.trim()}%`;
|
||||
}
|
||||
if (goodsId && String(goodsId).trim()) {
|
||||
whereParts.push('[goods_id] = @goodsId');
|
||||
params.goodsId = String(goodsId).trim();
|
||||
}
|
||||
if (status !== undefined && status !== '' && status !== 'all') {
|
||||
whereParts.push('[status] = @status');
|
||||
params.status = Number(status);
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const countResult = await jianSunDb.query(
|
||||
`SELECT COUNT(*) AS total FROM dbo.pdd_DataActivityRecords ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
const dataResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[id],
|
||||
[shopName],
|
||||
[createTime],
|
||||
[business_type_str] AS activityType,
|
||||
[activity_name] AS activityName,
|
||||
[activity_id] AS activityId,
|
||||
[goods_name] AS goodsName,
|
||||
[goods_id] AS goodsId,
|
||||
[hd_thumb_url] AS thumbUrl,
|
||||
[enroll_time] AS enrollTime,
|
||||
TRY_CONVERT(decimal(18,2), [min_activity_sku_price]) / 100.0 AS minPrice,
|
||||
TRY_CONVERT(decimal(18,2), [max_activity_sku_price]) / 100.0 AS maxPrice,
|
||||
[activity_goods_start_time] AS activityStartTime,
|
||||
[activity_goods_end_time] AS activityEndTime,
|
||||
[status],
|
||||
CASE WHEN [status] = 501 THEN N'报名成功' ELSE CONCAT(N'状态 ', [status]) END AS statusLabel
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}
|
||||
ORDER BY ${escapeColumn(sortColumn)} ${sortDir}
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||
{ ...params, offset, limit }
|
||||
);
|
||||
|
||||
const statusCountsResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[status],
|
||||
COUNT(*) AS count
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}
|
||||
GROUP BY [status]
|
||||
ORDER BY [status]`,
|
||||
params
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
let totalAll = 0;
|
||||
statusCountsResult.forEach((row) => {
|
||||
statusCounts[row.status] = row.count;
|
||||
totalAll += row.count;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list: dataResult,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: limit,
|
||||
filterDate,
|
||||
statusCounts: { all: totalAll, ...statusCounts },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询活动记录失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { date } = req.query;
|
||||
const filterDate = (date && String(date).trim()) || await getSecondLatestDate();
|
||||
|
||||
const whereSql = filterDate ? 'WHERE LEFT([createTime], 10) = @filterDate' : '';
|
||||
const params = filterDate ? { filterDate } : {};
|
||||
|
||||
const [row] = await jianSunDb.query(
|
||||
`SELECT
|
||||
COUNT(*) AS totalRecords,
|
||||
SUM(CASE WHEN [status] = 501 THEN 1 ELSE 0 END) AS successCount,
|
||||
COUNT(DISTINCT TRY_CONVERT(varchar(50), [goods_id])) AS goodsCount,
|
||||
COUNT(DISTINCT [shopName]) AS shopCount,
|
||||
COUNT(DISTINCT [activity_id]) AS activityIdCount,
|
||||
COUNT(DISTINCT [business_type_str]) AS activityTypeCount,
|
||||
AVG(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS avgMinPrice,
|
||||
AVG(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS avgMaxPrice,
|
||||
MIN(TRY_CONVERT(decimal(18,2), [min_activity_sku_price])) / 100.0 AS globalMinPrice,
|
||||
MAX(TRY_CONVERT(decimal(18,2), [max_activity_sku_price])) / 100.0 AS globalMaxPrice
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
${whereSql}`,
|
||||
params
|
||||
);
|
||||
|
||||
const stats = row || {};
|
||||
const totalRecords = Number(stats.totalRecords) || 0;
|
||||
const successCount = Number(stats.successCount) || 0;
|
||||
const successRate = totalRecords > 0 ? ((successCount / totalRecords) * 100).toFixed(1) : '0.0';
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filterDate,
|
||||
totalRecords,
|
||||
successCount,
|
||||
successRate,
|
||||
goodsCount: Number(stats.goodsCount) || 0,
|
||||
shopCount: Number(stats.shopCount) || 0,
|
||||
activityIdCount: Number(stats.activityIdCount) || 0,
|
||||
activityTypeCount: Number(stats.activityTypeCount) || 0,
|
||||
avgMinPrice: stats.avgMinPrice != null ? Number(stats.avgMinPrice).toFixed(2) : '-',
|
||||
avgMaxPrice: stats.avgMaxPrice != null ? Number(stats.avgMaxPrice).toFixed(2) : '-',
|
||||
globalMinPrice: stats.globalMinPrice != null ? Number(stats.globalMinPrice).toFixed(2) : '-',
|
||||
globalMaxPrice: stats.globalMaxPrice != null ? Number(stats.globalMaxPrice).toFixed(2) : '-',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询汇总数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status-counts', async (req, res) => {
|
||||
try {
|
||||
const result = await jianSunDb.query(
|
||||
`SELECT
|
||||
[status],
|
||||
COUNT(*) AS count
|
||||
FROM dbo.pdd_DataActivityRecords
|
||||
GROUP BY [status]
|
||||
ORDER BY [status]`
|
||||
);
|
||||
|
||||
const statusCounts = {};
|
||||
let total = 0;
|
||||
result.forEach((row) => {
|
||||
statusCounts[row.status] = row.count;
|
||||
total += row.count;
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { all: total, ...statusCounts },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MarketingEvents] 查询状态统计失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,127 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getMkhBomList,
|
||||
getMkhBomDetail,
|
||||
listMkhBomExportRows,
|
||||
clearMkhBomCache,
|
||||
} = require('../services/mkhBomService');
|
||||
const {
|
||||
getMkhReplenishAlertAdminConfig,
|
||||
resetMkhReplenishAlertState,
|
||||
saveMkhReplenishAlertConfig,
|
||||
} = require('../services/mkhReplenishAlertSettings');
|
||||
const { checkMkhReplenishAlerts } = require('../jobs/mkhReplenishAlertJob');
|
||||
const {
|
||||
saveYydNotificationCenterConfig,
|
||||
} = require('../services/yydNotificationCenterSettings');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback) {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[MkhBom]', {
|
||||
message: error?.message || fallback,
|
||||
code: error?.code || null,
|
||||
status,
|
||||
});
|
||||
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 资料列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 单个 MKH 组合商品的 BOM 配件明细(打印用)
|
||||
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 明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 导出:当前筛选条件下的全部数据(不分页)
|
||||
// mode=products(默认)→ 商品清单;mode=components → 配件明细摊平
|
||||
router.get('/export', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await listMkhBomExportRows(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '导出 MKH-BOM 资料失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 补货预警推送设置(接收人 / 开关 / 推送范围)
|
||||
router.get('/alert-config', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载补货预警推送设置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存补货预警推送设置
|
||||
router.put('/alert-config', async (req, res) => {
|
||||
try {
|
||||
const data = await saveMkhReplenishAlertConfig(req.body || {});
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存补货预警推送设置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 立即检查并推送(dryRun: 只预览不发送;force: 忽略「只推新增」推全量;reset: 先清空已推送记录)
|
||||
router.post('/alert-check', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (body.reset === true) await resetMkhReplenishAlertState();
|
||||
const result = await checkMkhReplenishAlerts({
|
||||
dryRun: body.dryRun === true,
|
||||
force: body.force === true,
|
||||
});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
sendError(res, error, '执行补货预警推送失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 保存统一通知中心凭据(补货预警的推送通道)
|
||||
router.put('/notification-center', async (req, res) => {
|
||||
try {
|
||||
await saveYydNotificationCenterConfig(req.body || {});
|
||||
res.json({ success: true, data: await getMkhReplenishAlertAdminConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存统一通知中心配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 手动同步:先清空本页服务端缓存(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 } });
|
||||
} catch (error) {
|
||||
sendError(res, error, '同步 MKH-BOM 数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports._private = { buildSyncQuery };
|
||||
@ -1,59 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getModularStockList,
|
||||
getModularStockFilters,
|
||||
getPurchaseInTransitList,
|
||||
syncModularStock,
|
||||
} = require('../services/modularStockMonitorService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback) {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ModularStockMonitor]', {
|
||||
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 {
|
||||
res.json({ success: true, data: await getModularStockList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载配件库存监控列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 筛选下拉选项(品类 / 预警等级)
|
||||
router.get('/filters', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getModularStockFilters() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载配件库存监控筛选选项失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细
|
||||
router.get('/purchase-in-transit', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getPurchaseInTransitList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载采购在途明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 手动同步:重新取最新监控日,前端「同步」按钮调用后再拉一次列表即拿到最新快照
|
||||
router.post('/sync', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await syncModularStock() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '同步配件库存监控数据失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -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,20 +1,9 @@
|
||||
const express = require('express');
|
||||
const { getResolvedUserWithPageAccess } = require('../services/pageAccess');
|
||||
const { getOpsBIncentiveBoard, getOpsBIncentiveBatches, getOpsBIncentiveDaily } = require('../services/opsBIncentiveBoardService');
|
||||
const { getAlibaba1688Product } = require('../services/alibaba1688ProductService');
|
||||
const { attachEncodingDetail } = require('../services/alibaba1688ProductEncodingRegistry');
|
||||
const router = express.Router();
|
||||
|
||||
async function ensurePlanningUploadAccess(req) {
|
||||
const user = await getResolvedUserWithPageAccess(req.user.sub);
|
||||
if (!user || (user.role !== 'admin' && !user.allowedPageKeys?.includes('ops_b_incentive'))) {
|
||||
const error = new Error('当前账号没有激励看板权限');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async function ensureSelectionFissionAccess(req) {
|
||||
// Selection workflows are available to every authenticated user. They share
|
||||
// this router with template uploads, but must not inherit upload permissions.
|
||||
@ -40,43 +29,4 @@ router.get('/selection-fission/1688-products/:productId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive/batches', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const data = await getOpsBIncentiveBatches();
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B激励看板批次列表失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B激励看板批次列表失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const result = await getOpsBIncentiveBoard(req.query || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B拼多多激励看板失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B拼多多激励看板失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/analysis/ops-b-incentive/daily', async (req, res) => {
|
||||
try {
|
||||
await ensurePlanningUploadAccess(req);
|
||||
const result = await getOpsBIncentiveDaily(req.query || {});
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
console.error('获取运营中心B拼多多激励每日明细失败:', error);
|
||||
res.status(error.status || 500).json({
|
||||
error: error.message || '获取运营中心B拼多多激励每日明细失败',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,73 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const {
|
||||
getMonitoringList,
|
||||
getMonitoringFilters,
|
||||
getDepartmentTagConfig,
|
||||
saveDepartmentTagConfig,
|
||||
getProductOperators,
|
||||
} = require('../services/productMonitoringService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function sendError(res, error, fallback = '商品监控请求失败') {
|
||||
const status = Number(error?.statusCode) || 500;
|
||||
console.error('[ProductMonitoring]', {
|
||||
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 getMonitoringList(req.query || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载商品监控列表失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 筛选下拉选项
|
||||
router.get('/filters', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getMonitoringFilters() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载筛选选项失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||
router.get('/operators', async (req, res) => {
|
||||
try {
|
||||
const sku = req.query?.sku || req.query?.skuoid;
|
||||
if (!sku) {
|
||||
return res.status(400).json({ success: false, error: '缺少 sku 参数' });
|
||||
}
|
||||
return res.json({ success: true, data: { operators: await getProductOperators(sku) } });
|
||||
} catch (error) {
|
||||
return sendError(res, error, '加载运营明细失败');
|
||||
}
|
||||
});
|
||||
|
||||
// 部门标签 + 负责人配置(系统设置用)
|
||||
router.get('/department-config', async (_req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await getDepartmentTagConfig() });
|
||||
} catch (error) {
|
||||
sendError(res, error, '加载部门标签配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/department-config', async (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: await saveDepartmentTagConfig(req.body || {}) });
|
||||
} catch (error) {
|
||||
sendError(res, error, '保存部门标签配置失败');
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,417 +0,0 @@
|
||||
const express = require('express');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const middleDb = require('../config/middlePlatformDatabase');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
|
||||
function safeInt(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toNum(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function fmtMoney(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '-';
|
||||
}
|
||||
|
||||
function fmtRate(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? (n * 100).toFixed(2) : '-';
|
||||
}
|
||||
|
||||
function fmtNum(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n.toLocaleString('zh-CN') : '-';
|
||||
}
|
||||
|
||||
function getDateRange(period, customStart, customEnd) {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
|
||||
switch (period) {
|
||||
case 'realtime': {
|
||||
const d = new Date(today);
|
||||
return { start: formatDate(d), end: formatDate(d) };
|
||||
}
|
||||
case 'yesterday': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 1);
|
||||
return { start: formatDate(d), end: formatDate(d) };
|
||||
}
|
||||
case 'week': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - d.getDay() + (d.getDay() === 0 ? -6 : 1));
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'month': {
|
||||
const d = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'last7d': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'last30d': {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 29);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
case 'custom': {
|
||||
return {
|
||||
start: customStart || formatDate(today),
|
||||
end: customEnd || formatDate(today),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() - 6);
|
||||
return { start: formatDate(d), end: formatDate(today) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
// 环比:取紧邻上一段等长周期(如近7天 → 再往前7天;自定义区间 → 等长前移)
|
||||
function getPreviousRange(range) {
|
||||
const start = new Date(`${range.start}T00:00:00`);
|
||||
const end = new Date(`${range.end}T00:00:00`);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const lengthDays = Math.max(1, Math.round((end - start) / 86400000) + 1);
|
||||
const prevEnd = new Date(start);
|
||||
prevEnd.setDate(prevEnd.getDate() - 1);
|
||||
const prevStart = new Date(prevEnd);
|
||||
prevStart.setDate(prevStart.getDate() - (lengthDays - 1));
|
||||
return { start: formatDate(prevStart), end: formatDate(prevEnd) };
|
||||
}
|
||||
|
||||
const PROMOTION_SUMMARY_SELECT = `SELECT
|
||||
COUNT(*) AS totalRecords,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 AS totalOrderSpend,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [gmv])) / 1000.0 AS totalGmv,
|
||||
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [gmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||
ELSE 0 END AS avgRoi,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [spend])) / 1000.0 AS totalSpend,
|
||||
SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / 1000.0 AS totalNetGmv,
|
||||
CASE WHEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [netGmv])) / SUM(TRY_CONVERT(decimal(18,2), [orderSpend]))
|
||||
ELSE 0 END AS avgNetRoi,
|
||||
SUM(TRY_CONVERT(int, [netOrderNum])) AS totalNetOrderNum,
|
||||
CASE WHEN SUM(TRY_CONVERT(int, [netOrderNum])) > 0
|
||||
THEN SUM(TRY_CONVERT(decimal(18,2), [orderSpend])) / 1000.0 / SUM(TRY_CONVERT(int, [netOrderNum]))
|
||||
ELSE 0 END AS avgCostPerNetOrder
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate`;
|
||||
|
||||
async function queryPromotionSummary(range) {
|
||||
const [row] = await jianSunDb.query(PROMOTION_SUMMARY_SELECT, {
|
||||
startDate: range.start,
|
||||
endDate: range.end,
|
||||
});
|
||||
const stats = row || {};
|
||||
return {
|
||||
totalRecords: toNum(stats.totalRecords),
|
||||
orderSpend: toNum(stats.totalOrderSpend),
|
||||
gmv: toNum(stats.totalGmv),
|
||||
roi: toNum(stats.avgRoi),
|
||||
spend: toNum(stats.totalSpend),
|
||||
netGmv: toNum(stats.totalNetGmv),
|
||||
netRoi: toNum(stats.avgNetRoi),
|
||||
netOrderNum: toNum(stats.totalNetOrderNum),
|
||||
costPerNetOrder: toNum(stats.avgCostPerNetOrder),
|
||||
};
|
||||
}
|
||||
|
||||
// 环比变化率:(当前 - 上一周期) / |上一周期|,上一周期为 0 时返回 null
|
||||
function buildPeriodChanges(current, previous) {
|
||||
const changes = {};
|
||||
Object.keys(current).forEach((key) => {
|
||||
const base = Math.abs(previous[key]);
|
||||
changes[key] = base > 0 ? (current[key] - previous[key]) / base : null;
|
||||
});
|
||||
return changes;
|
||||
}
|
||||
|
||||
async function getImageMap(goodsIds) {
|
||||
if (!goodsIds.length) return new Map();
|
||||
try {
|
||||
const placeholders = goodsIds.map((_, i) => `@gid${i}`).join(',');
|
||||
const params = {};
|
||||
goodsIds.forEach((id, i) => { params[`gid${i}`] = String(id); });
|
||||
const rows = await middleDb.query(
|
||||
`SELECT DISTINCT CONVERT(varchar(50), goodsId) AS goodsId, hdThumbUrl
|
||||
FROM dbo.pdd_DataGoods
|
||||
WHERE CONVERT(varchar(50), goodsId) IN (${placeholders})`,
|
||||
params
|
||||
);
|
||||
const map = new Map();
|
||||
rows.forEach((row) => {
|
||||
if (row.hdThumbUrl) map.set(String(row.goodsId), row.hdThumbUrl);
|
||||
});
|
||||
return map;
|
||||
} catch {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/latest-date', async (req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
const date = rows.length >= 2 ? rows[1].createDate : (rows[0]?.createDate || '');
|
||||
res.json({ success: true, data: { date: String(date || '').trim() } });
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询最新数据日期失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/shops', async (req, res) => {
|
||||
try {
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT DISTINCT [shopName]
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [shopName] IS NOT NULL AND LTRIM(RTRIM([shopName])) != ''
|
||||
ORDER BY [shopName]`
|
||||
);
|
||||
res.json({ success: true, data: rows.map((r) => r.shopName) });
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询店铺列表失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/summary', async (req, res) => {
|
||||
try {
|
||||
const { date, period, start: customStart, end: customEnd } = req.query;
|
||||
let filterDate = (date && String(date).trim()) || '';
|
||||
let range;
|
||||
|
||||
if (period === 'custom') {
|
||||
range = getDateRange('custom', customStart, customEnd);
|
||||
} else if (period) {
|
||||
range = getDateRange(period);
|
||||
filterDate = '';
|
||||
}
|
||||
|
||||
if (!range) {
|
||||
// 单日口径:未带 period 时按最新数据日(默认取倒数第二个有数据的日期)
|
||||
if (!filterDate) {
|
||||
const dateRows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
filterDate = dateRows.length >= 2
|
||||
? String(dateRows[1].createDate || '').trim()
|
||||
: String(dateRows[0]?.createDate || '').trim();
|
||||
}
|
||||
range = { start: filterDate, end: filterDate };
|
||||
}
|
||||
|
||||
const previousRange = getPreviousRange(range);
|
||||
const [current, previous] = await Promise.all([
|
||||
queryPromotionSummary(range),
|
||||
previousRange ? queryPromotionSummary(previousRange) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
filterDate: `${range.start} ~ ${range.end}`,
|
||||
previousRange: previousRange ? `${previousRange.start} ~ ${previousRange.end}` : '',
|
||||
totalRecords: current.totalRecords,
|
||||
orderSpend: fmtMoney(current.orderSpend),
|
||||
gmv: fmtMoney(current.gmv),
|
||||
roi: fmtNum(current.roi),
|
||||
spend: fmtMoney(current.spend),
|
||||
netGmv: fmtMoney(current.netGmv),
|
||||
netRoi: fmtNum(current.netRoi),
|
||||
netOrderNum: fmtNum(current.netOrderNum),
|
||||
costPerNetOrder: fmtMoney(current.costPerNetOrder),
|
||||
changes: previous ? buildPeriodChanges(current, previous) : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询汇总数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/list', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'orderSpend',
|
||||
sortOrder = 'DESC',
|
||||
keyword,
|
||||
shopName,
|
||||
date,
|
||||
period,
|
||||
start: customStart,
|
||||
end: customEnd,
|
||||
} = req.query;
|
||||
|
||||
const currentPage = safeInt(page, 1);
|
||||
const limit = Math.min(safeInt(pageSize, 20), MAX_PAGE_SIZE);
|
||||
const offset = (currentPage - 1) * limit;
|
||||
|
||||
const whereParts = [];
|
||||
const params = {};
|
||||
|
||||
if (period && period !== 'custom') {
|
||||
const range = getDateRange(period);
|
||||
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||
params.startDate = range.start;
|
||||
params.endDate = range.end;
|
||||
} else if (period === 'custom' && customStart && customEnd) {
|
||||
whereParts.push('LEFT([createTime], 10) >= @startDate AND LEFT([createTime], 10) <= @endDate');
|
||||
params.startDate = customStart;
|
||||
params.endDate = customEnd;
|
||||
} else {
|
||||
let filterDate = (date && String(date).trim()) || '';
|
||||
if (!filterDate) {
|
||||
const dateRows = await jianSunDb.query(
|
||||
`SELECT DISTINCT TOP 2 LEFT([createTime], 10) AS createDate
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
WHERE [createTime] IS NOT NULL AND LTRIM(RTRIM([createTime])) != ''
|
||||
ORDER BY createDate DESC`
|
||||
);
|
||||
filterDate = dateRows.length >= 2
|
||||
? String(dateRows[1].createDate || '').trim()
|
||||
: String(dateRows[0]?.createDate || '').trim();
|
||||
}
|
||||
if (filterDate) {
|
||||
whereParts.push('LEFT([createTime], 10) = @filterDate');
|
||||
params.filterDate = filterDate;
|
||||
}
|
||||
}
|
||||
|
||||
if (keyword && keyword.trim()) {
|
||||
whereParts.push('([goodsName] LIKE @kw OR [goodsId] LIKE @kw)');
|
||||
params.kw = `%${keyword.trim()}%`;
|
||||
}
|
||||
if (shopName && shopName.trim()) {
|
||||
whereParts.push('[shopName] = @shopName');
|
||||
params.shopName = shopName.trim();
|
||||
}
|
||||
|
||||
const whereSql = whereParts.length > 0 ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||
|
||||
const allowedSort = new Set([
|
||||
'goodsName', 'goodsId', 'shopName', 'createTime', 'orderSpend', 'gmv', 'roi',
|
||||
'spend', 'orderNum', 'netGmv', 'netRoi', 'netOrderNum', 'netAvgPayAmount',
|
||||
'settlementGmv', 'settlementRoi', 'settlementOrder', 'impression', 'click', 'ctr', 'cvr',
|
||||
]);
|
||||
const sortCol = allowedSort.has(sortBy) ? `[${sortBy}]` : '[orderSpend]';
|
||||
const sortDir = String(sortOrder || '').toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
const countResult = await jianSunDb.query(
|
||||
`SELECT COUNT(*) AS total FROM dbo.pdd_ReportGoodsSpread ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const total = countResult[0]?.total || 0;
|
||||
|
||||
const dataResult = await jianSunDb.query(
|
||||
`SELECT
|
||||
[id],
|
||||
[goodsName],
|
||||
[goodsId],
|
||||
[shopName],
|
||||
[createTime],
|
||||
[orderSpend],
|
||||
[gmv],
|
||||
[roi],
|
||||
[spend],
|
||||
[orderNum],
|
||||
[transactionCost],
|
||||
[avgPayAmount],
|
||||
[directGmv],
|
||||
[indirectGmv],
|
||||
[directOrderNum],
|
||||
[indirectOrderNum],
|
||||
[netGmv],
|
||||
[netRoi],
|
||||
[netOrderNum],
|
||||
[netAvgPayAmount],
|
||||
[netRate],
|
||||
[settlementGmv],
|
||||
[settlementRoi],
|
||||
[settlementOrder],
|
||||
[refundGmvRate30d],
|
||||
[refundOrderRate30d],
|
||||
[exemptRefundGmvRate30d],
|
||||
[exemptRefundOrderRate30d],
|
||||
[settlementGmvRate],
|
||||
[settlementOrderRate],
|
||||
[costPerSettlementOrderForDeal],
|
||||
[impression],
|
||||
[click],
|
||||
[ctr],
|
||||
[cvr],
|
||||
[targetRoi],
|
||||
[targetBid]
|
||||
FROM dbo.pdd_ReportGoodsSpread
|
||||
${whereSql}
|
||||
ORDER BY ${sortCol} ${sortDir}
|
||||
OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY`,
|
||||
{ ...params, offset, limit }
|
||||
);
|
||||
|
||||
const goodsIds = dataResult.map((r) => String(r.goodsId)).filter(Boolean);
|
||||
const imageMap = await getImageMap(goodsIds);
|
||||
|
||||
const list = dataResult.map((row) => ({
|
||||
...row,
|
||||
thumbUrl: imageMap.get(String(row.goodsId)) || '',
|
||||
orderSpend: row.orderSpend != null ? toNum(row.orderSpend) / 1000.0 : null,
|
||||
gmv: row.gmv != null ? toNum(row.gmv) / 1000.0 : null,
|
||||
spend: row.spend != null ? toNum(row.spend) / 1000.0 : null,
|
||||
transactionCost: row.transactionCost != null ? toNum(row.transactionCost) / 1000.0 : null,
|
||||
avgPayAmount: row.avgPayAmount != null ? toNum(row.avgPayAmount) / 1000.0 : null,
|
||||
directGmv: row.directGmv != null ? toNum(row.directGmv) / 1000.0 : null,
|
||||
indirectGmv: row.indirectGmv != null ? toNum(row.indirectGmv) / 1000.0 : null,
|
||||
netGmv: row.netGmv != null ? toNum(row.netGmv) / 1000.0 : null,
|
||||
netAvgPayAmount: row.netAvgPayAmount != null ? toNum(row.netAvgPayAmount) / 1000.0 : null,
|
||||
settlementGmv: row.settlementGmv != null ? toNum(row.settlementGmv) / 1000.0 : null,
|
||||
costPerSettlementOrderForDeal: row.costPerSettlementOrderForDeal != null ? toNum(row.costPerSettlementOrderForDeal) / 1000.0 : null,
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
list,
|
||||
total,
|
||||
page: currentPage,
|
||||
pageSize: limit,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PromotionModule] 查询推广数据失败:', error.message);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
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,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,169 +0,0 @@
|
||||
/**
|
||||
* 产品资料系统数据库连接模块(PostgreSQL)
|
||||
*
|
||||
* 数据来源:系统库 database_configs 表中 name = 'product_information_database' 的配置。
|
||||
* 典型配置:postgres / 218.93.9.38:25432 / furniture3d_prod / external_full_reader / ssl=1
|
||||
*
|
||||
* 该库用于 MKH-BOM 资料页面的商品资料匹配:
|
||||
* external.intake_sku_items_ready_v1
|
||||
* —— 真实可用的 SKU 明细视图(约 4.6 万行)
|
||||
* —— source_sku_id 为聚水潭原始 SKU 编码(即业务库 BOM 的配件编码)
|
||||
* —— sku_code 为商品编码,barcode 为条码
|
||||
* external.warehouse_locations_v1 —— 库位主数据(当前仅 1 行:A-01 成品区)
|
||||
*
|
||||
* 说明:external.product_bom_v1 / product_skus_v1 当前为空视图,不可用。
|
||||
*/
|
||||
|
||||
const { Pool } = require('pg');
|
||||
const database = require('./database');
|
||||
|
||||
const CONFIG_NAME = 'product_information_database';
|
||||
const READY_VIEW = 'external.intake_sku_items_ready_v1';
|
||||
const LOCATION_VIEW = 'external.warehouse_locations_v1';
|
||||
|
||||
const parsePositiveInt = (value, fallback) => {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
};
|
||||
|
||||
const POOL_MAX = parsePositiveInt(process.env.PRODUCT_DB_POOL_MAX, 4);
|
||||
|
||||
let pool = null;
|
||||
let cachedSignature = '';
|
||||
|
||||
const truthy = (value) => (
|
||||
value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value || '') === '1'
|
||||
);
|
||||
|
||||
async function loadStoredConfig() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, host, port, database_name, username, password, \`ssl\`, driver, status
|
||||
FROM database_configs WHERE name = ? LIMIT 1`,
|
||||
[CONFIG_NAME]
|
||||
);
|
||||
|
||||
const config = rows[0];
|
||||
if (!config) {
|
||||
const error = new Error('产品资料系统数据库尚未配置,请在「系统设置 - 数据库配置」中完成配置');
|
||||
error.statusCode = 503;
|
||||
error.code = 'PRODUCT_DB_NOT_CONFIGURED';
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (String(config.driver || '').toLowerCase() !== 'postgres') {
|
||||
const error = new Error('产品资料系统数据库需要选择 PostgreSQL 类型');
|
||||
error.statusCode = 503;
|
||||
error.code = 'PRODUCT_DB_DRIVER_MISMATCH';
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
host: String(config.host || '').trim(),
|
||||
port: Number(config.port) || 5432,
|
||||
database: String(config.database_name || '').trim(),
|
||||
user: String(config.username || '').trim(),
|
||||
password: config.password || '',
|
||||
ssl: truthy(config.ssl),
|
||||
};
|
||||
}
|
||||
|
||||
const buildSignature = (config) => [
|
||||
config.id,
|
||||
config.host,
|
||||
config.port,
|
||||
config.database,
|
||||
config.user,
|
||||
config.ssl ? 'ssl' : 'nossl',
|
||||
].join('::');
|
||||
|
||||
async function getPool() {
|
||||
const config = await loadStoredConfig();
|
||||
const signature = buildSignature(config);
|
||||
|
||||
if (pool && signature === cachedSignature) {
|
||||
return pool;
|
||||
}
|
||||
|
||||
if (pool) {
|
||||
await pool.end().catch(() => {});
|
||||
pool = null;
|
||||
}
|
||||
|
||||
pool = new Pool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
ssl: config.ssl ? { rejectUnauthorized: false } : false,
|
||||
max: POOL_MAX,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 20000,
|
||||
query_timeout: 180000,
|
||||
statement_timeout: 180000,
|
||||
});
|
||||
|
||||
pool.on('error', (error) => {
|
||||
console.warn('[ProductDB] 连接池异常:', error.message);
|
||||
});
|
||||
|
||||
cachedSignature = signature;
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行查询,返回行数组。
|
||||
* @param {string} sqlText PostgreSQL 语句(使用 $1/$2 占位)
|
||||
* @param {Array} params 参数数组
|
||||
*/
|
||||
async function query(sqlText, params = []) {
|
||||
const maxAttempts = 2;
|
||||
let lastError = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const activePool = await getPool();
|
||||
const result = await activePool.query(sqlText, params);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error.code === 'PRODUCT_DB_NOT_CONFIGURED' || error.code === 'PRODUCT_DB_DRIVER_MISMATCH') {
|
||||
throw error;
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
console.warn(`[ProductDB] 查询失败,准备重连后重试 (${attempt}/${maxAttempts}): ${error.message}`);
|
||||
await reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
if (pool) {
|
||||
await pool.end().catch(() => {});
|
||||
}
|
||||
pool = null;
|
||||
cachedSignature = '';
|
||||
}
|
||||
|
||||
async function test() {
|
||||
try {
|
||||
const rows = await query('SELECT 1 AS test');
|
||||
return { success: true, message: '产品资料系统数据库连接成功', data: rows };
|
||||
} catch (error) {
|
||||
return { success: false, message: `连接失败: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
query,
|
||||
test,
|
||||
reset,
|
||||
getPool,
|
||||
CONFIG_NAME,
|
||||
READY_VIEW,
|
||||
LOCATION_VIEW,
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
@ -14,42 +14,13 @@ const buildPlanningPage = ({ pageKey, pageName, path, allowSubPaths = false }) =
|
||||
});
|
||||
|
||||
const SIDEBAR_PAGES = [
|
||||
buildPlanningPage({ pageKey: 'ops_b_incentive', pageName: '激励看板', path: '/planning/operation/ops-b-incentive' }),
|
||||
buildPlanningPage({
|
||||
pageKey: 'dashboard_preview',
|
||||
pageName: '仪表盘(开发预览)',
|
||||
path: '/planning/dev-style-preview',
|
||||
}),
|
||||
buildPlanningPage({
|
||||
pageKey: 'marketing_events',
|
||||
pageName: '营销活动',
|
||||
path: '/planning/operation/marketing-events',
|
||||
allowSubPaths: true,
|
||||
}),
|
||||
buildPlanningPage({ pageKey: 'promotion_module', pageName: '推广模块', path: '/planning/operation/promotion-module' }),
|
||||
buildPlanningPage({ pageKey: 'new_product_test', pageName: '新品测款', path: '/planning/operation/new-product-test' }),
|
||||
buildPlanningPage({ pageKey: 'product_analysis', pageName: '商品分析', path: '/planning/operation/product-analysis' }),
|
||||
buildPlanningPage({
|
||||
pageKey: 'modular_stock_monitor',
|
||||
pageName: '模块化库存监控 · 配件库存监控',
|
||||
path: '/planning/operation/modular-stock-monitor',
|
||||
}),
|
||||
buildPlanningPage({
|
||||
pageKey: 'mkh_bom',
|
||||
pageName: '模块化库存监控 · MKH-BOM资料',
|
||||
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: '系统设置',
|
||||
|
||||
@ -29,20 +29,7 @@ const thirdPartySsoAdminRoutes = require('./api/thirdPartySsoAdmin');
|
||||
const channelRoutes = require('./api/channel');
|
||||
const analysisRoutes = require('./api/analysis');
|
||||
const productCodeBoardRoutes = require('./api/productCodeBoard');
|
||||
const productAnalysisRoutes = require('./api/productAnalysis');
|
||||
const marketingEventsRoutes = require('./api/marketingEvents');
|
||||
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 dailyInventoryRoutes = require('./api/dailyInventory');
|
||||
const dailyInventoryNotificationRoutes = require('./api/dailyInventoryNotification');
|
||||
const stockSalesAnalysisRoutes = require('./api/stockSalesAnalysis');
|
||||
const pickingBinSalesAnalysisRoutes = require('./api/pickingBinSalesAnalysis');
|
||||
const returnInventoryWarningRoutes = require('./api/returnInventoryWarning');
|
||||
const returnMonitorRoutes = require('./api/returnMonitor');
|
||||
const returnStockWarningRoutes = require('./api/returnStockWarning');
|
||||
const dashboardRoutes = require('./api/dashboard');
|
||||
const database = require('./config/database');
|
||||
const cloudDb = require('./config/cloudDatabase');
|
||||
const jianSunDb = require('./config/jianSunDatabase');
|
||||
@ -53,20 +40,9 @@ const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductE
|
||||
const { startPddSkuDimensionSnapshotSchedulers } = require('./jobs/pddSkuDimensionSnapshotJob');
|
||||
const { startPlanningMayProductSnapshotSchedulers } = require('./jobs/planningMayProductSnapshotJob');
|
||||
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 { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService');
|
||||
const { ensureLocalSchema } = require('./services/localSchema');
|
||||
const {
|
||||
@ -147,7 +123,6 @@ function shouldHandlePlanningUploadPageCache(req) {
|
||||
req.path === '/'
|
||||
|| req.path.startsWith('/table/may_product_data')
|
||||
|| req.path.startsWith('/analysis/may-channel-insight')
|
||||
|| req.path.startsWith('/analysis/ops-b-incentive')
|
||||
|| req.path.startsWith('/selection-fission')
|
||||
) {
|
||||
return false;
|
||||
@ -665,25 +640,9 @@ async function startServer(options = {}) {
|
||||
cubejsApp.use('/api/desktop-presence', authMiddleware, desktopPresenceRoutes);
|
||||
cubejsApp.use('/api/desktop-execution', desktopExecutionTaskRoutes);
|
||||
cubejsApp.use('/api/product-code-board', authMiddleware, productCodeBoardRoutes);
|
||||
cubejsApp.use('/api/product-analysis', authMiddleware, productAnalysisRoutes);
|
||||
cubejsApp.use('/api/marketing-events', authMiddleware, marketingEventsRoutes);
|
||||
cubejsApp.use('/api/promotion-module', authMiddleware, promotionModuleRoutes);
|
||||
cubejsApp.use('/api/dashboard', authMiddleware, dashboardRoutes);
|
||||
cubejsApp.use('/api/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes);
|
||||
cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes);
|
||||
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);
|
||||
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);
|
||||
|
||||
// =====================
|
||||
// 数据分析 API(公开访问,可根据需要添加认证)
|
||||
@ -719,41 +678,11 @@ 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}`);
|
||||
console.log(`📡 服务地址: http://localhost:4000`);
|
||||
console.log(cubeMode === 'cube'
|
||||
? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api`
|
||||
? '📊 Cube.js API: http://localhost:4000/cubejs-api'
|
||||
: '📊 Cube.js API: 已降级不可用,业务 API 继续运行');
|
||||
|
||||
// 测试数据库连接
|
||||
@ -768,11 +697,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;
|
||||
@ -784,11 +708,7 @@ async function startServer(options = {}) {
|
||||
startPddSkuDimensionSnapshotSchedulers();
|
||||
startPlanningMayProductSnapshotSchedulers();
|
||||
startPlanningDingWorkflowSyncSchedulers();
|
||||
startOpsBIncentiveSnapshotSchedulers();
|
||||
startGoodsFirstSeenScheduler();
|
||||
startAlibaba1688ImageSearchExpiryScheduler();
|
||||
startMkhReplenishAlertScheduler();
|
||||
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,164 +0,0 @@
|
||||
const cron = require('node-cron');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const planningDb = require('../services/planningDatabase');
|
||||
const { pickSkuCaptureTime } = require('../services/opsBIncentiveBoardService');
|
||||
|
||||
const JOB_CRON = String(process.env.GOODS_FIRST_SEEN_CRON || '20 5 * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const SYNC_CHUNK_SIZE = 500;
|
||||
const BACKFILL_CHUNK_SIZE = 800;
|
||||
const PRODUCT_SNAPSHOT_TABLE = 'op_pdd_sku_dimension_goods_daily';
|
||||
|
||||
let schemaEnsured = false;
|
||||
|
||||
async function ensureTable() {
|
||||
if (schemaEnsured) return;
|
||||
await planningDb.query(`
|
||||
CREATE TABLE IF NOT EXISTS pdd_goods_first_seen (
|
||||
goods_id VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||
first_created_at DATETIME NOT NULL COMMENT '首次采集到的SKU创建时间(约等于上架时间)',
|
||||
last_capture_date DATE NOT NULL COMMENT '最近一次出现在采集批次的日期',
|
||||
is_onsale TINYINT NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
|
||||
schemaEnsured = true;
|
||||
}
|
||||
|
||||
function epochToDatetime(value) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num) || num <= 0) return null;
|
||||
const ms = num >= 1e12 ? num : num * 1000;
|
||||
const date = new Date(ms);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
// 同步单个采集批次:新商品写入首见时间,已存在商品只刷新最近采集日/在售状态(保留最初 first_created_at)
|
||||
async function syncCaptureDate(captureDate) {
|
||||
await ensureTable();
|
||||
const startedAt = Date.now();
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) AS goodsId,
|
||||
MIN(s.createdAt) AS minCreatedAt,
|
||||
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index))
|
||||
WHERE s.createTime = @captureDate
|
||||
AND s.goodsId IS NOT NULL AND LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId))) <> ''
|
||||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), s.goodsId)))`,
|
||||
{ captureDate },
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const records = [];
|
||||
rows.forEach((row) => {
|
||||
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||
if (!createdAt) return;
|
||||
records.push([String(row.goodsId), createdAt, captureDate, Number(row.onsale) === 1 ? 1 : 0]);
|
||||
});
|
||||
|
||||
const countBefore = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||
for (let offset = 0; offset < records.length; offset += SYNC_CHUNK_SIZE) {
|
||||
const chunk = records.slice(offset, offset + SYNC_CHUNK_SIZE);
|
||||
await planningDb.query(
|
||||
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_capture_date = VALUES(last_capture_date),
|
||||
is_onsale = VALUES(is_onsale)`,
|
||||
[chunk]
|
||||
);
|
||||
}
|
||||
const countAfter = await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen');
|
||||
|
||||
return {
|
||||
captureDate,
|
||||
goodsInBatch: records.length,
|
||||
upserted: records.length,
|
||||
newGoods: Math.max(0, Number(countAfter[0]?.n || 0) - Number(countBefore[0]?.n || 0)),
|
||||
total: Number(countAfter[0]?.n || 0),
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// 每日增量:取最近一个完整采集批次(复用激励看板的 7 天完整性校验)
|
||||
async function refreshLatestCapture() {
|
||||
const captureDate = await pickSkuCaptureTime(new Date().toISOString().slice(0, 10));
|
||||
if (!captureDate) {
|
||||
console.warn('[GoodsFirstSeen] 近7天无可用完整采集批次,跳过本次同步');
|
||||
return { skipped: true };
|
||||
}
|
||||
return syncCaptureDate(captureDate);
|
||||
}
|
||||
|
||||
// 历史回填:对当前快照表里的全部商品,走 goodsId 索引精确点查首次创建时间(不受采集历史跨度限制)
|
||||
async function backfillAllHistory() {
|
||||
await ensureTable();
|
||||
const startedAt = Date.now();
|
||||
const goodsRows = await planningDb.query(
|
||||
`SELECT DISTINCT goodsId FROM ${PRODUCT_SNAPSHOT_TABLE} WHERE statDate = (SELECT MAX(statDate) FROM ${PRODUCT_SNAPSHOT_TABLE})`
|
||||
);
|
||||
const goodsIds = [...new Set(goodsRows.map((row) => String(row.goodsId || '').trim()).filter(Boolean))];
|
||||
console.log(`[GoodsFirstSeen] 历史回填开始,快照商品 ${goodsIds.length} 个`);
|
||||
|
||||
const countBefore = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||
let synced = 0;
|
||||
for (let offset = 0; offset < goodsIds.length; offset += BACKFILL_CHUNK_SIZE) {
|
||||
const chunk = goodsIds.slice(offset, offset + BACKFILL_CHUNK_SIZE);
|
||||
const params = {};
|
||||
const placeholders = chunk.map((goodsId, index) => {
|
||||
params[`g${index}`] = Number(goodsId);
|
||||
return `@g${index}`;
|
||||
}).join(', ');
|
||||
const rows = await jianSunDb.query(
|
||||
`SELECT s.goodsId AS goodsId,
|
||||
MIN(TRY_CONVERT(BIGINT, s.createdAt)) AS minCreatedAt,
|
||||
MAX(s.createTime) AS lastCaptureRaw,
|
||||
MAX(TRY_CONVERT(INT, s.isOnsale)) AS onsale
|
||||
FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(INDEXOF_goodsId_pdd_Skus))
|
||||
WHERE s.goodsId IN (${placeholders})
|
||||
GROUP BY s.goodsId`,
|
||||
params,
|
||||
{ requestTimeout: 300000 }
|
||||
);
|
||||
|
||||
const records = [];
|
||||
rows.forEach((row) => {
|
||||
const createdAt = epochToDatetime(row.minCreatedAt);
|
||||
const lastCapture = epochToDatetime(row.lastCaptureRaw);
|
||||
if (!createdAt) return;
|
||||
records.push([String(row.goodsId), createdAt, lastCapture || createdAt, Number(row.onsale) === 1 ? 1 : 0]);
|
||||
});
|
||||
for (let i = 0; i < records.length; i += SYNC_CHUNK_SIZE) {
|
||||
const chunk = records.slice(i, i + SYNC_CHUNK_SIZE);
|
||||
await planningDb.query(
|
||||
`INSERT INTO pdd_goods_first_seen (goods_id, first_created_at, last_capture_date, is_onsale)
|
||||
VALUES ?
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_capture_date = VALUES(last_capture_date),
|
||||
is_onsale = VALUES(is_onsale)`,
|
||||
[chunk]
|
||||
);
|
||||
}
|
||||
synced += records.length;
|
||||
console.log(`[GoodsFirstSeen] 回填进度 ${Math.min(offset + BACKFILL_CHUNK_SIZE, goodsIds.length)}/${goodsIds.length},命中 ${records.length}`);
|
||||
}
|
||||
const countAfter = Number((await planningDb.query('SELECT COUNT(*) AS n FROM pdd_goods_first_seen'))[0]?.n || 0);
|
||||
const summary = { goodsInSnapshot: goodsIds.length, synced, newGoods: Math.max(0, countAfter - countBefore), total: countAfter, elapsedMs: Date.now() - startedAt };
|
||||
console.log(`[GoodsFirstSeen] 历史回填结束: ${JSON.stringify(summary)}`);
|
||||
return summary;
|
||||
}
|
||||
|
||||
function startGoodsFirstSeenScheduler() {
|
||||
const disabled = String(process.env.DISABLE_GOODS_FIRST_SEEN_JOB || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[GoodsFirstSeen] 首见时间同步任务已被禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
refreshLatestCapture().catch((error) => {
|
||||
console.error('[GoodsFirstSeen] 定时同步失败:', error.message);
|
||||
});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 商品首见时间同步任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { refreshLatestCapture, backfillAllHistory, syncCaptureDate, startGoodsFirstSeenScheduler };
|
||||
@ -1,396 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// MKH 成品补货预警 · 定时推送
|
||||
//
|
||||
// 业务口径(2026-09-17 业务确认):
|
||||
// ① 推送范围:缺货(可用天数 ≤ 0)+ 3 天内预警商品(默认,可在页面里改成仅缺货)
|
||||
// · 含「订单驱动」商品:近7日日销 = 0、但存在未发货订单的 MKH 成品
|
||||
// (口径与取数见 mkhBomService 文件头 ⑩;正文里以「待发货订单 N 件」代替日销展示)
|
||||
// · 含「配件不足」商品:配件现有库存可组装数 = 0(口径见 mkhBomService 文件头 ⑪)
|
||||
// —— 这类商品**补不出去**,正文里明确写「配件不足,无法补货」并列出短板配件,
|
||||
// 不给出建议补货量(否则业务会照着补不到的目标去采购)
|
||||
// ② 只提醒**新出现**的预警 —— 已经在「已推送」记录里的商品不重复推送,
|
||||
// 商品补货完成恢复到正常范围后会从记录里移除,下次再预警会重新提醒
|
||||
// ③ 收件人:单独配置(配置为空时回退环境变量 → 统一通知中心默认收件人)
|
||||
// ④ 时间:每天早上 09:00(Asia/Shanghai)
|
||||
//
|
||||
// 被未完成加工单豁免的「已排产」商品**不进入**推送清单(口径见 mkhBomService 文件头 ⑧)。
|
||||
|
||||
const crypto = require('crypto');
|
||||
const cron = require('node-cron');
|
||||
const {
|
||||
listReplenishAlertRows,
|
||||
} = require('../services/mkhBomService');
|
||||
const {
|
||||
getMkhReplenishAlertRuntimeConfig,
|
||||
loadMkhReplenishAlertState,
|
||||
resolveMkhReplenishAlertRecipient,
|
||||
saveMkhReplenishAlertState,
|
||||
} = require('../services/mkhReplenishAlertSettings');
|
||||
const {
|
||||
isYydNotificationCenterConfigured,
|
||||
sendYydNotification,
|
||||
} = require('../services/yydNotificationCenterClient');
|
||||
const {
|
||||
getYydNotificationCenterRuntimeConfig,
|
||||
} = require('../services/yydNotificationCenterSettings');
|
||||
|
||||
const DEFAULT_CRON = '0 9 * * *';
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
const OUT_OF_STOCK = '缺货';
|
||||
const WARNING = '预警';
|
||||
// 正文最多列出多少个商品(超出只报数量,避免超出通知中心 4800 字上限)
|
||||
const MAX_LISTED_ITEMS = 20;
|
||||
const STARTUP_DELAY_MS = 45 * 1000;
|
||||
|
||||
function formatQty(value) {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return '—';
|
||||
return Number.isInteger(num) ? String(num) : String(Number(num.toFixed(2)));
|
||||
}
|
||||
|
||||
function formatChinaTime(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/** 北京时间 YYYYMMDD —— 幂等键按「天 + 本批商品」去重 */
|
||||
function chinaDateKey(date = new Date()) {
|
||||
const text = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: TIMEZONE,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date instanceof Date ? date : new Date(date));
|
||||
return text.replace(/\D/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 对比「当前预警清单」与「已推送记录」:
|
||||
* fresh 本次新出现的(需要推送)
|
||||
* persisted 之前推过、目前仍在预警的(不重复推)
|
||||
* restored 之前推过、现在已经不预警的(补货完成,从记录里剔除)
|
||||
*/
|
||||
function diffAlertState(alerts = [], state = {}) {
|
||||
const notified = state?.notified && typeof state.notified === 'object' ? state.notified : {};
|
||||
const fresh = [];
|
||||
const persisted = [];
|
||||
alerts.forEach((row) => {
|
||||
const skuId = String(row?.skuId || '');
|
||||
if (!skuId) return;
|
||||
if (notified[skuId]) persisted.push(row);
|
||||
else fresh.push(row);
|
||||
});
|
||||
const alertIds = new Set(alerts.map((row) => String(row?.skuId || '')).filter(Boolean));
|
||||
const restored = Object.keys(notified).filter((skuId) => !alertIds.has(skuId));
|
||||
return { fresh, persisted, restored };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成下一版「已推送」记录:只保留当前仍在预警的商品。
|
||||
* touch=false 时不刷新 lastNotifiedAt(用于「没有新预警、本轮未真正推送」的场景)。
|
||||
*/
|
||||
function buildNotifiedSnapshot(alerts = [], state = {}, nowIso, options = {}) {
|
||||
const touch = options.touch !== false;
|
||||
const prev = state?.notified && typeof state.notified === 'object' ? state.notified : {};
|
||||
const next = {};
|
||||
alerts.forEach((row) => {
|
||||
const skuId = String(row?.skuId || '');
|
||||
if (!skuId) return;
|
||||
const old = prev[skuId];
|
||||
const firstNotifiedAt = old?.firstNotifiedAt || nowIso;
|
||||
next[skuId] = {
|
||||
level: row.warningLevel || null,
|
||||
firstNotifiedAt,
|
||||
lastNotifiedAt: touch ? nowIso : (old?.lastNotifiedAt || firstNotifiedAt),
|
||||
dailySale: Number(row.dailySale) || 0,
|
||||
stock: Number(row.stock) || 0,
|
||||
};
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 本批商品的指纹 —— 同一天内同一批商品只推一次(服务重启后重复触发也能被通知中心去重) */
|
||||
function buildAlertSignature(alerts = []) {
|
||||
const parts = alerts
|
||||
.map((row) => `${String(row?.skuId || '')}:${row?.warningLevel || ''}`)
|
||||
.sort();
|
||||
return crypto.createHash('sha1').update(parts.join('|'), 'utf8').digest('hex').slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* 短板配件文案(见 mkhBomService 文件头 ⑪):只列「可支配数 ≤ 0」的配件,
|
||||
* 即真正卡住组装的那些,最多 2 个,避免正文过长。
|
||||
*/
|
||||
function formatShortageNote(row) {
|
||||
const bottlenecks = Array.isArray(row?.bottleneckComponents) ? row.bottleneckComponents : [];
|
||||
const detail = bottlenecks
|
||||
.filter((item) => Number(item?.available) <= 0)
|
||||
.slice(0, 2)
|
||||
.map((item) => {
|
||||
const occupied = Number(item.occupied) || 0;
|
||||
return `${item.code}(库存 ${formatQty(item.stock)}`
|
||||
+ `${occupied > 0 ? ` − 加工单占用 ${formatQty(occupied)}` : ''})`;
|
||||
})
|
||||
.join('、');
|
||||
return detail ? `,短板配件:${detail}` : '';
|
||||
}
|
||||
|
||||
function describeAlert(row) {
|
||||
// 配件不足(见 mkhBomService 文件头 ⑪):可组装数 = 0 → 补不出来,
|
||||
// 建议补货量不再给数字(显示「—」),与页面口径保持一致。
|
||||
const replenishText = row.componentShortage ? '建议补货 —' : `建议补货 ${formatQty(row.replenishQty)}`;
|
||||
// 订单驱动(近7日日销=0 但有未发货订单,见 mkhBomService 文件头 ⑩):
|
||||
// 此时日销必然是 0,直接显示会让人误以为「没销量却要补货」,改展示待发货订单量
|
||||
const metric = row.orderDriven
|
||||
? `待发货订单 ${formatQty(row.pendingOrderQty)} 件 | 现有库存 ${formatQty(row.stock)} | ${replenishText}(近7日暂无日销,按订单补)`
|
||||
: `日销 ${formatQty(row.dailySale)} | 现有库存 ${formatQty(row.stock)} | ${replenishText}`;
|
||||
const lines = [
|
||||
`**${row.warningLevel === OUT_OF_STOCK ? '🔴 缺货' : '🟠 预警'}** — \`${row.skuId}\``,
|
||||
String(row.productName || '').trim() || '(无名称)',
|
||||
metric,
|
||||
];
|
||||
if (row.componentShortage) {
|
||||
lines.splice(2, 0, `⚠️ **配件不足,无法补货**(现有配件可组装 ${formatQty(row.assemblableQty)} 台${formatShortageNote(row)})`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildMkhReplenishNotification({
|
||||
alerts = [],
|
||||
allAlerts = alerts,
|
||||
snapshot = {},
|
||||
recipientAlias,
|
||||
dateKey,
|
||||
}) {
|
||||
const listed = alerts.slice(0, MAX_LISTED_ITEMS);
|
||||
const outOfStockCount = alerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length;
|
||||
|
||||
const totalCount = allAlerts.length || alerts.length;
|
||||
const totalOutOfStock = Number.isFinite(Number(snapshot.outOfStockCount))
|
||||
? Number(snapshot.outOfStockCount)
|
||||
: allAlerts.filter((row) => row.warningLevel === OUT_OF_STOCK).length;
|
||||
const totalWarningOnly = Number.isFinite(Number(snapshot.warningOnlyCount))
|
||||
? Number(snapshot.warningOnlyCount)
|
||||
: Math.max(0, totalCount - totalOutOfStock);
|
||||
|
||||
const titleParts = [`MKH 成品补货预警 · 新增 ${alerts.length} 个`];
|
||||
if (outOfStockCount > 0) titleParts.push(`含 ${outOfStockCount} 个已缺货`);
|
||||
|
||||
const blocks = [
|
||||
`**仓库:** 简尚-崔桥工厂品牌仓(MKH 模块化成品)`,
|
||||
`**监控日:** ${snapshot.monitorDate || '—'} | 日销口径:近 ${snapshot.saleWindowDays || 7} 日 | 补货阈值:可用天数低于 ${snapshot.replenishDaysThreshold || 3} 天`,
|
||||
`**本次新增:** ${alerts.length} 个${listed.length < alerts.length ? `(仅列出前 ${listed.length} 个)` : ''}`,
|
||||
];
|
||||
|
||||
listed.forEach((row, index) => {
|
||||
blocks.push(`**${index + 1}.** ${describeAlert(row)}`);
|
||||
});
|
||||
|
||||
blocks.push(
|
||||
[
|
||||
`---`,
|
||||
`当前共 **${totalCount}** 个 MKH 商品需要补货(缺货 ${totalOutOfStock} | ${snapshot.replenishDaysThreshold || 3}天内预警 ${totalWarningOnly})。`,
|
||||
`建议补货合计 ${formatQty(snapshot.replenishQtyTotal)} 件。`,
|
||||
Number(snapshot.orderDrivenCount) > 0
|
||||
? `其中 **${snapshot.orderDrivenCount}** 个近7日无日销、因有未发货订单(合计 ${formatQty(snapshot.pendingOrderQtyTotal)} 件)而列入预警。`
|
||||
: '',
|
||||
Number(snapshot.componentShortageCount) > 0
|
||||
? `其中 **${snapshot.componentShortageCount}** 个因**配件库存不足**(可组装数 = 0)暂时补不出来,建议补货量显示「—」,需先采购配件。`
|
||||
: '',
|
||||
snapshot.workOrderDegraded === true
|
||||
? `> ⚠️ 加工单数据源暂不可用,在途量按 0 处理,预警数量可能偏多。`
|
||||
: '',
|
||||
snapshot.pendingOrderDegraded === true
|
||||
? `> ⚠️ 待发货订单数据源暂不可用,待发货量按 0 处理,可能遗漏「无日销但有订单」的商品。`
|
||||
: '',
|
||||
snapshot.componentStockDegraded === true
|
||||
? `> ⚠️ 配件库存数据源暂不可用,本次**未做**「配件不足」判定,可能存在补不出来的商品。`
|
||||
: '',
|
||||
].filter(Boolean).join('\n\n')
|
||||
);
|
||||
|
||||
const actionUrl = String(
|
||||
process.env.MKH_REPLENISH_ALERT_ACTION_URL
|
||||
|| 'https://pddg.jiansuntuzhi.cn/planning/operation/mkh-bom-data?scope=warning'
|
||||
).trim();
|
||||
|
||||
return {
|
||||
idempotencyKey: `mkh_replenish_alert:${recipientAlias}:${dateKey}:${buildAlertSignature(alerts)}:v1`,
|
||||
recipientAlias,
|
||||
title: titleParts.join(' · ').slice(0, 100),
|
||||
content: blocks.join('\n\n').slice(0, 4800),
|
||||
actionTitle: '查看补货清单',
|
||||
actionUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行一次检查。可选参数:
|
||||
* force 忽略「只推新增」,把当前全部预警商品推一次(手动触发 / 测试用)
|
||||
* dryRun 只计算不发送,用于预览将推送的内容
|
||||
*/
|
||||
async function checkMkhReplenishAlerts(options = {}) {
|
||||
const now = options.now === undefined ? new Date() : new Date(options.now);
|
||||
const nowIso = now.toISOString();
|
||||
const dryRun = options.dryRun === true;
|
||||
|
||||
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||||
const snapshot = await listReplenishAlertRows({ scope: config.scope });
|
||||
const alerts = snapshot.rows || [];
|
||||
const state = await loadMkhReplenishAlertState();
|
||||
const { fresh, persisted, restored } = diffAlertState(alerts, state);
|
||||
|
||||
const summary = {
|
||||
scope: snapshot.scope,
|
||||
totalCount: snapshot.totalCount,
|
||||
outOfStockCount: snapshot.outOfStockCount,
|
||||
warningOnlyCount: snapshot.warningOnlyCount,
|
||||
replenishQtyTotal: snapshot.replenishQtyTotal,
|
||||
// 订单驱动(近7日无日销但有未发货订单)的商品数 / 待发货件数,口径见 mkhBomService 文件头 ⑩
|
||||
orderDrivenCount: snapshot.orderDrivenCount,
|
||||
pendingOrderQtyTotal: snapshot.pendingOrderQtyTotal,
|
||||
// 配件不足(可组装数 = 0)的商品数,口径见 mkhBomService 文件头 ⑪
|
||||
componentShortageCount: snapshot.componentShortageCount,
|
||||
freshCount: fresh.length,
|
||||
persistedCount: persisted.length,
|
||||
restoredCount: restored.length,
|
||||
monitorDate: snapshot.monitorDate,
|
||||
workOrderDegraded: snapshot.workOrderDegraded,
|
||||
pendingOrderDegraded: snapshot.pendingOrderDegraded,
|
||||
componentStockDegraded: snapshot.componentStockDegraded,
|
||||
};
|
||||
|
||||
if (!config.enabled) return { sent: false, reason: 'disabled', summary };
|
||||
|
||||
// 预警清空(都补完了)→ 清空已推送记录,下次再预警重新提醒
|
||||
if (!alerts.length) {
|
||||
if (!dryRun) await saveMkhReplenishAlertState({ notified: {}, lastRunAt: nowIso });
|
||||
return { sent: false, reason: 'no_alert', summary };
|
||||
}
|
||||
|
||||
const targets = options.force === true ? alerts : fresh;
|
||||
|
||||
if (!targets.length) {
|
||||
if (!dryRun) {
|
||||
await saveMkhReplenishAlertState({
|
||||
notified: buildNotifiedSnapshot(alerts, state, nowIso, { touch: false }),
|
||||
lastRunAt: nowIso,
|
||||
});
|
||||
}
|
||||
return { sent: false, reason: 'no_new_alert', summary };
|
||||
}
|
||||
|
||||
const recipient = await resolveMkhReplenishAlertRecipient({ config });
|
||||
if (!recipient.alias) return { sent: false, reason: 'recipient_not_configured', summary };
|
||||
|
||||
const payload = buildMkhReplenishNotification({
|
||||
alerts: targets,
|
||||
allAlerts: alerts,
|
||||
snapshot,
|
||||
recipientAlias: recipient.alias,
|
||||
dateKey: chinaDateKey(now),
|
||||
});
|
||||
|
||||
// 预览不受「通知中心是否配置」影响,方便先核对口径
|
||||
if (dryRun) {
|
||||
return {
|
||||
sent: false,
|
||||
reason: 'dry_run',
|
||||
summary,
|
||||
recipient: recipient.alias,
|
||||
recipientSource: recipient.source,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
let centerConfig = null;
|
||||
try {
|
||||
centerConfig = await getYydNotificationCenterRuntimeConfig();
|
||||
} catch (_error) {
|
||||
centerConfig = null;
|
||||
}
|
||||
if (!isYydNotificationCenterConfigured(centerConfig)) {
|
||||
return {
|
||||
sent: false,
|
||||
reason: 'notification_center_not_configured',
|
||||
summary,
|
||||
recipient: recipient.alias,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
const response = await sendYydNotification(payload);
|
||||
await saveMkhReplenishAlertState({
|
||||
notified: buildNotifiedSnapshot(alerts, state, nowIso),
|
||||
lastRunAt: nowIso,
|
||||
});
|
||||
|
||||
return {
|
||||
sent: true,
|
||||
recipient: recipient.alias,
|
||||
recipientSource: recipient.source,
|
||||
newCount: targets.length,
|
||||
deliveryId: response?.id || response?.data?.id || null,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMkhReplenishAlertJob(source = 'scheduled', options = {}) {
|
||||
try {
|
||||
const result = await checkMkhReplenishAlerts(options);
|
||||
if (result.sent) {
|
||||
console.log(`[MkhReplenishAlert] ${source} 已推送 ${result.newCount} 个新增预警 → ${result.recipient}`);
|
||||
} else {
|
||||
console.log(`[MkhReplenishAlert] ${source} 跳过推送: ${result.reason}`);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[MkhReplenishAlert] ${source} 执行失败:`, error.message || error);
|
||||
return { sent: false, reason: 'error', error: error.message || String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
function startMkhReplenishAlertScheduler() {
|
||||
const disabled = String(process.env.DISABLE_MKH_REPLENISH_ALERT || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] MKH 补货预警推送已禁用');
|
||||
return;
|
||||
}
|
||||
const expression = String(process.env.MKH_REPLENISH_ALERT_CRON || DEFAULT_CRON).trim();
|
||||
cron.schedule(expression, () => {
|
||||
runMkhReplenishAlertJob('daily_schedule').catch(() => {});
|
||||
}, { timezone: TIMEZONE });
|
||||
|
||||
const startupTimer = setTimeout(() => {
|
||||
runMkhReplenishAlertJob('startup').catch(() => {});
|
||||
}, STARTUP_DELAY_MS);
|
||||
if (typeof startupTimer.unref === 'function') startupTimer.unref();
|
||||
|
||||
console.log(`[Scheduler] MKH 补货预警推送已注册: ${expression} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_CRON,
|
||||
MAX_LISTED_ITEMS,
|
||||
TIMEZONE,
|
||||
buildAlertSignature,
|
||||
buildMkhReplenishNotification,
|
||||
buildNotifiedSnapshot,
|
||||
checkMkhReplenishAlerts,
|
||||
chinaDateKey,
|
||||
diffAlertState,
|
||||
formatQty,
|
||||
runMkhReplenishAlertJob,
|
||||
startMkhReplenishAlertScheduler,
|
||||
};
|
||||
@ -1,31 +0,0 @@
|
||||
const cron = require('node-cron');
|
||||
const { refreshOpsBIncentiveSnapshots } = require('../services/opsBIncentiveBoardService');
|
||||
|
||||
const JOB_CRON = String(process.env.OPS_B_INCENTIVE_SNAPSHOT_CRON || '0 * * * *').trim();
|
||||
const TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
async function runOpsBIncentiveSnapshotJob(source = 'scheduled') {
|
||||
try {
|
||||
const result = await refreshOpsBIncentiveSnapshots(source);
|
||||
console.log('[OpsBIncentiveSnapshot] refresh completed:', JSON.stringify(result));
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[OpsBIncentiveSnapshot] refresh failed:', error.message || error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function startOpsBIncentiveSnapshotSchedulers() {
|
||||
// 默认开启;仅当显式设置 DISABLE_OPS_B_INCENTIVE_SNAPSHOT=true 时关闭。
|
||||
const disabled = String(process.env.DISABLE_OPS_B_INCENTIVE_SNAPSHOT || '').trim().toLowerCase() === 'true';
|
||||
if (disabled) {
|
||||
console.log('[Scheduler] 运营中心B激励看板快照任务已被禁用');
|
||||
return;
|
||||
}
|
||||
cron.schedule(JOB_CRON, () => {
|
||||
runOpsBIncentiveSnapshotJob('daily_schedule').catch(() => {});
|
||||
}, { timezone: TIMEZONE });
|
||||
console.log(`[Scheduler] 运营中心B激励看板快照任务已注册: ${JOB_CRON} (${TIMEZONE})`);
|
||||
}
|
||||
|
||||
module.exports = { startOpsBIncentiveSnapshotSchedulers, runOpsBIncentiveSnapshotJob };
|
||||
@ -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,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,
|
||||
};
|
||||
@ -77,8 +77,6 @@ function buildDesktopCapabilities(user) {
|
||||
shopManagement: canPlan,
|
||||
execution: canPlan,
|
||||
logs: true,
|
||||
marketingEvents: isAdmin || allowed.has('marketing_events'),
|
||||
promotion: isAdmin || allowed.has('promotion_module'),
|
||||
productTest: isAdmin || allowed.has('new_product_test'),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
@ -7,16 +7,6 @@ const { ensureDesktopPresenceSchema } = require('./desktopPresenceService');
|
||||
const { ensureDesktopExecutionTaskSchema } = require('./desktopExecutionTaskService');
|
||||
const { ensureThirdPartySsoSchema } = require('./thirdPartySsoService');
|
||||
const { ensureThirdPartySsoDirectSchema } = require('./thirdPartySsoDirectService');
|
||||
const { ensureProductTagConfigSchema } = require('./productTagConfigService');
|
||||
const {
|
||||
ensureProductMonitoringSchema,
|
||||
warmProductMonitoringDataset,
|
||||
} = require('./productMonitoringService');
|
||||
const { ensureReturnMonitorSchema } = require('./returnMonitorSchema');
|
||||
const {
|
||||
ensureReturnStockWarningSchema,
|
||||
} = require('./returnStockWarningSchema');
|
||||
const { warmReturnStockWarningCache } = require('./returnStockWarningService');
|
||||
|
||||
const USER_COLUMNS = [
|
||||
['auth_source', "VARCHAR(20) NOT NULL DEFAULT 'local' COMMENT 'Recent auth source: local/dingtalk/google'"],
|
||||
@ -123,18 +113,6 @@ async function ensureTable(tableName, createSql) {
|
||||
}
|
||||
}
|
||||
|
||||
async function tableExists(tableName) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
async function indexExists(tableName, indexName) {
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1
|
||||
@ -260,67 +238,10 @@ async function seedRedisConfigFromEnv() {
|
||||
}
|
||||
|
||||
async function ensureLocalSchema() {
|
||||
await ensureTable(
|
||||
'ops_b_incentive_snapshots',
|
||||
`CREATE TABLE IF NOT EXISTS ops_b_incentive_snapshots (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
snapshot_key VARCHAR(80) NOT NULL,
|
||||
scope_start DATE NOT NULL,
|
||||
scope_end DATE NOT NULL,
|
||||
metric_end DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'completed',
|
||||
finalized TINYINT(1) NOT NULL DEFAULT 0,
|
||||
rows_json MEDIUMTEXT NOT NULL,
|
||||
summary_json TEXT NULL,
|
||||
generated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
completed_at DATETIME NULL,
|
||||
error_message TEXT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ops_b_snapshot_key (snapshot_key),
|
||||
KEY idx_ops_b_snapshot_period (scope_start, scope_end, metric_end, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
||||
);
|
||||
await ensureTable(
|
||||
'ops_b_incentive_periods',
|
||||
`CREATE TABLE IF NOT EXISTS ops_b_incentive_periods (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scope_start DATE NOT NULL COMMENT '上架日期区间起',
|
||||
scope_end DATE NOT NULL COMMENT '上架日期区间止',
|
||||
pull_date DATE NOT NULL COMMENT '拉取(冻结)日期',
|
||||
fee_cap DECIMAL(5,4) NOT NULL COMMENT '费比达标上限',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_ops_b_period_scope (scope_start, scope_end)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`
|
||||
);
|
||||
await ensureTable(
|
||||
'product_analysis_user_prefs',
|
||||
`CREATE TABLE IF NOT EXISTS product_analysis_user_prefs (
|
||||
user_id BIGINT PRIMARY KEY COMMENT '用户ID',
|
||||
prefs_json VARCHAR(2000) NOT NULL COMMENT '个性化配置 JSON',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品分析用户个性化配置'`
|
||||
);
|
||||
if (await tableExists('pricing_calculator_user_prefs')) {
|
||||
await database.local.query(
|
||||
`INSERT INTO product_analysis_user_prefs (user_id, prefs_json, created_at, updated_at)
|
||||
SELECT user_id, prefs_json, created_at, updated_at
|
||||
FROM pricing_calculator_user_prefs
|
||||
WHERE JSON_VALID(prefs_json)
|
||||
AND JSON_EXTRACT(prefs_json, '$.productAnalysisSignalThresholds') IS NOT NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
prefs_json = VALUES(prefs_json),
|
||||
updated_at = VALUES(updated_at)`
|
||||
);
|
||||
}
|
||||
await ensureDesktopPresenceSchema();
|
||||
await ensureDesktopExecutionTaskSchema();
|
||||
await ensureThirdPartySsoSchema();
|
||||
await ensureThirdPartySsoDirectSchema();
|
||||
await ensureProductTagConfigSchema();
|
||||
|
||||
await ensureTable(
|
||||
'auth_configs',
|
||||
@ -956,15 +877,7 @@ async function ensureLocalSchema() {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='天猫控价快照读库'`
|
||||
);
|
||||
|
||||
await ensureProductMonitoringSchema();
|
||||
await ensureReturnMonitorSchema();
|
||||
await ensureReturnStockWarningSchema();
|
||||
// 退货库存预警数据集(约 1,800 行,首次回源 3~10s)后台预热,不阻塞启动
|
||||
void warmReturnStockWarningCache();
|
||||
|
||||
await ensureSidebarRolePermissions();
|
||||
// 商品监控数据集(5.6 万行,构建约 30s)后台预热,不阻塞启动
|
||||
void warmProductMonitoringDataset();
|
||||
await ensureDepartmentPagePermissions();
|
||||
await cleanupRetiredFeatureSettings(database.local);
|
||||
await repairDuplicateExternalDepartments();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,227 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// MKH 成品补货预警推送 —— 配置 + 推送状态(本地库 system_config)
|
||||
//
|
||||
// ── 配置键 mkh_replenish_alert_config ─────────────────────────────
|
||||
// { enabled, recipientAlias, scope }
|
||||
// · recipientAlias 为空时回退顺序:环境变量 → 统一通知中心默认收件人
|
||||
// · scope: 'warning'(缺货 + 3 天内预警,默认)| 'outOfStock'(仅缺货)
|
||||
//
|
||||
// ── 状态键 mkh_replenish_alert_state ──────────────────────────────
|
||||
// { version, notified: { [skuId]: { level, firstNotifiedAt, lastNotifiedAt, ... } }, lastRunAt }
|
||||
// · 用于实现「只提醒新出现的预警」:已在 notified 里的商品不再重复推送;
|
||||
// 商品恢复到正常范围后从 notified 移除,下次再预警会重新提醒。
|
||||
|
||||
const database = require('../config/database');
|
||||
const {
|
||||
getYydNotificationCenterRuntimeConfig,
|
||||
toAdminConfig: toNotificationCenterAdminConfig,
|
||||
} = require('./yydNotificationCenterSettings');
|
||||
|
||||
const CONFIG_KEY = 'mkh_replenish_alert_config';
|
||||
const STATE_KEY = 'mkh_replenish_alert_state';
|
||||
const CONFIG_CACHE_TTL_MS = 30 * 1000;
|
||||
|
||||
const SCOPES = Object.freeze({ WARNING: 'warning', OUT_OF_STOCK: 'outOfStock' });
|
||||
|
||||
const SCOPE_OPTIONS = Object.freeze([
|
||||
{ value: SCOPES.WARNING, label: '缺货 + 3天内预警' },
|
||||
{ value: SCOPES.OUT_OF_STOCK, label: '仅缺货' },
|
||||
]);
|
||||
|
||||
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 normalizeScope(value) {
|
||||
const text = normalizeText(value, 40).toLowerCase();
|
||||
if (!text) return SCOPES.WARNING;
|
||||
if (text === 'outofstock' || text === 'out_of_stock' || text === '缺货') return SCOPES.OUT_OF_STOCK;
|
||||
if (text === 'warning' || text === 'warn' || text === '预警' || text === 'all') return SCOPES.WARNING;
|
||||
return SCOPES.WARNING;
|
||||
}
|
||||
|
||||
function normalizeFlag(value, fallback = true) {
|
||||
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;
|
||||
}
|
||||
|
||||
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 getMkhReplenishAlertRuntimeConfig({ 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, true),
|
||||
recipientAlias: normalizeText(stored.recipientAlias || stored.recipient_alias, 50),
|
||||
scope: normalizeScope(stored.scope),
|
||||
recipientAliasFallback: normalizeText(process.env.MKH_REPLENISH_ALERT_RECIPIENT_ALIAS, 50),
|
||||
};
|
||||
cachedAt = Date.now();
|
||||
return { ...cachedConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析实际收件人 alias:配置填写值 → 环境变量 → 统一通知中心默认收件人。
|
||||
*/
|
||||
async function resolveMkhReplenishAlertRecipient(options = {}) {
|
||||
const config = options.config || await getMkhReplenishAlertRuntimeConfig();
|
||||
if (config.recipientAlias) return { alias: config.recipientAlias, source: 'config' };
|
||||
if (config.recipientAliasFallback) return { alias: config.recipientAliasFallback, source: 'environment' };
|
||||
let center = {};
|
||||
try {
|
||||
center = await getYydNotificationCenterRuntimeConfig({ forceRefresh: options.forceRefresh === true });
|
||||
} catch (_error) {
|
||||
center = {};
|
||||
}
|
||||
const alias = normalizeText(center?.recipientAlias, 50);
|
||||
return { alias, source: alias ? 'notification_center' : 'none' };
|
||||
}
|
||||
|
||||
async function getMkhReplenishAlertAdminConfig() {
|
||||
const config = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||||
const recipient = await resolveMkhReplenishAlertRecipient({ config });
|
||||
let center = {};
|
||||
try {
|
||||
center = await getYydNotificationCenterRuntimeConfig();
|
||||
} catch (_error) {
|
||||
center = {};
|
||||
}
|
||||
const centerAdmin = toNotificationCenterAdminConfig(center);
|
||||
const state = await loadMkhReplenishAlertState();
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
scope: config.scope,
|
||||
recipientAlias: config.recipientAlias,
|
||||
effectiveRecipientAlias: recipient.alias || '',
|
||||
recipientSource: recipient.source,
|
||||
notificationCenterConfigured: centerAdmin.configured,
|
||||
notificationCenterBaseUrl: centerAdmin.baseUrl,
|
||||
notificationCenter: {
|
||||
configured: centerAdmin.configured,
|
||||
baseUrl: centerAdmin.baseUrl,
|
||||
projectCode: centerAdmin.projectCode,
|
||||
projectSecretConfigured: centerAdmin.projectSecretConfigured,
|
||||
projectSecretPreview: centerAdmin.projectSecretPreview,
|
||||
recipientAlias: centerAdmin.recipientAlias,
|
||||
},
|
||||
notifiedCount: Object.keys(state.notified || {}).length,
|
||||
lastRunAt: state.lastRunAt || null,
|
||||
scopeOptions: SCOPE_OPTIONS.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveMkhReplenishAlertConfig(input = {}) {
|
||||
const current = await getMkhReplenishAlertRuntimeConfig({ forceRefresh: true });
|
||||
const rawAlias = input.recipientAlias ?? input.recipient_alias;
|
||||
const next = {
|
||||
enabled: normalizeFlag(input.enabled, current.enabled),
|
||||
recipientAlias: normalizeText(rawAlias === undefined ? current.recipientAlias : rawAlias, 50),
|
||||
scope: normalizeScope(input.scope || current.scope),
|
||||
};
|
||||
await upsertSystemConfigValue(CONFIG_KEY, next);
|
||||
cachedConfig = { ...next, recipientAliasFallback: current.recipientAliasFallback };
|
||||
cachedAt = Date.now();
|
||||
return getMkhReplenishAlertAdminConfig();
|
||||
}
|
||||
|
||||
// ── 推送状态 ────────────────────────────────────────────────────
|
||||
async function loadMkhReplenishAlertState() {
|
||||
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
|
||||
: {};
|
||||
return {
|
||||
version: 1,
|
||||
notified,
|
||||
lastRunAt: normalizeText(parsed.lastRunAt, 40) || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function saveMkhReplenishAlertState(state = {}) {
|
||||
const value = {
|
||||
version: 1,
|
||||
notified: state.notified && typeof state.notified === 'object' ? state.notified : {},
|
||||
lastRunAt: normalizeText(state.lastRunAt, 40) || null,
|
||||
};
|
||||
await upsertSystemConfigValue(STATE_KEY, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 清空「已推送」记录,让下一次检查重新按新增口径判断(便于测试)。 */
|
||||
async function resetMkhReplenishAlertState() {
|
||||
return saveMkhReplenishAlertState({ notified: {}, lastRunAt: null });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CONFIG_KEY,
|
||||
STATE_KEY,
|
||||
SCOPES,
|
||||
SCOPE_OPTIONS,
|
||||
getMkhReplenishAlertAdminConfig,
|
||||
getMkhReplenishAlertRuntimeConfig,
|
||||
loadMkhReplenishAlertState,
|
||||
normalizeFlag,
|
||||
normalizeScope,
|
||||
resetMkhReplenishAlertState,
|
||||
resolveMkhReplenishAlertRecipient,
|
||||
saveMkhReplenishAlertConfig,
|
||||
saveMkhReplenishAlertState,
|
||||
};
|
||||
@ -1,635 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 模块化库存监控 —— 配件库存监控
|
||||
//
|
||||
// 需求来源:《模块化库存监控.xlsx》
|
||||
// 一级功能:模块化库存监控 二级功能:配件库存监控
|
||||
// 表头:图片 | 预警 | 商品编码 | 名称 | 当日入库库存 | 当日出库库存 | 实时库存 |
|
||||
// 仓位 | 近15天日均发货量 | 库存可用天数 | 所在仓库
|
||||
// 数据范围:仅取「简尚-崔桥工厂半成品包装项目仓」仓库下的商品编码
|
||||
//
|
||||
// 数据来源(业务库 JSReportReplica / schema=rpt,2026-09-12 实测确认):
|
||||
// 主表 rpt.jst_sku —— SKU 档案;other_3 = 所属仓库
|
||||
// 目标仓库共 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 库存可用天数
|
||||
// safetyDays 安全天数(默认 15)
|
||||
// suggestStock 建议备货量
|
||||
// jsStock 监控表口径库存(作为 inventoryInfo 的兜底)
|
||||
// nearSecondSale 近一日发货量 → 当日出库
|
||||
//
|
||||
// ⚠️ 关联键坑(2026-09-12 修正):
|
||||
// pm_stockMonitor 里同时有 sku 与 sku_id 两列:
|
||||
// sku = 短款号,如 DNZ040967 —— 用它关联只能命中 34 / 794
|
||||
// sku_id = 完整 SKU 编码 DNZ040967N1440V01 —— 必须用它关联,命中 794 / 794
|
||||
// 当日入库:业务库无出入库流水表,用采购到货 rpt.jst_purchaseItem 按「监控日」汇总 inQty
|
||||
// (全库 207 张视图均无库存流水表;dws_sn_sales_full_info_di 是成品销售明细,只命中 97 / 794)
|
||||
//
|
||||
// ✅ 仓位 location(2026-09-18 改源,旧的「无数据源」结论作废):
|
||||
// - 旧源 rpt.jst_sku.bin / other_bin 在目标仓 794 行**恒为空**,所以页面长期显示「未维护」
|
||||
// - 新源 rpt.jst_packInventoryInfo.bin(真货位):该表 2026-09-18 10:48 被上游刷新后
|
||||
// 目标仓 794 个 SKU **命中 727 个**(如 ZT-8 / BCP01-01-1)
|
||||
// - 详见 buildLocationSql() 注释
|
||||
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const productDb = require('../config/productInformationDatabase');
|
||||
|
||||
const WAREHOUSE_NAME = '简尚-崔桥工厂半成品包装项目仓';
|
||||
// rpt.jst_warehouseInfo 中「简尚-崔桥工厂半成品包装项目仓」对应的 wms_co_id
|
||||
const WAREHOUSE_WMS_CO_ID = 15718196;
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
const DEFAULT_SAFETY_DAYS = 15;
|
||||
// 预警阈值:库存可用天数 <= 7 天即预警(缺货 = 实时库存 <= 0)
|
||||
// ⚠️ 2026-09-17 业务确认:未配置MKH 的配件不进任何预警(见 buildWarningOrderSql)
|
||||
const LOW_STOCK_DAYS_THRESHOLD = 7;
|
||||
|
||||
const WARNING_LEVEL = Object.freeze({
|
||||
OUT_OF_STOCK: '缺货',
|
||||
WARNING: '预警',
|
||||
NORMAL: '正常',
|
||||
});
|
||||
|
||||
// 模块化配置状态(2026-09-17 业务确认口径,2026-09-17 晚修正):
|
||||
// 「已配置MKH」= 该 SKU 作为「配件商品编码」出现在聚水潭 SKU 级 BOM 维护中,
|
||||
// 被某个 MKH 模块化成品引用:
|
||||
// 业务库 rpt.jst_skuBomItem 中 map_outer_sku_id = 该 SKU
|
||||
// 且父项 sku_id 以 'MKH' 开头(即 MKH 成品档案,如 MKHSJZ0033260600001)。
|
||||
// 「未配置MKH」= 未出现在任何 MKH 成品的 BOM 配件清单中。
|
||||
// 实测目标仓 794 个 SKU 中 **699** 个命中(按 map_outer_sku_id 匹配、父项 MKH 过滤)。
|
||||
// ⚠️ 历史口径均已废弃:
|
||||
// · 早期「产品资料系统数据库商品档案」口径(661/794)—— 覆盖面过宽、与 BOM 无关;
|
||||
// · 上一版「jst_comboProductItem 组合装引用」口径(29/794)—— 仅命中 MKH- 组合装,
|
||||
// 漏掉了真正以 MKH 成品为父项的 BOM 配件,数量明显偏低。
|
||||
// ✅ 当前采用 rpt.jst_skuBomItem(聚水潭 SKU BOM 明细,含配件商品编码 map_outer_sku_id)。
|
||||
const MKH_STATUS = Object.freeze({
|
||||
CONFIGURED: '已配置MKH',
|
||||
UNCONFIGURED: '未配置MKH',
|
||||
});
|
||||
|
||||
const SORTABLE_FIELDS = Object.freeze({
|
||||
skuId: 's.sku_id',
|
||||
productName: 's.name',
|
||||
category: 's.category',
|
||||
realtimeStock: 'realtimeStock',
|
||||
avgShip15d: 'm.fifteenAvgSale',
|
||||
stockDays: 'stockDays',
|
||||
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 维护
|
||||
* rpt.jst_skuBomItem 中,且父项 sku_id 以 'MKH' 开头(即被某个 MKH 模块化成品引用)。
|
||||
*/
|
||||
const buildConfiguredExistsSql = () => (
|
||||
`EXISTS (
|
||||
SELECT 1 FROM rpt.jst_skuBomItem x
|
||||
WHERE x.map_outer_sku_id = s.sku_id
|
||||
AND x.sku_id LIKE N'MKH%'
|
||||
)`
|
||||
);
|
||||
|
||||
/**
|
||||
* 统一的「预警等级(数值)」表达式(2026-09-17 业务确认:未配置MKH 不进任何预警)。
|
||||
* 0 = 缺货 / 1 = 预警 / 2 = 正常
|
||||
* 未配置MKH(cfg.configured = 0)一律判为「正常」,不报缺货也不报预警。
|
||||
* 注意 cfg / inv / m 三个别名都来自 buildBaseSql 的 OUTER APPLY,WHERE / SELECT 中均可引用。
|
||||
* 列表映射、汇总计数、筛选、排序四处共用此表达式,避免口径不一致。
|
||||
*/
|
||||
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
|
||||
ELSE 2
|
||||
END`
|
||||
);
|
||||
|
||||
/**
|
||||
* 统一的「仓位」表达式(2026-09-18 改源)。
|
||||
* 旧源 `jst_sku.bin / other_bin` 在目标仓 794 行**恒为空**(页面因此长期显示「未维护」);
|
||||
* 真货位在 `rpt.jst_packInventoryInfo.bin` —— 该表 2026-09-18 10:48 被上游刷新后,
|
||||
* 目标仓 794 个 SKU **命中 727 个**(09-19 实测;该表小时级增量刷新,命中数会变)。
|
||||
* ⚠️ 只取 bin,**不 join 仓名**,所以不受该表 wh_id / wms_co_id 关联列问题影响(见
|
||||
* mkhBomService 文件头 ⑤;那个坑只影响 MKH-BOM 页配件仓位列的仓名文本)。
|
||||
* · 只取 `qty > 0` 的箱,同 SKU 多箱时取库存最大的那个(列表页只展示主货位)
|
||||
* · 取不到才回退商品档案的 bin / other_bin(目前仍全空,保留兜底不影响结果)
|
||||
* 与 MKH-BOM 页 `mkhBomService.loadComponentLocations()` 同源,区别只在本页取单个主货位。
|
||||
* ⚠️ 列表映射、仓位筛选两处共用此表达式,改口径时只改这里。
|
||||
*/
|
||||
const buildLocationSql = () => (
|
||||
`COALESCE(
|
||||
ploc.bin,
|
||||
NULLIF(LTRIM(RTRIM(s.bin)), N''),
|
||||
NULLIF(LTRIM(RTRIM(s.other_bin)), N'')
|
||||
)`
|
||||
);
|
||||
|
||||
const buildBaseSql = () => `
|
||||
FROM dbo.jst_sku s
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 i.qty
|
||||
FROM dbo.jst_inventoryInfo i
|
||||
WHERE i.sku_id = s.sku_id AND i.wms_co_id = @wmsCoId
|
||||
ORDER BY i.updateTime DESC
|
||||
) inv
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 m2.fifteenAvgSale, m2.jsUseDays, m2.safetyDays, m2.suggestStock,
|
||||
m2.date, m2.jsStock, m2.nearSecondSale
|
||||
FROM dbo.pm_stockMonitor m2
|
||||
WHERE m2.sku_id = s.sku_id AND m2.warehouse = @warehouseName
|
||||
ORDER BY m2.date DESC
|
||||
) m
|
||||
OUTER APPLY (
|
||||
SELECT CAST(SUM(ISNULL(p.inQty, 0)) AS DECIMAL(18, 2)) AS inQty
|
||||
FROM dbo.jst_purchaseItem p
|
||||
WHERE p.sku_id = s.sku_id
|
||||
AND p.delivery_date >= @monitorDate
|
||||
AND p.delivery_date < DATEADD(DAY, 1, @monitorDate)
|
||||
) pin
|
||||
-- 采购在途:该 SKU 全部采购单中「已下单 − 已入库」的未到货部分合计
|
||||
-- jst_purchaseItem.qty = 采购数量,inQty = 已入库数量,plan_arrive_qty 全库恒为 0(不可用)、
|
||||
-- is_delivery 全库恒为 0(不可信),故在途口径 = SUM(MAX(0, qty − inQty))。
|
||||
-- ⚠️ 此列只参与「取数/展示」,绝不参与 ORDER BY / 聚合(参考本文件头 MSSQL 坑说明,
|
||||
-- 对 OUTER APPLY 列做排序/聚合会触发灾难性执行计划)。
|
||||
OUTER APPLY (
|
||||
SELECT CAST(SUM(CASE WHEN p.qty > ISNULL(p.inQty, 0) THEN p.qty - ISNULL(p.inQty, 0) ELSE 0 END) AS DECIMAL(18, 2)) AS transit
|
||||
FROM dbo.jst_purchaseItem p
|
||||
WHERE p.sku_id = s.sku_id
|
||||
) ptr
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 NULLIF(LTRIM(RTRIM(c.pictureSkucode)), N'') AS pictureSkucode
|
||||
FROM dbo.fys_skuCostPriceUpdate c
|
||||
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
|
||||
-- 模块化配置状态:见文件头部「已配置MKH」口径说明
|
||||
OUTER APPLY (
|
||||
SELECT CAST(CASE WHEN ${buildConfiguredExistsSql()} THEN 1 ELSE 0 END AS BIT) AS configured
|
||||
) cfg
|
||||
WHERE s.other_3 = @warehouseName`;
|
||||
|
||||
const toNumber = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const toText = (value) => {
|
||||
if (value === null || value === undefined) return null;
|
||||
const text = String(value).trim();
|
||||
return text === '' ? null : text;
|
||||
};
|
||||
|
||||
const WARNING_ORDER = {
|
||||
[WARNING_LEVEL.OUT_OF_STOCK]: 0,
|
||||
[WARNING_LEVEL.WARNING]: 1,
|
||||
[WARNING_LEVEL.NORMAL]: 2,
|
||||
};
|
||||
|
||||
/**
|
||||
* 取目标仓最新监控日(pm_stockMonitor 快照日),作为「当日入库 / 当日出库」的统计基准日。
|
||||
* 业务库没有出入库流水表,只能以监控日为基准做按日汇总。
|
||||
*/
|
||||
async function resolveMonitorDate() {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT CAST(MAX(date) AS DATE) AS monitorDate
|
||||
FROM dbo.pm_stockMonitor
|
||||
WHERE warehouse = @warehouseName`,
|
||||
{ warehouseName: WAREHOUSE_NAME },
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
const raw = rows?.[0]?.monitorDate;
|
||||
if (!raw) return null;
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const buildFilters = (query = {}, monitorDate = null) => {
|
||||
const conditions = [];
|
||||
const params = {
|
||||
wmsCoId: WAREHOUSE_WMS_CO_ID,
|
||||
warehouseName: WAREHOUSE_NAME,
|
||||
monitorDate,
|
||||
};
|
||||
|
||||
const keyword = toText(query.keyword);
|
||||
if (keyword) {
|
||||
params.keyword = `%${keyword}%`;
|
||||
conditions.push('(s.sku_id LIKE @keyword OR s.name LIKE @keyword)');
|
||||
}
|
||||
|
||||
const category = toText(query.category);
|
||||
if (category) {
|
||||
params.category = category;
|
||||
conditions.push('s.category = @category');
|
||||
}
|
||||
|
||||
const location = toText(query.location);
|
||||
if (location) {
|
||||
params.location = location;
|
||||
conditions.push(`${buildLocationSql()} = @location`);
|
||||
}
|
||||
|
||||
const mkhStatus = toText(query.mkhStatus);
|
||||
if (mkhStatus === MKH_STATUS.CONFIGURED || mkhStatus === MKH_STATUS.UNCONFIGURED) {
|
||||
params.mkhStatus = mkhStatus;
|
||||
conditions.push(`CASE WHEN cfg.configured = 1 THEN N'${MKH_STATUS.CONFIGURED}' ELSE N'${MKH_STATUS.UNCONFIGURED}' END = @mkhStatus`);
|
||||
}
|
||||
|
||||
const warningLevel = toText(query.warningLevel);
|
||||
if (warningLevel && Object.prototype.hasOwnProperty.call(WARNING_ORDER, warningLevel)) {
|
||||
params.warningOrder = WARNING_ORDER[warningLevel];
|
||||
// 用统一表达式,未配置MKH 不参与预警判定(筛「缺货 / 预警」时不会漏出未配置MKH)
|
||||
conditions.push(`${buildWarningOrderSql()} = @warningOrder`);
|
||||
}
|
||||
|
||||
const onlyWarning = String(query.onlyWarning || '').toLowerCase();
|
||||
if (['1', 'true', 'yes'].includes(onlyWarning)) {
|
||||
// 仅看缺货 / 预警:统一表达式 <= 1(未配置MKH 一律为 2,自动排除)
|
||||
conditions.push(`${buildWarningOrderSql()} <= 1`);
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
};
|
||||
|
||||
/**
|
||||
* 用「产品资料系统数据库」补齐商品图片。
|
||||
*
|
||||
* ⚠️ 2026-09-15 实测:目标仓 794 个 SKU 在业务库里图片字段全部为空——
|
||||
* rpt.jst_sku.pic / pic_big 794/794 为 NULL
|
||||
* rpt.fys_skuCostPriceUpdate.pictureSkucode 794/794 为 NULL
|
||||
* 且 rpt.jst_sku 中只有 pic / pic_big 两个图片列,无其他图片源。
|
||||
* 因此图片统一改从产品资料系统数据库(PostgreSQL)取:
|
||||
* external.product_catalog_wide_base_v1
|
||||
* 按 barcode(完整 SKU 编码)匹配,image_url 优先、product_thumbnail_url 兜底
|
||||
* 实测目标仓 661 条可匹配,其中 481 条带图。
|
||||
* 查询失败时静默降级,不影响列表主流程。
|
||||
*/
|
||||
async function attachProductImages(rows = []) {
|
||||
const codes = Array.from(new Set(rows.map((row) => toText(row.skuId)).filter(Boolean)));
|
||||
if (codes.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const imageMap = new Map();
|
||||
try {
|
||||
const imageRows = await productDb.query(
|
||||
`SELECT barcode, image_url, product_thumbnail_url
|
||||
FROM external.product_catalog_wide_base_v1
|
||||
WHERE barcode = ANY($1)`,
|
||||
[codes]
|
||||
);
|
||||
|
||||
imageRows.forEach((row) => {
|
||||
const code = toText(row.barcode);
|
||||
if (!code || imageMap.has(code)) return;
|
||||
const url = toText(row.image_url) || toText(row.product_thumbnail_url);
|
||||
if (url) imageMap.set(code, url);
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[ModularStock] 读取产品资料库图片失败,降级为业务库图片:', error.message);
|
||||
}
|
||||
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
productImageUrl: imageMap.get(toText(row.skuId)) || null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 「已配置MKH」判定说明(2026-09-17 晚修正):
|
||||
* 直接由 buildBaseSql 的 cfg 子查询判定,数据源为聚水潭 SKU 级 BOM 维护
|
||||
* rpt.jst_skuBomItem(map_outer_sku_id = 该 SKU 且父项 sku_id 以 'MKH' 开头),
|
||||
* 不再依赖产品资料系统数据库。产品资料库仅用于补齐商品图片(见 attachProductImages)。
|
||||
*/
|
||||
|
||||
const mapRow = (row = {}) => {
|
||||
const realtimeStock = Number(row.realtimeStock || 0);
|
||||
const stockDays = toNumber(row.stockDays);
|
||||
const safetyDays = toNumber(row.safetyDays) ?? DEFAULT_SAFETY_DAYS;
|
||||
// 预警等级以 SQL 端 buildWarningOrderSql() 的判定为准(已含「未配置MKH 不进预警」规则),
|
||||
// 前端显示与排序口径保持一致,避免前后端各算一份。
|
||||
const warningOrder = Number(row.warningOrder ?? WARNING_ORDER[WARNING_LEVEL.NORMAL]);
|
||||
const warningLevel = warningOrder === WARNING_ORDER[WARNING_LEVEL.OUT_OF_STOCK]
|
||||
? WARNING_LEVEL.OUT_OF_STOCK
|
||||
: warningOrder === WARNING_ORDER[WARNING_LEVEL.WARNING]
|
||||
? WARNING_LEVEL.WARNING
|
||||
: WARNING_LEVEL.NORMAL;
|
||||
|
||||
return {
|
||||
skuId: toText(row.skuId),
|
||||
productName: toText(row.productName),
|
||||
category: toText(row.category),
|
||||
// 图片优先取产品资料系统数据库(业务库 pic/pic_big 目标仓全为空)
|
||||
imageUrl: toText(row.productImageUrl) || toText(row.imageUrl),
|
||||
warningLevel,
|
||||
warningOrder,
|
||||
mkhStatus: toText(row.mkhStatus) || MKH_STATUS.UNCONFIGURED,
|
||||
// 业务库无出入库流水表:入库取采购到货(监控日),出库取 pm_stockMonitor 近一日发货量
|
||||
inQtyToday: toNumber(row.inQtyToday) ?? 0,
|
||||
outQtyToday: toNumber(row.outQtyToday) ?? 0,
|
||||
realtimeStock,
|
||||
purchaseTransit: toNumber(row.purchaseTransit) ?? 0,
|
||||
location: toText(row.location),
|
||||
avgShip15d: toNumber(row.avgShip15d),
|
||||
stockDays,
|
||||
safetyDays,
|
||||
suggestStock: toNumber(row.suggestStock),
|
||||
warehouse: toText(row.warehouse) || WAREHOUSE_NAME,
|
||||
};
|
||||
};
|
||||
|
||||
const buildProjection = () => `
|
||||
s.sku_id AS skuId,
|
||||
s.name AS productName,
|
||||
s.category AS category,
|
||||
${buildRealtimeStockSql()} 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.safetyDays AS safetyDays,
|
||||
m.suggestStock AS suggestStock,
|
||||
s.other_3 AS warehouse,
|
||||
COALESCE(
|
||||
NULLIF(LTRIM(RTRIM(s.pic_big)), N''),
|
||||
NULLIF(LTRIM(RTRIM(s.pic)), N''),
|
||||
cost.pictureSkucode
|
||||
) AS imageUrl,
|
||||
CASE WHEN cfg.configured = 1 THEN N'已配置MKH' ELSE N'未配置MKH' END AS mkhStatus,
|
||||
${buildWarningOrderSql()} AS warningOrder,
|
||||
m.date AS monitorDate`;
|
||||
|
||||
async function getModularStockList(query = {}) {
|
||||
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
|
||||
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
|
||||
const monitorDate = await resolveMonitorDate();
|
||||
const { conditions, params } = buildFilters(query, monitorDate);
|
||||
const baseSql = buildBaseSql();
|
||||
const whereSql = conditions.length ? `${baseSql} AND ${conditions.join(' AND ')}` : baseSql;
|
||||
|
||||
const sortField = SORTABLE_FIELDS[query.sortField] ? query.sortField : 'realtimeStock';
|
||||
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||
const orderSql = `${SORTABLE_FIELDS[sortField]} ${sortOrder}`;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
// 已按 s.sku_id 排序时不再追加 tie-breaker,否则 ORDER BY 出现重复列报错 169
|
||||
const orderClause = SORTABLE_FIELDS[sortField] === 's.sku_id'
|
||||
? orderSql
|
||||
: `${orderSql}, s.sku_id ASC`;
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT ${buildProjection()}
|
||||
${whereSql}
|
||||
ORDER BY ${orderClause}
|
||||
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
|
||||
params,
|
||||
{ requestTimeout: 180000 }
|
||||
);
|
||||
|
||||
const [totalRow] = await cloudDatabase.query(
|
||||
`SELECT COUNT_BIG(*) AS total ${whereSql}`,
|
||||
params,
|
||||
{ requestTimeout: 180000 }
|
||||
);
|
||||
|
||||
const [summaryRow] = await buildSummary(whereSql, params);
|
||||
|
||||
// 用产品资料系统数据库补齐图片(业务库目标仓图片字段全为空)
|
||||
const enrichedRows = await attachProductImages(rows);
|
||||
|
||||
return {
|
||||
rows: enrichedRows.map(mapRow),
|
||||
total: Number(totalRow?.total || 0),
|
||||
page,
|
||||
pageSize,
|
||||
warehouse: WAREHOUSE_NAME,
|
||||
monitorDate: summaryRow?.monitorDate || monitorDate,
|
||||
summary: {
|
||||
skuCount: summaryRow?.skuCount || 0,
|
||||
outOfStockCount: summaryRow?.outOfStockCount || 0,
|
||||
lowStockDaysCount: summaryRow?.lowStockDaysCount || 0,
|
||||
configuredCount: summaryRow?.configuredCount || 0,
|
||||
unconfiguredCount: (summaryRow?.skuCount || 0) - (summaryRow?.configuredCount || 0),
|
||||
totalStock: summaryRow?.totalStock || 0,
|
||||
totalPurchaseTransit: summaryRow?.totalPurchaseTransit || 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildSummary(whereSql, params) {
|
||||
const rows = await cloudDatabase.query(
|
||||
`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(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,
|
||||
MAX(m.date) AS monitorDate
|
||||
${whereSql}`,
|
||||
params,
|
||||
{ requestTimeout: 180000 }
|
||||
);
|
||||
const row = rows[0] || {};
|
||||
|
||||
return [{
|
||||
skuCount: Number(row.skuCount || 0),
|
||||
outOfStockCount: Number(row.outOfStockCount || 0),
|
||||
totalStock: Number(row.totalStock || 0),
|
||||
totalPurchaseTransit: Number(row.totalPurchaseTransit || 0),
|
||||
lowStockDaysCount: Number(row.lowStockDaysCount || 0),
|
||||
configuredCount: Number(row.configuredCount || 0),
|
||||
monitorDate: row.monitorDate ? new Date(row.monitorDate).toISOString().slice(0, 10) : null,
|
||||
}];
|
||||
}
|
||||
|
||||
async function getModularStockFilters() {
|
||||
// 2026-09-18 修正:品类下拉不需要 JOIN 库存/采购/监控等重表,只用 SKU 主表即可,
|
||||
// 避免 buildBaseSql 里新增采购在途 OUTER APPLY 后把 filters 接口拖慢。
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT DISTINCT s.category
|
||||
FROM dbo.jst_sku s
|
||||
WHERE s.other_3 = @warehouseName
|
||||
AND s.category IS NOT NULL
|
||||
AND LTRIM(RTRIM(s.category)) <> N''
|
||||
ORDER BY s.category`,
|
||||
{ warehouseName: WAREHOUSE_NAME },
|
||||
{ requestTimeout: 60000 }
|
||||
);
|
||||
|
||||
return {
|
||||
categories: rows.map((row) => toText(row.category)).filter(Boolean),
|
||||
warningLevels: [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING, WARNING_LEVEL.NORMAL],
|
||||
mkhStatuses: [MKH_STATUS.CONFIGURED, MKH_STATUS.UNCONFIGURED],
|
||||
warehouse: WAREHOUSE_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 采购在途明细列表:目标仓全部「采购数量 > 已入库数量」的采购单明细。
|
||||
* 口径:在途数量 = qty − inQty(已下单但尚未入库的部分)。
|
||||
* 支持 keyword 搜索商品编码 / 名称,支持按 transitQty(默认)/ skuId / purchaseQty / deliveryDate 排序。
|
||||
*/
|
||||
async function getPurchaseInTransitList(query = {}) {
|
||||
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
|
||||
const requestedSize = Number.parseInt(query.pageSize, 10) || DEFAULT_PAGE_SIZE;
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, requestedSize));
|
||||
|
||||
const keyword = toText(query.keyword);
|
||||
const params = { warehouseName: WAREHOUSE_NAME };
|
||||
let whereSql = `s.other_3 = @warehouseName AND p.qty > ISNULL(p.inQty, 0)`;
|
||||
if (keyword) {
|
||||
params.keyword = `%${keyword}%`;
|
||||
whereSql += ` AND (p.sku_id LIKE @keyword OR p.name LIKE @keyword)`;
|
||||
}
|
||||
|
||||
const sortField = String(query.sortField || '').trim();
|
||||
const sortOrder = String(query.sortOrder || 'DESC').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
|
||||
const orderMap = {
|
||||
skuId: 'p.sku_id',
|
||||
deliveryDate: 'p.delivery_date',
|
||||
transitQty: 'transitQty',
|
||||
purchaseQty: 'p.qty',
|
||||
};
|
||||
const orderCol = orderMap[sortField] || 'transitQty';
|
||||
const orderClause = sortField === 'skuId'
|
||||
? `${orderCol} ${sortOrder}`
|
||||
: `${orderCol} ${sortOrder}, p.sku_id ASC`;
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
|
||||
const [rows, totalRow] = await Promise.all([
|
||||
cloudDatabase.query(
|
||||
`SELECT
|
||||
p.sku_id AS skuId,
|
||||
p.name AS productName,
|
||||
CAST(p.qty AS BIGINT) AS purchaseQty,
|
||||
CAST(ISNULL(p.inQty, 0) AS BIGINT) AS inQty,
|
||||
CAST(p.qty - ISNULL(p.inQty, 0) AS BIGINT) AS transitQty,
|
||||
p.delivery_date AS deliveryDate,
|
||||
CAST(p.po_id AS NVARCHAR(50)) AS poId,
|
||||
CAST(p.poi_id AS NVARCHAR(50)) AS poiId
|
||||
FROM rpt.jst_purchaseItem p
|
||||
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY ${orderClause}
|
||||
OFFSET ${offset} ROWS FETCH NEXT ${pageSize} ROWS ONLY`,
|
||||
params,
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
cloudDatabase.query(
|
||||
`SELECT COUNT_BIG(*) AS total
|
||||
FROM rpt.jst_purchaseItem p
|
||||
INNER JOIN dbo.jst_sku s ON s.sku_id = p.sku_id
|
||||
WHERE ${whereSql}`,
|
||||
params,
|
||||
{ requestTimeout: 180000 }
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
rows: rows.map((row) => ({
|
||||
skuId: toText(row.skuId),
|
||||
productName: toText(row.productName),
|
||||
purchaseQty: Number(row.purchaseQty || 0),
|
||||
inQty: Number(row.inQty || 0),
|
||||
transitQty: Number(row.transitQty || 0),
|
||||
deliveryDate: row.deliveryDate ? new Date(row.deliveryDate).toISOString().slice(0, 10) : null,
|
||||
poId: toText(row.poId),
|
||||
poiId: toText(row.poiId),
|
||||
})),
|
||||
total: Number(totalRow?.[0]?.total || 0),
|
||||
page,
|
||||
pageSize,
|
||||
warehouse: WAREHOUSE_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
// 手动同步:本页无进程内缓存(每次请求直连副本库),同步动作 = 重新解析监控日并返回,
|
||||
// 前端据此刷新列表到最新快照。
|
||||
// 注意:副本库由聚水潭侧同步,应用层无法触发上游拉取,这里只保证取到最新监控日。
|
||||
async function syncModularStock() {
|
||||
const monitorDate = await resolveMonitorDate();
|
||||
return {
|
||||
syncedAt: new Date().toISOString(),
|
||||
monitorDate,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
WAREHOUSE_NAME,
|
||||
WAREHOUSE_WMS_CO_ID,
|
||||
WARNING_LEVEL,
|
||||
DEFAULT_SAFETY_DAYS,
|
||||
getModularStockList,
|
||||
getModularStockFilters,
|
||||
getPurchaseInTransitList,
|
||||
syncModularStock,
|
||||
};
|
||||
@ -1,430 +0,0 @@
|
||||
const dayjs = require('dayjs');
|
||||
const database = require('../config/database');
|
||||
const jianSunDb = require('../config/jianSunDatabase');
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
|
||||
const DEFAULT_SCOPE_START = '2026-07-15';
|
||||
const DEFAULT_SCOPE_END = '2026-07-21';
|
||||
const DEFAULT_END_DATE = '2026-09-02';
|
||||
const AWARD_PER_ID = 400;
|
||||
const JULY_START = '2026-07-01';
|
||||
const JULY_END = '2026-07-31';
|
||||
|
||||
function text(value, fallback = '') {
|
||||
const valueText = String(value ?? '').trim();
|
||||
return valueText || fallback;
|
||||
}
|
||||
|
||||
function validDate(value, fallback) {
|
||||
const valueText = text(value, fallback);
|
||||
return /^\d{4}-\d{2}-\d{2}$/u.test(valueText) && dayjs(valueText).isValid() ? valueText : fallback;
|
||||
}
|
||||
|
||||
function addDays(value, offset) {
|
||||
return dayjs(value).add(offset, 'day').format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function resolveQuery(query = {}) {
|
||||
const scopeStart = validDate(query.scopeStart || query.startDate, DEFAULT_SCOPE_START);
|
||||
const scopeEnd = validDate(query.scopeEnd || query.endDate, DEFAULT_SCOPE_END);
|
||||
const endDate = validDate(query.metricEnd || query.asOfDate, DEFAULT_END_DATE);
|
||||
const metricStart = addDays(endDate, -29);
|
||||
const feeCap = scopeStart >= '2026-07-22' ? 0.88 : 0.9;
|
||||
return {
|
||||
scopeStart,
|
||||
scopeEnd: scopeEnd < scopeStart ? scopeStart : scopeEnd,
|
||||
metricStart,
|
||||
metricEnd: endDate,
|
||||
feeCap,
|
||||
ider: text(query.ider),
|
||||
shopName: text(query.shopName),
|
||||
spu: text(query.spu),
|
||||
goodsId: text(query.goodsId),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function normalizeRow(row = {}, query) {
|
||||
const shippedDays30d = Number(row.shippedDays30d || 0);
|
||||
const performance30d = normalizeNumber(row.performance30d);
|
||||
const fee30d = normalizeNumber(row.fee30d);
|
||||
const feeRatio30d = performance30d > 0 && fee30d !== null ? fee30d / performance30d : null;
|
||||
const firstCreatedAt = row.firstCreatedAt ? new Date(row.firstCreatedAt) : null;
|
||||
const listingStart = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||||
? dayjs(firstCreatedAt).startOf('day')
|
||||
: null;
|
||||
const metricEndDay = dayjs(query.metricEnd).startOf('day');
|
||||
const listedCalendarDays = listingStart && metricEndDay.isValid()
|
||||
? Math.max(1, metricEndDay.diff(listingStart, 'day') + 1)
|
||||
: 30;
|
||||
const metricDenominatorDays = Math.min(30, listedCalendarDays);
|
||||
const sellThroughRate30d = shippedDays30d / metricDenominatorDays;
|
||||
const feeQualified = performance30d > 0 && feeRatio30d !== null && feeRatio30d <= query.feeCap;
|
||||
const bonusQualified = feeQualified && sellThroughRate30d > 0.3;
|
||||
const listingDays = firstCreatedAt && !Number.isNaN(firstCreatedAt.getTime())
|
||||
? Math.max(0, dayjs(query.metricEnd).diff(dayjs(firstCreatedAt), 'day'))
|
||||
: null;
|
||||
return {
|
||||
...row,
|
||||
goodsId: text(row.goodsId),
|
||||
ider: text(row.ider),
|
||||
shopName: text(row.shopName),
|
||||
spu: text(row.spu),
|
||||
goodsName: text(row.goodsName),
|
||||
linkType: text(row.linkType),
|
||||
status: Number(row.onsale || 0) === 1 ? '在售' : '已下架',
|
||||
skuCount: Number(row.skuCount || 0),
|
||||
listingDays,
|
||||
julyPerformance: normalizeNumber(row.julyPerformance),
|
||||
julyFee: normalizeNumber(row.julyFee),
|
||||
julyFeeRatio: normalizeNumber(row.julyPerformance) > 0 ? normalizeNumber(row.julyFee) / normalizeNumber(row.julyPerformance) : null,
|
||||
julyGoodsCount: normalizeNumber(row.julyGoodsCount) || 0,
|
||||
julyShippedDays: Number(row.julyShippedDays || 0),
|
||||
julySellThroughRate: Number(row.julyShippedDays || 0) / 31,
|
||||
performance30d,
|
||||
fee30d,
|
||||
feeRatio30d,
|
||||
goodsCount30d: normalizeNumber(row.goodsCount30d) || 0,
|
||||
shippedDays30d,
|
||||
metricDenominatorDays,
|
||||
sellThroughRate30d,
|
||||
feeQualified,
|
||||
sellThroughQualified: sellThroughRate30d > 0.3,
|
||||
bonusQualified,
|
||||
bonus: bonusQualified ? AWARD_PER_ID : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function buildParams(query) {
|
||||
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||||
return {
|
||||
scopeStart: query.scopeStart,
|
||||
scopeEnd: query.scopeEnd,
|
||||
metricStart: query.metricStart,
|
||||
metricEnd: query.metricEnd,
|
||||
julyStart: JULY_START,
|
||||
julyEnd: JULY_END,
|
||||
ider: query.ider,
|
||||
shopName: query.shopName,
|
||||
spu: query.spu,
|
||||
goodsId: query.goodsId,
|
||||
scopeStartEpochSec: epoch(query.scopeStart),
|
||||
scopeEndEpochSec: epoch(addDays(query.scopeEnd, 1)),
|
||||
scopeStartEpochMs: epoch(query.scopeStart) * 1000,
|
||||
scopeEndEpochMs: epoch(addDays(query.scopeEnd, 1)) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyMetrics() {
|
||||
return { julyPerformance: 0, julyFee: 0, julyGoodsCount: 0, julyShippedDays: 0, performance30d: 0, fee30d: 0, goodsCount30d: 0, shippedDays30d: 0 };
|
||||
}
|
||||
|
||||
async function loadSalesMetrics(goodsIds, query) {
|
||||
const metrics = new Map();
|
||||
const ids = [...new Set((goodsIds || []).map((value) => String(value || '').trim()).filter((value) => /^\d+$/u.test(value)))];
|
||||
for (let offset = 0; offset < ids.length; offset += 1000) {
|
||||
const chunk = ids.slice(offset, offset + 1000);
|
||||
const inList = chunk.map((id) => `'${id}'`).join(',');
|
||||
const rows = await cloudDatabase.query(`
|
||||
SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) AS goodsId,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS julyPerformance,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS julyFee,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS julyGoodsCount,
|
||||
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @julyStart AND @julyEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS julyShippedDays,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_performance),0) ELSE 0 END) AS performance30d,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.real_conclude_fee),0) ELSE 0 END) AS fee30d,
|
||||
SUM(CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd THEN ISNULL(TRY_CONVERT(DECIMAL(38,6), d.goods_count),0) ELSE 0 END) AS goodsCount30d,
|
||||
COUNT(DISTINCT CASE WHEN TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd AND TRY_CONVERT(DECIMAL(38,6), d.goods_count) > 0 THEN TRY_CONVERT(DATE, d.stat_date) END) AS shippedDays30d
|
||||
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||||
WHERE d.item_id IN (${inList})
|
||||
AND d.stat_date BETWEEN @julyStart AND @metricEnd
|
||||
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||||
GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id)))
|
||||
`, { julyStart: JULY_START, julyEnd: JULY_END, metricStart: query.metricStart, metricEnd: query.metricEnd }, { requestTimeout: 180000 });
|
||||
rows.forEach((row) => metrics.set(String(row.goodsId), row));
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
// pdd_Skus 由外部采集系统写入,存在残缺批次(如 2026-09-02 仅 113 个商品)。
|
||||
// 从 upperBound 往回 7 天内挑最新一个商品数不低于窗口最大值 70% 的批次,避免用到不完整采集。
|
||||
async function pickSkuCaptureTime(upperBound) {
|
||||
const end = dayjs(upperBound).isAfter(dayjs(), 'day') ? dayjs().format('YYYY-MM-DD') : dayjs(upperBound).format('YYYY-MM-DD');
|
||||
const dates = Array.from({ length: 7 }, (_, index) => dayjs(end).subtract(index, 'day').format('YYYY-MM-DD'));
|
||||
const counts = await Promise.all(dates.map((captureTime) => jianSunDb.query(
|
||||
'SELECT COUNT(DISTINCT goodsId) AS goodsCount FROM dbo.pdd_Skus WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE createTime = @captureTime',
|
||||
{ captureTime }, { requestTimeout: 60000 }
|
||||
)));
|
||||
const mapped = dates.map((dateValue, index) => ({ date: dateValue, goodsCount: Number(counts[index]?.[0]?.goodsCount || 0) }));
|
||||
const maxCount = Math.max(...mapped.map((item) => item.goodsCount));
|
||||
if (maxCount <= 0) return null;
|
||||
return (mapped.find((item) => item.goodsCount >= maxCount * 0.7) || mapped.find((item) => item.goodsCount === maxCount)).date;
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBoardLive(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const livePeriod = findPeriod(await loadPeriods(), query);
|
||||
if (livePeriod) query.feeCap = livePeriod.feeCap;
|
||||
const targetRows = await jianSunDb.query(`SELECT LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid))) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.ider)))) AS ider, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), tm.shopname)))) AS shopName, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.spu)))) AS spu, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.linkType)))) AS linkType FROM dbo.op_TargetManagement tm WITH (NOLOCK) WHERE tm.dept = N'运营中心B' GROUP BY LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), tm.shopproid)))`, {}, { requestTimeout: 60000 });
|
||||
const latestCreateTime = await pickSkuCaptureTime(query.metricEnd);
|
||||
if (!latestCreateTime) throw new Error('pdd_Skus 近7天无可用完整采集批次');
|
||||
const epoch = (value) => Math.floor(new Date(`${value}T00:00:00Z`).getTime() / 1000);
|
||||
const startSec = epoch(query.scopeStart);
|
||||
const endSec = epoch(addDays(query.scopeEnd, 1));
|
||||
const skuRows = await jianSunDb.query(`SELECT CONVERT(NVARCHAR(100), s.goodsId) AS goodsId, MAX(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(255), s.goodsName)))) AS goodsName, MIN(DATEADD(SECOND, CONVERT(INT, CASE WHEN s.createdAt >= 1000000000000 THEN s.createdAt / 1000 ELSE s.createdAt END), CONVERT(DATETIME2, '1970-01-01'))) AS firstCreatedAt, MAX(CASE WHEN TRY_CONVERT(INT, s.isOnsale) = 1 THEN 1 ELSE 0 END) AS onsale, COUNT_BIG(1) AS skuCount FROM dbo.pdd_Skus s WITH (NOLOCK, INDEX(pdd_Skus_createTime_index)) WHERE s.createTime = @latestCreateTime AND ((s.createdAt >= @startSec AND s.createdAt < @endSec) OR (s.createdAt >= @startMs AND s.createdAt < @endMs)) GROUP BY s.goodsId`, { latestCreateTime, startSec, endSec, startMs: startSec * 1000, endMs: endSec * 1000 }, { requestTimeout: 180000 });
|
||||
const targetMap = new Map(targetRows.map((row) => [String(row.goodsId), row]));
|
||||
const scopeRows = skuRows.flatMap((row) => {
|
||||
const target = targetMap.get(String(row.goodsId));
|
||||
if (!target) return [];
|
||||
if ((query.ider && target.ider !== query.ider) || (query.shopName && target.shopName !== query.shopName) || (query.spu && !String(target.spu || '').includes(query.spu)) || (query.goodsId && !String(row.goodsId).includes(query.goodsId))) return [];
|
||||
return [{ ...row, ...target }];
|
||||
});
|
||||
const salesMetrics = await loadSalesMetrics(scopeRows.map((row) => row.goodsId), query);
|
||||
const rows = scopeRows.map((row) => ({ ...emptyMetrics(), ...row, ...(salesMetrics.get(String(row.goodsId)) || {}) }));
|
||||
let dwsLatestRows = [];
|
||||
try {
|
||||
dwsLatestRows = await cloudDatabase.query(`SELECT MAX(TRY_CONVERT(DATE, stat_date)) AS latestDate FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] WITH (NOLOCK) WHERE ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), platform))), N'') IN (N'Pinduoduo', N'拼多多')`, {}, { requestTimeout: 60000 });
|
||||
} catch (error) {
|
||||
console.warn('[OpsBIncentiveBoard] latest DWS date unavailable:', error.message);
|
||||
}
|
||||
const normalizedRows = (rows || []).map((row) => normalizeRow(row, query));
|
||||
const qualifiedRows = normalizedRows.filter((row) => row.bonusQualified);
|
||||
const ownerMap = new Map();
|
||||
normalizedRows.forEach((row) => {
|
||||
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||||
current.goodsIdCount += 1;
|
||||
current.goodsCount30d += row.goodsCount30d;
|
||||
current.shippedDays30d += row.shippedDays30d;
|
||||
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||||
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||||
current.bonus += row.bonus;
|
||||
ownerMap.set(row.ider, current);
|
||||
});
|
||||
return {
|
||||
rows: normalizedRows,
|
||||
bonusRows: qualifiedRows,
|
||||
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||||
summary: {
|
||||
goodsIdCount: normalizedRows.length,
|
||||
feeQualifiedCount: normalizedRows.filter((row) => row.feeQualified).length,
|
||||
sellThroughQualifiedCount: normalizedRows.filter((row) => row.sellThroughQualified).length,
|
||||
bonusQualifiedCount: qualifiedRows.length,
|
||||
bonus: qualifiedRows.length * AWARD_PER_ID,
|
||||
awardPerId: AWARD_PER_ID,
|
||||
},
|
||||
query: { ...query, julyStart: JULY_START, julyEnd: JULY_END, latestDwsDate: dwsLatestRows?.[0]?.latestDate || null },
|
||||
};
|
||||
}
|
||||
|
||||
// 口径来源:运营中心B激励方案批次表。pullDate = 拉取(冻结)日期,统计窗口固定为 pullDate 往前 30 天。
|
||||
// 批次配置存 MySQL ops_b_incentive_periods 表(可动态新增批次,无需发版),此列表仅作首次种子与兜底。
|
||||
const DEFAULT_SNAPSHOT_PERIODS = [
|
||||
{ scopeStart: '2026-07-15', scopeEnd: '2026-07-21', pullDate: '2026-08-29', feeCap: 0.9 },
|
||||
{ scopeStart: '2026-07-22', scopeEnd: '2026-07-28', pullDate: '2026-09-05', feeCap: 0.88 },
|
||||
{ scopeStart: '2026-07-29', scopeEnd: '2026-08-04', pullDate: '2026-09-12', feeCap: 0.875 },
|
||||
{ scopeStart: '2026-08-05', scopeEnd: '2026-08-11', pullDate: '2026-09-19', feeCap: 0.87 },
|
||||
{ scopeStart: '2026-08-12', scopeEnd: '2026-08-18', pullDate: '2026-09-26', feeCap: 0.865 },
|
||||
{ scopeStart: '2026-08-19', scopeEnd: '2026-08-25', pullDate: '2026-10-03', feeCap: 0.86 },
|
||||
{ scopeStart: '2026-08-26', scopeEnd: '2026-09-01', pullDate: '2026-10-10', feeCap: 0.855 },
|
||||
{ scopeStart: '2026-09-02', scopeEnd: '2026-09-08', pullDate: '2026-10-17', feeCap: 0.85 },
|
||||
{ scopeStart: '2026-09-09', scopeEnd: '2026-09-15', pullDate: '2026-10-24', feeCap: 0.845 },
|
||||
{ scopeStart: '2026-09-16', scopeEnd: '2026-09-22', pullDate: '2026-10-31', feeCap: 0.84 },
|
||||
];
|
||||
|
||||
function makePeriodKey(scopeStart, scopeEnd) {
|
||||
return `ops-b-${scopeStart.replace(/-/g, '')}-${scopeEnd.replace(/-/g, '')}`;
|
||||
}
|
||||
|
||||
let periodsCache = null;
|
||||
let periodsCacheExpiresAt = 0;
|
||||
|
||||
async function loadPeriods() {
|
||||
if (periodsCache && Date.now() < periodsCacheExpiresAt) return periodsCache;
|
||||
let rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||||
if (!rows.length) {
|
||||
for (const period of DEFAULT_SNAPSHOT_PERIODS) {
|
||||
await database.local.query(
|
||||
'INSERT IGNORE INTO ops_b_incentive_periods (scope_start, scope_end, pull_date, fee_cap, status) VALUES (?, ?, ?, ?, ?)',
|
||||
[period.scopeStart, period.scopeEnd, period.pullDate, period.feeCap, 'active']
|
||||
);
|
||||
}
|
||||
rows = await database.local.query("SELECT scope_start, scope_end, pull_date, fee_cap, status FROM ops_b_incentive_periods ORDER BY scope_start, scope_end");
|
||||
}
|
||||
periodsCache = rows
|
||||
.filter((row) => String(row.status || 'active') === 'active')
|
||||
.map((row) => ({
|
||||
key: makePeriodKey(dayjs(row.scope_start).format('YYYY-MM-DD'), dayjs(row.scope_end).format('YYYY-MM-DD')),
|
||||
scopeStart: dayjs(row.scope_start).format('YYYY-MM-DD'),
|
||||
scopeEnd: dayjs(row.scope_end).format('YYYY-MM-DD'),
|
||||
pullDate: dayjs(row.pull_date).format('YYYY-MM-DD'),
|
||||
feeCap: Number(row.fee_cap),
|
||||
}));
|
||||
periodsCacheExpiresAt = Date.now() + 60000;
|
||||
return periodsCache;
|
||||
}
|
||||
|
||||
function findPeriod(periods, query) {
|
||||
return periods.find((period) => period.scopeStart === query.scopeStart && period.scopeEnd === query.scopeEnd) || null;
|
||||
}
|
||||
|
||||
async function readSnapshot(period) {
|
||||
const rows = await database.local.query(
|
||||
'SELECT rows_json, summary_json, metric_end, finalized, generated_at FROM ops_b_incentive_snapshots WHERE snapshot_key = ? AND status = ? LIMIT 1',
|
||||
[period.key, 'completed']
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
try {
|
||||
const meta = JSON.parse(row.summary_json || '{}');
|
||||
return { rows: JSON.parse(row.rows_json || '[]'), summary: meta.summary || {}, ownerSummary: meta.ownerSummary || [], metricEnd: row.metric_end, finalized: Boolean(row.finalized), generatedAt: row.generated_at };
|
||||
} catch (error) {
|
||||
console.warn('[OpsBIncentiveSnapshot] invalid snapshot:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSnapshot(period, query, result, finalized) {
|
||||
await database.local.query(`
|
||||
INSERT INTO ops_b_incentive_snapshots
|
||||
(snapshot_key, scope_start, scope_end, metric_end, status, finalized, rows_json, summary_json, generated_at, completed_at, error_message)
|
||||
VALUES (?, ?, ?, ?, 'completed', ?, ?, ?, NOW(), NOW(), NULL)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
metric_end = VALUES(metric_end), finalized = VALUES(finalized), rows_json = VALUES(rows_json), summary_json = VALUES(summary_json),
|
||||
generated_at = VALUES(generated_at), completed_at = VALUES(completed_at), status = 'completed', error_message = NULL
|
||||
`, [period.key, query.scopeStart, query.scopeEnd, query.metricEnd, finalized ? 1 : 0, JSON.stringify(result.rows || []), JSON.stringify({ summary: result.summary || {}, ownerSummary: result.ownerSummary || [] })]);
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBoard(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const period = findPeriod(await loadPeriods(), query);
|
||||
if (period) query.feeCap = period.feeCap;
|
||||
if (!period) return getOpsBIncentiveBoardLive(input);
|
||||
const existing = await readSnapshot(period);
|
||||
if (!existing) {
|
||||
return {
|
||||
pending: true,
|
||||
rows: [],
|
||||
bonusRows: [],
|
||||
ownerSummary: [],
|
||||
summary: {},
|
||||
query: { ...query, latestDwsDate: null, snapshot: { cached: false, finalized: false, pending: true } },
|
||||
};
|
||||
}
|
||||
const filteredRows = existing.rows.filter((row) => (
|
||||
(!query.ider || row.ider === query.ider)
|
||||
&& (!query.shopName || row.shopName === query.shopName)
|
||||
&& (!query.spu || String(row.spu || '').includes(query.spu))
|
||||
&& (!query.goodsId || String(row.goodsId || '').includes(query.goodsId))
|
||||
));
|
||||
const ownerMap = new Map();
|
||||
filteredRows.forEach((row) => {
|
||||
const current = ownerMap.get(row.ider) || { ider: row.ider, goodsIdCount: 0, goodsCount30d: 0, shippedDays30d: 0, feeQualifiedCount: 0, bonusQualifiedCount: 0, bonus: 0 };
|
||||
current.goodsIdCount += 1;
|
||||
current.goodsCount30d += Number(row.goodsCount30d || 0);
|
||||
current.shippedDays30d += Number(row.shippedDays30d || 0);
|
||||
current.feeQualifiedCount += row.feeQualified ? 1 : 0;
|
||||
current.bonusQualifiedCount += row.bonusQualified ? 1 : 0;
|
||||
current.bonus += Number(row.bonus || 0);
|
||||
ownerMap.set(row.ider, current);
|
||||
});
|
||||
const bonusRows = filteredRows.filter((row) => row.bonusQualified);
|
||||
return {
|
||||
...existing,
|
||||
rows: filteredRows,
|
||||
bonusRows,
|
||||
ownerSummary: [...ownerMap.values()].sort((a, b) => b.bonus - a.bonus || b.goodsIdCount - a.goodsIdCount),
|
||||
summary: {
|
||||
goodsIdCount: filteredRows.length,
|
||||
feeQualifiedCount: filteredRows.filter((row) => row.feeQualified).length,
|
||||
sellThroughQualifiedCount: filteredRows.filter((row) => row.sellThroughQualified).length,
|
||||
bonusQualifiedCount: bonusRows.length,
|
||||
bonus: bonusRows.reduce((sum, row) => sum + Number(row.bonus || 0), 0),
|
||||
awardPerId: AWARD_PER_ID,
|
||||
},
|
||||
query: {
|
||||
...query,
|
||||
metricEnd: dayjs(existing.metricEnd).format('YYYY-MM-DD'),
|
||||
latestDwsDate: null,
|
||||
snapshot: { cached: true, finalized: existing.finalized, generatedAt: existing.generatedAt },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshOpsBIncentiveSnapshots(source = 'scheduled') {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const results = [];
|
||||
for (const period of await loadPeriods()) {
|
||||
const { scopeStart, scopeEnd } = period;
|
||||
// 拉取日次日首刷才定档,确保统计窗口包含拉取日全天数据(数仓滞后约一天)。
|
||||
const finalized = today > period.pullDate;
|
||||
const existing = await readSnapshot(period);
|
||||
if (existing?.finalized) {
|
||||
results.push({ key: period.key, rows: existing.rows.length, finalized: true, skipped: true, source });
|
||||
continue;
|
||||
}
|
||||
const result = await getOpsBIncentiveBoardLive({ scopeStart, scopeEnd, metricEnd: period.pullDate });
|
||||
await writeSnapshot(period, { scopeStart, scopeEnd, metricEnd: period.pullDate }, result, finalized);
|
||||
results.push({ key: period.key, rows: result.rows.length, finalized, source });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveBatches() {
|
||||
const periods = await loadPeriods();
|
||||
const snapshots = await database.local.query(
|
||||
"SELECT snapshot_key, finalized, generated_at, JSON_EXTRACT(summary_json, '$.summary.goodsIdCount') goodsIdCount, JSON_EXTRACT(summary_json, '$.summary.bonusQualifiedCount') bonusQualifiedCount, JSON_EXTRACT(summary_json, '$.summary.bonus') bonus FROM ops_b_incentive_snapshots WHERE status = 'completed'"
|
||||
);
|
||||
const snapshotMap = new Map(snapshots.map((row) => [row.snapshot_key, row]));
|
||||
return periods.map((period, index) => {
|
||||
const snapshot = snapshotMap.get(period.key) || {};
|
||||
return {
|
||||
...period,
|
||||
batchNo: index + 1,
|
||||
finalized: Boolean(snapshot.finalized),
|
||||
generatedAt: snapshot.generated_at || null,
|
||||
goodsIdCount: Number(snapshot.goodsIdCount || 0),
|
||||
bonusQualifiedCount: Number(snapshot.bonusQualifiedCount || 0),
|
||||
bonus: Number(snapshot.bonus || 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getOpsBIncentiveDaily(input = {}) {
|
||||
const query = resolveQuery(input);
|
||||
const params = buildParams(query);
|
||||
if (!query.goodsId) return { rows: [], query };
|
||||
const rows = await cloudDatabase.query(`
|
||||
WITH daily AS (
|
||||
SELECT TRY_CONVERT(DATE, d.stat_date) AS statDate,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.goods_count), 0)) AS goodsCount,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_performance), 0)) AS realPerformance,
|
||||
SUM(ISNULL(TRY_CONVERT(DECIMAL(38, 6), d.real_conclude_fee), 0)) AS realFee
|
||||
FROM [JSDataWarehouse].[dws].[dws_sn_sales_full_info_di] d WITH (NOLOCK)
|
||||
WHERE LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(100), d.item_id))) = @goodsId
|
||||
AND TRY_CONVERT(DATE, d.stat_date) BETWEEN @metricStart AND @metricEnd
|
||||
AND ISNULL(LTRIM(RTRIM(TRY_CONVERT(NVARCHAR(50), d.platform))), N'') IN (N'Pinduoduo', N'拼多多')
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.ps_payment_classify_isYes), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_amount_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.refund_sale_compensate_isZc), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.oo_mix_zc_kd), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zc_dynamic_express_cost), 0) = 0
|
||||
AND ISNULL(TRY_CONVERT(DECIMAL(18, 2), d.fr_zcd_pj_fdf_express_cost), 0) = 0
|
||||
GROUP BY TRY_CONVERT(DATE, d.stat_date)
|
||||
) SELECT statDate, goodsCount, CASE WHEN goodsCount > 0 THEN N'是' ELSE N'否' END AS isActive, realPerformance, realFee
|
||||
FROM daily ORDER BY statDate ASC
|
||||
`, params, { requestTimeout: 120000 });
|
||||
return { rows: rows || [], query };
|
||||
}
|
||||
|
||||
module.exports = { getOpsBIncentiveBoard, getOpsBIncentiveBoardLive, getOpsBIncentiveDaily, getOpsBIncentiveBatches, refreshOpsBIncentiveSnapshots, resolveQuery, pickSkuCaptureTime, AWARD_PER_ID, DEFAULT_SNAPSHOT_PERIODS };
|
||||
@ -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,943 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 商品监控:全公司在售商品的基础档案 + 库存 + 标签分级 + 部门运营指标
|
||||
//
|
||||
// 数据来源(2026-09-11 实测确认):
|
||||
// 主表 业务库 rpt.pm_stockMonitor —— 脑图「商品监控」的原始数据源
|
||||
// (5.6 万行 / 日更 / 含 sevenAvgSale 日销、jsStock 库存、jsUseDays 可用天数、
|
||||
// suggestStock 建议备货、purchase 采购负责人、supplier、warehouse、sku_idStatus)
|
||||
// 补充 业务库 rpt.jst_sku —— 图片、条码(sku_code)、材质、颜色、尺寸
|
||||
// 配置 系统库 product_scale_thresholds / product_profit_levels / product_sabc_mappings
|
||||
// (公司 SABC,由「系统设置 → 商品标签配置」维护)
|
||||
// 配置 系统库 product_tag_rules (部门标签 G-S/G-A/G-B/G-C)
|
||||
// 配置 系统库 department_sku_owners (部门 SKU 负责人)
|
||||
//
|
||||
// 判定口径:
|
||||
// - 公司标签 S/A/B/C/D ← productTagConfigService.resolveProductTags()(业绩 + 日销双命中)
|
||||
// - 部门标签 G-S/G-A/G-B/G-C ← product_tag_rules(tag_scope='department'),左闭右开,升序命中即停
|
||||
//
|
||||
// ⚠️ 已知留空字段(等业务给源表后再填):
|
||||
// - 在途库存 inTransitStock → null,前端显示 `--`
|
||||
// - 部门未发货数量 departmentUnshipped → null,前端显示 `--`
|
||||
// (pm_stockMonitor.outOfStock 语义未确认,本次不用)
|
||||
|
||||
const database = require('../config/database');
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const productTagConfigService = require('./productTagConfigService');
|
||||
|
||||
const REPORT_SOURCE_TABLE = 'dbo.pm_stockMonitor';
|
||||
// 销售明细(最新,2026-09-09):按 skuoid 聚合可得业绩/销售额、部门、主渠道、主运营
|
||||
const SALES_DETAIL_TABLE = 'dbo.dws_sn_sales_full_info_di';
|
||||
const SALES_WINDOW_DAYS = 7;
|
||||
// 公司标签 S/A/B/C 的「业绩」阈值(系统设置 → 商品标签配置)是 30 天量级
|
||||
// (如「住宅家具 - 书架」爆款线 48000),因此标签判定用 30 天销售额,日销仍用宽表 sevenAvgSale
|
||||
const SALES_PERFORMANCE_WINDOW_DAYS = 30;
|
||||
|
||||
const SALE_STATUS_OPTIONS = Object.freeze(['销售', '停产', '退市']);
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_PAGE_SIZE = 200;
|
||||
|
||||
const DEPARTMENT_TAG_SCOPE = 'department';
|
||||
const DEPARTMENT_TAG_LEVELS = Object.freeze(['G-S', 'G-A', 'G-B', 'G-C']);
|
||||
const DEPARTMENT_METRIC_KEYS = Object.freeze(['sales_qty_7d', 'sales_amount_7d']);
|
||||
|
||||
// ==================== 表结构 ====================
|
||||
|
||||
async function ensureTable(tableName, createSql) {
|
||||
await database.local.query(createSql);
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`Table ${tableName} create failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProductMonitoringSchema() {
|
||||
await ensureTable(
|
||||
'product_tag_rules',
|
||||
`CREATE TABLE IF NOT EXISTS product_tag_rules (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
tag_scope VARCHAR(16) NOT NULL COMMENT 'company | department',
|
||||
tag_level VARCHAR(16) NOT NULL COMMENT '部门标签 G-S/G-A/G-B/G-C',
|
||||
metric_key VARCHAR(64) NOT NULL COMMENT 'sales_qty_7d | sales_amount_7d',
|
||||
threshold_min DECIMAL(18,4) NULL,
|
||||
threshold_max DECIMAL(18,4) NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scope_level_metric (tag_scope, tag_level, metric_key),
|
||||
KEY idx_scope_enabled (tag_scope, enabled),
|
||||
KEY idx_metric (metric_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品标签分级规则(公司SABC / 部门G系列)'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'department_sku_owners',
|
||||
`CREATE TABLE IF NOT EXISTS department_sku_owners (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
department_id BIGINT UNSIGNED NOT NULL,
|
||||
owner_type VARCHAR(16) NOT NULL COMMENT 'sku_owner | purchaser',
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
spu VARCHAR(64) NULL COMMENT '为空表示部门级默认负责人',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_dept_type_spu_user (department_id, owner_type, spu, user_id),
|
||||
KEY idx_dept (department_id),
|
||||
KEY idx_spu (spu),
|
||||
KEY idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='部门SKU负责人配置'`
|
||||
);
|
||||
|
||||
// 部门标签默认四档(不存在时播种,口径:近 7 天销售件数)
|
||||
const existing = await database.local.query(
|
||||
'SELECT COUNT(*) AS c FROM product_tag_rules WHERE tag_scope = ?',
|
||||
[DEPARTMENT_TAG_SCOPE]
|
||||
);
|
||||
if (Number(existing[0]?.c || 0) === 0) {
|
||||
const seeds = [
|
||||
['G-S', 500, null, 1],
|
||||
['G-A', 200, 500, 2],
|
||||
['G-B', 50, 200, 3],
|
||||
['G-C', 0, 50, 4],
|
||||
];
|
||||
for (const [level, min, max, priority] of seeds) {
|
||||
await database.local.query(
|
||||
`INSERT INTO product_tag_rules
|
||||
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES (?, ?, 'sales_qty_7d', ?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE tag_scope = tag_scope`,
|
||||
[DEPARTMENT_TAG_SCOPE, level, min, max, priority]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 配置读取 ====================
|
||||
|
||||
async function loadDepartmentTagRules() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, tag_level, metric_key, threshold_min, threshold_max, priority, enabled
|
||||
FROM product_tag_rules
|
||||
WHERE tag_scope = ? AND enabled = 1
|
||||
ORDER BY priority ASC, id ASC`,
|
||||
[DEPARTMENT_TAG_SCOPE]
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
tagLevel: row.tag_level,
|
||||
metricKey: row.metric_key,
|
||||
thresholdMin: row.threshold_min === null ? null : Number(row.threshold_min),
|
||||
thresholdMax: row.threshold_max === null ? null : Number(row.threshold_max),
|
||||
priority: Number(row.priority || 0),
|
||||
}));
|
||||
}
|
||||
|
||||
// 公司 SKU 负责人:取自 rpt.V_caterAndOper.oper,**SPU 粒度**(方案第 12 条已确认接受该粒度)
|
||||
async function loadCompanyOwnerIndex() {
|
||||
try {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT spu, oper FROM dbo.V_caterAndOper WHERE oper IS NOT NULL AND oper <> N''`
|
||||
);
|
||||
const index = new Map();
|
||||
rows.forEach((row) => {
|
||||
const spu = String(row.spu || '').trim();
|
||||
const oper = String(row.oper || '').trim();
|
||||
if (spu && oper && !index.has(spu)) index.set(spu, oper);
|
||||
});
|
||||
return index;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载公司SKU负责人失败,该列将留空:', error.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartmentSkuOwners() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT o.department_id, o.owner_type, o.user_id, o.spu, u.name AS user_name, d.name AS department_name
|
||||
FROM department_sku_owners o
|
||||
LEFT JOIN users u ON u.id = o.user_id
|
||||
LEFT JOIN departments d ON d.id = o.department_id`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
departmentId: row.department_id,
|
||||
departmentName: row.department_name,
|
||||
ownerType: row.owner_type,
|
||||
userId: row.user_id,
|
||||
ownerName: row.user_name,
|
||||
spu: row.spu,
|
||||
}));
|
||||
}
|
||||
|
||||
// 部门标签判定:左闭右开 [min, max),按 priority 升序命中即停;未命中返回 null
|
||||
function matchDepartmentTag(rules, salesQty7d) {
|
||||
if (!rules.length) return null;
|
||||
// ⚠️ 无销售记录(null)不等于「销量 0」:Number(null) 是 0,会把无数据行误判成最低档
|
||||
if (salesQty7d === null || salesQty7d === undefined || salesQty7d === '') return null;
|
||||
const value = Number(salesQty7d);
|
||||
if (!Number.isFinite(value)) return null;
|
||||
for (const rule of rules) {
|
||||
const geMin = rule.thresholdMin === null || value >= rule.thresholdMin;
|
||||
const ltMax = rule.thresholdMax === null || value < rule.thresholdMax;
|
||||
if (geMin && ltMax) return rule.tagLevel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== 业务库查询 ====================
|
||||
|
||||
// 可排序字段(排序在内存完成,直接用行字段名)
|
||||
const DERIVED_SORT_FIELDS = new Set([
|
||||
'skuId', 'spu', 'category', 'productName', 'supplier', 'warehouse', 'purchaser', 'saleStatus',
|
||||
'companyOwner', 'companySales7d', 'availableStock', 'stockDays', 'suggestedReplenish',
|
||||
'companyTag', 'department', 'departmentTag', 'departmentOwner',
|
||||
'performance7d', 'performance30d', 'departmentSales7d', 'mainSalesChannel', 'mainSalesOperator',
|
||||
]);
|
||||
|
||||
async function getLatestReportDate() {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(date) AS latestDate FROM ${REPORT_SOURCE_TABLE}`
|
||||
);
|
||||
const latest = rows[0]?.latestDate;
|
||||
if (!latest) {
|
||||
throw new Error('业务库 pm_stockMonitor 无数据,无法确定最新统计日期');
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
// 全量基础行:pm_stockMonitor 主表 + jst_sku 补图片/条码/材质/颜色/尺寸
|
||||
//
|
||||
// ⚠️ 只按统计日过滤、不带任何业务条件——结果会被 buildDataset 缓存,
|
||||
// 业务条件(状态/品类/SPU/SKU/供应商…)与标签过滤都在内存里做,见 applyBaseFilters。
|
||||
async function queryAllReportRows(latestDate) {
|
||||
const params = { latestDate };
|
||||
|
||||
const dataSql = `
|
||||
SELECT
|
||||
m.sku_id AS skuId,
|
||||
m.sku AS skuCode,
|
||||
m.spu AS spu,
|
||||
m.skuName AS productName,
|
||||
m.category AS category,
|
||||
m.supplier AS supplier,
|
||||
m.warehouse AS warehouse,
|
||||
m.purchase AS purchaser,
|
||||
m.area AS area,
|
||||
m.cooperation AS cooperation,
|
||||
m.brand AS brand,
|
||||
m.sku_idStatus AS saleStatus,
|
||||
m.jsStock AS availableStock,
|
||||
m.jsUseDays AS stockDays,
|
||||
m.sevenAvgSale AS companySales7d,
|
||||
m.suggestStock AS suggestedReplenish,
|
||||
m.produceCycle AS produceCycle,
|
||||
m.safetyDays AS safetyDays,
|
||||
s.sku_code AS barcode,
|
||||
s.color AS colorRaw,
|
||||
s.properties_value AS sizeRaw,
|
||||
COALESCE(NULLIF(s.other_10, N''), NULLIF(s.other_2, N'')) AS material,
|
||||
COALESCE(s.pic_big, s.pic) AS imageUrl
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
LEFT JOIN dbo.jst_sku s ON s.sku_id = m.sku_id
|
||||
WHERE m.date = @latestDate
|
||||
`;
|
||||
|
||||
const rows = await cloudDatabase.query(dataSql, params, { requestTimeout: 180000 });
|
||||
|
||||
return {
|
||||
rows: rows.map((row) => ({
|
||||
skuId: row.skuId,
|
||||
skuCode: row.skuCode || row.skuId,
|
||||
spu: row.spu,
|
||||
barcode: row.barcode || row.skuId,
|
||||
imageUrl: row.imageUrl || null,
|
||||
category: row.category,
|
||||
productName: row.productName,
|
||||
material: row.material || null,
|
||||
color: row.colorRaw || null,
|
||||
size: row.sizeRaw || null,
|
||||
saleStatus: row.saleStatus,
|
||||
supplier: row.supplier || null,
|
||||
warehouse: row.warehouse || null,
|
||||
purchaser: row.purchaser || null,
|
||||
area: row.area || null,
|
||||
cooperation: row.cooperation || null,
|
||||
brand: row.brand || null,
|
||||
companySales7d: toNumberOrNull(row.companySales7d),
|
||||
availableStock: toNumberOrNull(row.availableStock),
|
||||
inTransitStock: null, // 待业务提供源表
|
||||
stockDays: toNumberOrNull(row.stockDays),
|
||||
suggestedReplenish: toNumberOrNull(row.suggestedReplenish),
|
||||
produceCycle: toNumberOrNull(row.produceCycle),
|
||||
safetyDays: toNumberOrNull(row.safetyDays),
|
||||
departmentUnshipped: null, // 待业务确认 outOfStock 语义
|
||||
})),
|
||||
latestDate,
|
||||
};
|
||||
}
|
||||
|
||||
function toNumberOrNull(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
// ==================== 汇总统计 ====================
|
||||
|
||||
// 汇总基于「过滤后的最终结果」统计,保证与列表口径一致
|
||||
function buildSummary(rows = []) {
|
||||
let outOfStockCount = 0;
|
||||
let lowStockDaysCount = 0;
|
||||
let totalSuggestedReplenish = 0;
|
||||
let needReplenishCount = 0;
|
||||
|
||||
rows.forEach((row) => {
|
||||
const stock = row.availableStock === null ? 0 : Number(row.availableStock);
|
||||
const days = row.stockDays === null ? 0 : Number(row.stockDays);
|
||||
if (stock <= 0) outOfStockCount += 1;
|
||||
else if (days < 7) lowStockDaysCount += 1;
|
||||
|
||||
// ⚠️ suggestStock 是有符号的净需求(库存过剩为负数),合计只累计正值,避免负负相加失真
|
||||
const replenish = row.suggestedReplenish === null ? 0 : Number(row.suggestedReplenish);
|
||||
if (replenish > 0) {
|
||||
totalSuggestedReplenish += replenish;
|
||||
needReplenishCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
skuCount: rows.length,
|
||||
outOfStockCount,
|
||||
lowStockDaysCount,
|
||||
totalSuggestedReplenish: Math.round(totalSuggestedReplenish),
|
||||
needReplenishCount,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 销售明细聚合(业绩 / 部门 / 主渠道 / 主运营) ====================
|
||||
|
||||
// 取销售明细表的最新统计日
|
||||
async function getLatestSalesDate() {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT MAX(stat_date) AS latestDate FROM ${SALES_DETAIL_TABLE}`
|
||||
);
|
||||
return rows[0]?.latestDate || null;
|
||||
}
|
||||
|
||||
// 按 skuoid 聚合近 7 天:销售额(业绩口径)、部门、主销售渠道、主销售运营
|
||||
async function loadSalesAggregatesBySku() {
|
||||
try {
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return new Map();
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT
|
||||
d.skuoid AS skuoid,
|
||||
SUM(d.sales_amount) AS salesAmount30d,
|
||||
SUM(d.goods_count) AS salesQty30d,
|
||||
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.sales_amount ELSE 0 END) AS salesAmount7d,
|
||||
SUM(CASE WHEN d.stat_date >= DATEADD(day, -@days, @latestDate) THEN d.goods_count ELSE 0 END) AS salesQty7d,
|
||||
MAX(d.profit_center) AS department,
|
||||
MAX(d.shop_dept) AS shopDept
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@perfDays, @latestDate)
|
||||
AND d.skuoid IS NOT NULL AND d.skuoid <> N''
|
||||
GROUP BY d.skuoid`,
|
||||
{ days: SALES_WINDOW_DAYS, perfDays: SALES_PERFORMANCE_WINDOW_DAYS, latestDate },
|
||||
{ requestTimeout: 180000 }
|
||||
);
|
||||
|
||||
const index = new Map();
|
||||
rows.forEach((row) => {
|
||||
const key = String(row.skuoid || '').trim();
|
||||
if (!key) return;
|
||||
index.set(key, {
|
||||
salesAmount7d: toNumberOrNull(row.salesAmount7d),
|
||||
salesQty7d: toNumberOrNull(row.salesQty7d),
|
||||
salesAmount30d: toNumberOrNull(row.salesAmount30d),
|
||||
salesQty30d: toNumberOrNull(row.salesQty30d),
|
||||
department: row.department || row.shopDept || null,
|
||||
});
|
||||
});
|
||||
return index;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载销售明细聚合失败,业绩/部门列将留空:', error.message);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
// 主销售渠道 / 主销售运营:近 7 天销售额最高的渠道与运营(脑图口径)
|
||||
async function loadMainChannelAndOperator(skuoids) {
|
||||
const result = new Map();
|
||||
if (!Array.isArray(skuoids) || !skuoids.length) return result;
|
||||
|
||||
try {
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return result;
|
||||
|
||||
// 仅对本页 SKU 查询,避免全表聚合
|
||||
const list = [...new Set(skuoids.filter(Boolean))].slice(0, MAX_PAGE_SIZE);
|
||||
const params = { days: SALES_WINDOW_DAYS, latestDate };
|
||||
const placeholders = list.map((sku, i) => {
|
||||
params[`sku${i}`] = sku;
|
||||
return `@sku${i}`;
|
||||
});
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT d.skuoid AS skuoid, d.shop_name AS shopName, d.ider AS operator,
|
||||
SUM(d.sales_amount) AS amt
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid IN (${placeholders.join(',')})
|
||||
GROUP BY d.skuoid, d.shop_name, d.ider`,
|
||||
params,
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
const byChannel = new Map();
|
||||
const byOperator = new Map();
|
||||
rows.forEach((row) => {
|
||||
const sku = String(row.skuoid || '').trim();
|
||||
const amt = toNumberOrNull(row.amt) || 0;
|
||||
if (!sku) return;
|
||||
if (row.shopName) {
|
||||
const key = `${sku}||${row.shopName}`;
|
||||
const prev = byChannel.get(key) || 0;
|
||||
byChannel.set(key, prev + amt);
|
||||
}
|
||||
if (row.operator) {
|
||||
const key = `${sku}||${row.operator}`;
|
||||
const prev = byOperator.get(key) || 0;
|
||||
byOperator.set(key, prev + amt);
|
||||
}
|
||||
});
|
||||
|
||||
const pickTop = (map, targetMap) => {
|
||||
map.forEach((amt, key) => {
|
||||
const [sku, name] = key.split('||');
|
||||
const prev = targetMap.get(sku);
|
||||
if (!prev || amt > prev.amt) targetMap.set(sku, { name, amt });
|
||||
});
|
||||
};
|
||||
const topChannel = new Map();
|
||||
const topOperator = new Map();
|
||||
pickTop(byChannel, topChannel);
|
||||
pickTop(byOperator, topOperator);
|
||||
|
||||
list.forEach((sku) => {
|
||||
result.set(sku, {
|
||||
mainSalesChannel: topChannel.get(sku)?.name || null,
|
||||
mainSalesOperator: topOperator.get(sku)?.name || null,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载主渠道/主运营失败,相关列将留空:', error.message);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// 行下钻:该 SKU 归属的运营人员列表 + 各自日销(近 7 天日均发货件数)
|
||||
async function getProductOperators(skuoid) {
|
||||
const sku = String(skuoid || '').trim();
|
||||
if (!sku) return [];
|
||||
|
||||
const latestDate = await getLatestSalesDate();
|
||||
if (!latestDate) return [];
|
||||
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT d.ider AS operator,
|
||||
SUM(d.goods_count) AS qty7d,
|
||||
SUM(d.sales_amount) AS amt7d,
|
||||
MAX(d.shop_name) AS sampleShop
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid = @sku
|
||||
AND d.ider IS NOT NULL AND d.ider <> N''
|
||||
GROUP BY d.ider
|
||||
ORDER BY SUM(d.goods_count) DESC`,
|
||||
{ days: SALES_WINDOW_DAYS, latestDate, sku },
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
// 每位运营的主渠道
|
||||
const channelRows = await cloudDatabase.query(
|
||||
`SELECT d.ider AS operator, d.shop_name AS shopName, SUM(d.sales_amount) AS amt
|
||||
FROM ${SALES_DETAIL_TABLE} d
|
||||
WHERE d.stat_date >= DATEADD(day, -@days, @latestDate)
|
||||
AND d.skuoid = @sku AND d.ider IS NOT NULL AND d.ider <> N''
|
||||
GROUP BY d.ider, d.shop_name`,
|
||||
{ days: SALES_WINDOW_DAYS, latestDate, sku },
|
||||
{ requestTimeout: 120000 }
|
||||
);
|
||||
|
||||
const topChannel = new Map();
|
||||
channelRows.forEach((row) => {
|
||||
const amt = toNumberOrNull(row.amt) || 0;
|
||||
const prev = topChannel.get(row.operator);
|
||||
if (!prev || amt > prev.amt) topChannel.set(row.operator, { name: row.shopName, amt });
|
||||
});
|
||||
|
||||
return rows.map((row) => {
|
||||
const qty7d = toNumberOrNull(row.qty7d) || 0;
|
||||
return {
|
||||
operator: row.operator,
|
||||
dailySales: Number((qty7d / SALES_WINDOW_DAYS).toFixed(2)),
|
||||
salesQty7d: qty7d,
|
||||
salesAmount7d: toNumberOrNull(row.amt7d),
|
||||
mainChannel: topChannel.get(row.operator)?.name || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 筛选选项 ====================
|
||||
|
||||
async function queryFilterOptions(latestDate) {
|
||||
const [dimensionRows, statusRows] = await Promise.all([
|
||||
cloudDatabase.query(
|
||||
`SELECT DISTINCT TOP 300 m.category AS category, m.supplier AS supplier, m.warehouse AS warehouse,
|
||||
m.purchase AS purchaser
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
WHERE m.date = @latestDate`,
|
||||
{ latestDate }
|
||||
),
|
||||
cloudDatabase.query(
|
||||
`SELECT m.sku_idStatus AS status, COUNT(*) AS c
|
||||
FROM ${REPORT_SOURCE_TABLE} m
|
||||
WHERE m.date = @latestDate
|
||||
GROUP BY m.sku_idStatus`,
|
||||
{ latestDate }
|
||||
),
|
||||
]);
|
||||
|
||||
const uniq = (key) => [...new Set(dimensionRows.map((r) => r[key]).filter(Boolean))].sort((a, b) => String(a).localeCompare(String(b), 'zh-CN'));
|
||||
|
||||
return {
|
||||
categories: uniq('category'),
|
||||
suppliers: uniq('supplier'),
|
||||
warehouses: uniq('warehouse'),
|
||||
purchasers: uniq('purchaser'),
|
||||
saleStatuses: SALE_STATUS_OPTIONS.map((status) => ({
|
||||
status,
|
||||
count: Number(statusRows.find((r) => r.status === status)?.c || 0),
|
||||
})),
|
||||
latestDate,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 数据集缓存 ====================
|
||||
//
|
||||
// 实测:mssql 驱动逐行开销约 0.4ms/行,取全量 5.6 万行要 20~30 秒,不能每次请求都跑。
|
||||
// 而「公司标签 / 部门标签 / 部门负责人」依赖系统库配置,无法下推到 SQL,必须拿到全量行再算。
|
||||
// 因此:按统计日缓存一份「已完成派生」的全量数据集,后续请求只在内存里过滤 + 排序 + 分页。
|
||||
// 缓存失效时机:① 统计日变化(日更)② 保存部门标签/负责人配置 ③ 前端带 refresh=1。
|
||||
|
||||
const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
let datasetCache = null;
|
||||
let datasetBuilding = null;
|
||||
|
||||
const dateKeyOf = (value) => (value ? new Date(value).toISOString().slice(0, 10) : '');
|
||||
|
||||
async function buildDataset(latestDate) {
|
||||
const key = dateKeyOf(latestDate);
|
||||
const [baseResult, departmentRules, departmentOwners, companyTagConfig, companyOwnerIndex, salesAggIndex] = await Promise.all([
|
||||
queryAllReportRows(latestDate),
|
||||
loadDepartmentTagRules(),
|
||||
loadDepartmentSkuOwners(),
|
||||
loadCompanyTagResolver(),
|
||||
loadCompanyOwnerIndex(),
|
||||
loadSalesAggregatesBySku(),
|
||||
]);
|
||||
|
||||
const rows = baseResult.rows.map((row) => {
|
||||
const salesAgg = salesAggIndex.get(String(row.skuCode || row.skuId).trim()) || null;
|
||||
|
||||
// 业绩口径 = 近 30 天销售额(对齐 SABC 阈值量级),近 7 天销售额单独留一列展示
|
||||
const performance30d = salesAgg?.salesAmount30d ?? null;
|
||||
const performance7d = salesAgg?.salesAmount7d ?? null;
|
||||
const departmentSalesQty7d = salesAgg?.salesQty7d ?? null;
|
||||
|
||||
const companyTag = resolveCompanyTag(companyTagConfig, row.category, performance30d, row.companySales7d);
|
||||
const departmentTag = matchDepartmentTag(departmentRules, departmentSalesQty7d);
|
||||
const department = salesAgg?.department || null;
|
||||
const owner = resolveDepartmentOwner(departmentOwners, { ...row, department });
|
||||
|
||||
return {
|
||||
...row,
|
||||
companyOwner: companyOwnerIndex.get(String(row.spu || '').trim()) || null,
|
||||
department,
|
||||
// 业绩(销售额)——30 天用于 SABC 判定,7 天用于展示
|
||||
performance7d,
|
||||
performance30d,
|
||||
// 部门近7日日销(件数 / 7)
|
||||
departmentSales7d: departmentSalesQty7d === null
|
||||
? null
|
||||
: Number((departmentSalesQty7d / SALES_WINDOW_DAYS).toFixed(2)),
|
||||
departmentSalesQty7d,
|
||||
// 建议备货:宽表 suggestStock 为有符号净需求,负数代表库存过剩,展示时取 0
|
||||
suggestedReplenish: row.suggestedReplenish === null ? null : Math.max(0, row.suggestedReplenish),
|
||||
companyTag,
|
||||
departmentTag,
|
||||
departmentOwner: owner?.ownerName || null,
|
||||
mainSalesChannel: null,
|
||||
mainSalesOperator: null,
|
||||
};
|
||||
});
|
||||
|
||||
return { key, builtAt: Date.now(), rows };
|
||||
}
|
||||
|
||||
async function getDataset(force = false) {
|
||||
const latestDate = await getLatestReportDate();
|
||||
const key = dateKeyOf(latestDate);
|
||||
|
||||
const fresh = datasetCache && datasetCache.key === key
|
||||
&& (Date.now() - datasetCache.builtAt) < DATASET_CACHE_TTL_MS;
|
||||
if (!force && fresh) return { dataset: datasetCache, latestDate };
|
||||
|
||||
// 并发去重:同一统计日的构建只跑一次
|
||||
if (datasetBuilding && datasetBuilding.key === key) {
|
||||
return { dataset: await datasetBuilding.promise, latestDate };
|
||||
}
|
||||
|
||||
const promise = buildDataset(latestDate)
|
||||
.then((dataset) => {
|
||||
datasetCache = dataset;
|
||||
console.log(`[ProductMonitoring] 数据集构建完成:${key},共 ${dataset.rows.length} 行`);
|
||||
return dataset;
|
||||
})
|
||||
.finally(() => { datasetBuilding = null; });
|
||||
|
||||
datasetBuilding = { key, promise };
|
||||
return { dataset: await promise, latestDate };
|
||||
}
|
||||
|
||||
function invalidateDataset() {
|
||||
datasetCache = null;
|
||||
}
|
||||
|
||||
// 启动预热:不阻塞应用启动,构建失败只记日志(首次请求会重试)
|
||||
async function warmProductMonitoringDataset() {
|
||||
try {
|
||||
await getDataset();
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 数据集预热失败,将在首次请求时重试:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 基础字段过滤(原 SQL WHERE 的等价物,改为内存匹配)
|
||||
function applyBaseFilters(rows = [], query = {}) {
|
||||
const like = (value, keyword) => String(value ?? '').toLowerCase()
|
||||
.includes(String(keyword ?? '').trim().toLowerCase());
|
||||
|
||||
const keywordFields = [
|
||||
['category', 'category'],
|
||||
['spu', 'spu'],
|
||||
['sku', 'skuId'],
|
||||
['productName', 'productName'],
|
||||
['supplier', 'supplier'],
|
||||
['warehouse', 'warehouse'],
|
||||
['purchaser', 'purchaser'],
|
||||
].map(([param, field]) => [field, String(query[param] ?? '').trim()])
|
||||
.filter(([, keyword]) => keyword);
|
||||
|
||||
const saleStatus = String(query.saleStatus ?? '').trim();
|
||||
const includeAll = String(query.includeAll ?? '') === '1';
|
||||
const status = saleStatus || (includeAll ? '' : '销售');
|
||||
|
||||
if (!keywordFields.length && !status) return rows;
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (status && String(row.saleStatus || '') !== status) return false;
|
||||
return keywordFields.every(([field, keyword]) => like(row[field], keyword));
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 主入口 ====================
|
||||
|
||||
async function getMonitoringList(query = {}) {
|
||||
const forceRefresh = String(query.refresh ?? '') === '1';
|
||||
const { dataset, latestDate } = await getDataset(forceRefresh);
|
||||
|
||||
// 1) 基础字段过滤 → 2) 派生字段过滤 → 3) 排序 → 4) 分页
|
||||
const filtered = applyDerivedFilters(applyBaseFilters(dataset.rows, query), query);
|
||||
const sorted = sortRows(filtered, query);
|
||||
|
||||
const total = sorted.length;
|
||||
const page = Math.max(1, Number(query.page) || 1);
|
||||
const pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, Number(query.pageSize) || DEFAULT_PAGE_SIZE));
|
||||
const pageRows = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||||
|
||||
// 主渠道 / 主运营只对本页 SKU 查询,控制代价
|
||||
const mainChannelIndex = await loadMainChannelAndOperator(pageRows.map((row) => row.skuCode || row.skuId));
|
||||
const rows = pageRows.map((row) => {
|
||||
const main = mainChannelIndex.get(String(row.skuCode || row.skuId).trim()) || {};
|
||||
return {
|
||||
...row,
|
||||
mainSalesChannel: main.mainSalesChannel || null,
|
||||
mainSalesOperator: main.mainSalesOperator || null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
latestDate,
|
||||
cachedAt: dataset.builtAt,
|
||||
summary: buildSummary(sorted),
|
||||
};
|
||||
}
|
||||
|
||||
// 派生字段过滤:多选用逗号分隔(companyTag=S,A),文本用包含匹配
|
||||
function applyDerivedFilters(rows = [], query = {}) {
|
||||
const parseList = (value) => String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const companyTags = parseList(query.companyTag);
|
||||
const departmentTags = parseList(query.departmentTag);
|
||||
const departmentKeyword = String(query.department ?? '').trim();
|
||||
const departmentOwnerKeyword = String(query.departmentOwner ?? '').trim().toLowerCase();
|
||||
const companyOwnerKeyword = String(query.companyOwner ?? '').trim().toLowerCase();
|
||||
|
||||
if (!companyTags.length && !departmentTags.length && !departmentKeyword
|
||||
&& !departmentOwnerKeyword && !companyOwnerKeyword) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (companyTags.length && !companyTags.includes(String(row.companyTag || ''))) return false;
|
||||
if (departmentTags.length && !departmentTags.includes(String(row.departmentTag || ''))) return false;
|
||||
if (departmentKeyword && String(row.department || '') !== departmentKeyword) return false;
|
||||
if (departmentOwnerKeyword && !String(row.departmentOwner || '').toLowerCase().includes(departmentOwnerKeyword)) return false;
|
||||
if (companyOwnerKeyword && !String(row.companyOwner || '').toLowerCase().includes(companyOwnerKeyword)) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortRows(rows = [], query = {}) {
|
||||
const field = DERIVED_SORT_FIELDS.has(query.sortField) ? query.sortField : 'companySales7d';
|
||||
const desc = String(query.sortOrder || '').toUpperCase() !== 'ASC';
|
||||
const direction = desc ? -1 : 1;
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = a[field];
|
||||
const bv = b[field];
|
||||
// 空值恒排末尾,避免 null 参与数值/字符串比较造成的抖动
|
||||
if (av === null || av === undefined || av === '') {
|
||||
return bv === null || bv === undefined || bv === '' ? String(a.skuId).localeCompare(String(b.skuId)) : 1;
|
||||
}
|
||||
if (bv === null || bv === undefined || bv === '') return -1;
|
||||
|
||||
const an = Number(av);
|
||||
const bn = Number(bv);
|
||||
const bothNumeric = Number.isFinite(an) && Number.isFinite(bn);
|
||||
const delta = bothNumeric
|
||||
? an - bn
|
||||
: String(av).localeCompare(String(bv), 'zh-CN');
|
||||
|
||||
return (delta || String(a.skuId).localeCompare(String(b.skuId))) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
// 公司 SABC:复用「商品标签配置」的阈值,按 (品类, 业绩, 日销) 判定规模标签
|
||||
async function loadCompanyTagResolver() {
|
||||
try {
|
||||
const config = await productTagConfigService.getProductTagConfig();
|
||||
const thresholds = config?.scaleThresholds || [];
|
||||
const byCategory = new Map();
|
||||
thresholds.forEach((t) => {
|
||||
if (t.enabled === false) return;
|
||||
const key = String(t.category || '');
|
||||
if (!byCategory.has(key)) byCategory.set(key, []);
|
||||
byCategory.get(key).push(t);
|
||||
});
|
||||
return { byCategory, mappings: config?.sabcMappings || [] };
|
||||
} catch (error) {
|
||||
console.warn('[ProductMonitoring] 加载公司标签配置失败,标签列将留空:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 判定:业绩与日销同时命中(左闭右开),按 priority 升序命中即停
|
||||
function resolveCompanyTag(config, category, performance, sales) {
|
||||
if (!config) return null;
|
||||
const ofCategory = config.byCategory.get(String(category || ''));
|
||||
if (!ofCategory || !ofCategory.length) return null;
|
||||
// 业绩缺失时按 0 处理(区间在业务上必然从 0 起),避免整列留空
|
||||
const perf = performance === null || performance === undefined ? 0 : Number(performance);
|
||||
const sale = sales === null || sales === undefined ? 0 : Number(sales);
|
||||
const scaleTag = productTagConfigService.matchScaleTagOfCategory(ofCategory, perf, sale);
|
||||
if (!scaleTag || scaleTag === '滞销款') return 'D';
|
||||
// 利润等级当前无上游数据源,同规模标签下取最优档作为展示值
|
||||
const hit = config.mappings.find((m) => m.scaleTag === scaleTag && m.enabled !== false);
|
||||
return hit?.sabcTag || null;
|
||||
}
|
||||
|
||||
// 部门负责人:优先「部门 + SPU」精确配置 → 「部门 + SPU 为空」的部门级默认配置
|
||||
function resolveDepartmentOwner(owners, row) {
|
||||
if (!owners.length) return null;
|
||||
const deptName = String(row.department || '').trim();
|
||||
const spu = String(row.spu || '').trim();
|
||||
if (!deptName) return null;
|
||||
|
||||
const ofDept = owners.filter((o) => o.ownerType === 'sku_owner' && o.departmentName === deptName);
|
||||
if (!ofDept.length) return null;
|
||||
|
||||
const bySpu = spu ? ofDept.find((o) => String(o.spu || '').trim() === spu) : null;
|
||||
if (bySpu) return bySpu;
|
||||
return ofDept.find((o) => !o.spu) || null;
|
||||
}
|
||||
|
||||
async function getMonitoringFilters() {
|
||||
const latestDate = await getLatestReportDate();
|
||||
const [options, departmentRules] = await Promise.all([
|
||||
queryFilterOptions(latestDate),
|
||||
loadDepartmentTagRules(),
|
||||
]);
|
||||
return {
|
||||
...options,
|
||||
companyTags: ['S', 'A', 'B', 'C', 'D'],
|
||||
departmentTags: DEPARTMENT_TAG_LEVELS,
|
||||
departmentTagRules: departmentRules,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 部门标签配置(系统设置用) ====================
|
||||
|
||||
async function getDepartmentTagConfig() {
|
||||
const [rules, owners, departments, users] = await Promise.all([
|
||||
loadDepartmentTagRules(),
|
||||
loadDepartmentSkuOwners(),
|
||||
database.local.query('SELECT id, name, code, status FROM departments WHERE status = ? ORDER BY sort ASC, id ASC', ['active']),
|
||||
database.local.query('SELECT id, name, username, role FROM users ORDER BY id ASC LIMIT 1000'),
|
||||
]);
|
||||
|
||||
return {
|
||||
rules: DEPARTMENT_TAG_LEVELS.map((level) => {
|
||||
const hit = rules.find((r) => r.tagLevel === level);
|
||||
return {
|
||||
tagLevel: level,
|
||||
metricKey: hit?.metricKey || 'sales_qty_7d',
|
||||
thresholdMin: hit?.thresholdMin ?? null,
|
||||
thresholdMax: hit?.thresholdMax ?? null,
|
||||
priority: hit?.priority ?? (DEPARTMENT_TAG_LEVELS.indexOf(level) + 1),
|
||||
enabled: Boolean(hit),
|
||||
};
|
||||
}),
|
||||
metricKeys: [...DEPARTMENT_METRIC_KEYS],
|
||||
owners,
|
||||
departments: departments.map((d) => ({ id: d.id, name: d.name, code: d.code })),
|
||||
users: users.map((u) => ({ id: u.id, name: u.name, username: u.username, role: u.role })),
|
||||
};
|
||||
}
|
||||
|
||||
async function saveDepartmentTagConfig(payload = {}) {
|
||||
// 标签/负责人配置变了,已缓存的数据集里的派生字段立即失效
|
||||
invalidateDataset();
|
||||
|
||||
const rules = Array.isArray(payload.rules) ? payload.rules : null;
|
||||
const owners = Array.isArray(payload.owners) ? payload.owners : null;
|
||||
|
||||
if (rules) {
|
||||
validateDepartmentRules(rules);
|
||||
await database.local.query('DELETE FROM product_tag_rules WHERE tag_scope = ?', [DEPARTMENT_TAG_SCOPE]);
|
||||
for (const rule of rules) {
|
||||
if (rule.enabled === false) continue;
|
||||
await database.local.query(
|
||||
`INSERT INTO product_tag_rules
|
||||
(tag_scope, tag_level, metric_key, threshold_min, threshold_max, priority, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
metric_key = VALUES(metric_key),
|
||||
threshold_min = VALUES(threshold_min),
|
||||
threshold_max = VALUES(threshold_max),
|
||||
priority = VALUES(priority),
|
||||
enabled = 1`,
|
||||
[
|
||||
DEPARTMENT_TAG_SCOPE,
|
||||
String(rule.tagLevel),
|
||||
DEPARTMENT_METRIC_KEYS.includes(rule.metricKey) ? rule.metricKey : 'sales_qty_7d',
|
||||
toNumberOrNull(rule.thresholdMin),
|
||||
toNumberOrNull(rule.thresholdMax),
|
||||
Number(rule.priority) || 0,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (owners) {
|
||||
validateDepartmentOwners(owners);
|
||||
await database.local.query('DELETE FROM department_sku_owners');
|
||||
for (const owner of owners) {
|
||||
await database.local.query(
|
||||
`INSERT INTO department_sku_owners (department_id, owner_type, user_id, spu)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE department_id = VALUES(department_id)`,
|
||||
[
|
||||
Number(owner.departmentId),
|
||||
owner.ownerType === 'purchaser' ? 'purchaser' : 'sku_owner',
|
||||
Number(owner.userId),
|
||||
owner.spu ? String(owner.spu).trim() : null,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return getDepartmentTagConfig();
|
||||
}
|
||||
|
||||
function validateDepartmentRules(rules) {
|
||||
const seen = new Set();
|
||||
rules.forEach((rule, index) => {
|
||||
const line = index + 1;
|
||||
if (!DEPARTMENT_TAG_LEVELS.includes(String(rule.tagLevel))) {
|
||||
throw new Error(`第 ${line} 行:部门标签必须是 ${DEPARTMENT_TAG_LEVELS.join(' / ')}`);
|
||||
}
|
||||
const min = toNumberOrNull(rule.thresholdMin);
|
||||
const max = toNumberOrNull(rule.thresholdMax);
|
||||
if (min !== null && max !== null && min >= max) {
|
||||
throw new Error(`第 ${line} 行:${rule.tagLevel} 的起点(${min}) 必须小于终点(${max})`);
|
||||
}
|
||||
if (seen.has(rule.tagLevel)) {
|
||||
throw new Error(`第 ${line} 行:部门标签「${rule.tagLevel}」重复`);
|
||||
}
|
||||
seen.add(rule.tagLevel);
|
||||
});
|
||||
}
|
||||
|
||||
function validateDepartmentOwners(owners) {
|
||||
const seen = new Set();
|
||||
owners.forEach((owner, index) => {
|
||||
const line = index + 1;
|
||||
if (!owner.departmentId) throw new Error(`第 ${line} 行:必须选择部门`);
|
||||
if (!owner.userId) throw new Error(`第 ${line} 行:必须选择负责人`);
|
||||
const key = `${owner.departmentId}||${owner.ownerType}||${owner.spu || ''}||${owner.userId}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:部门/负责人/SPU 组合重复`);
|
||||
seen.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureProductMonitoringSchema,
|
||||
getMonitoringList,
|
||||
getMonitoringFilters,
|
||||
getDepartmentTagConfig,
|
||||
saveDepartmentTagConfig,
|
||||
warmProductMonitoringDataset,
|
||||
invalidateDataset,
|
||||
getProductOperators,
|
||||
matchDepartmentTag,
|
||||
DEPARTMENT_TAG_LEVELS,
|
||||
DEPARTMENT_METRIC_KEYS,
|
||||
SALE_STATUS_OPTIONS,
|
||||
};
|
||||
@ -1,521 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// 商品标签配置:规模标签阈值、利润标签阈值、SABC 映射
|
||||
//
|
||||
// 数据来源:《公司SABC标签配置.xlsx》
|
||||
// - 区块① 分类 × 业绩/日销区间 → 规模标签(148 行)
|
||||
// - 区块② 规模标签 × 利润标签 → SABC(25 行)
|
||||
//
|
||||
// 判定口径(2026-09-11 评审确认):
|
||||
// 1. SABC 保留 5 档,含 D
|
||||
// 2. 利润标签是上游传入的输入值,直接查表;营业利润率仅作展示说明,不参与计算
|
||||
// 3. 区间一律「左闭右开」[起点, 终点);规模标签按 priority 升序命中即停
|
||||
// 4. 业绩与日销必须同时命中
|
||||
// 5. 阈值导入时统一取整
|
||||
// 6. 品类为 '-' 的行跳过
|
||||
// 7. 暂不实现标签自动重算
|
||||
|
||||
const database = require('../config/database');
|
||||
|
||||
const SCALE_TAGS = Object.freeze(['爆款', '热卖款', '动销款', '低动销款']);
|
||||
const FALLBACK_SCALE_TAG = '滞销款';
|
||||
const SCALE_TAG_ORDER = Object.freeze([...SCALE_TAGS, FALLBACK_SCALE_TAG]);
|
||||
const PROFIT_LEVELS = Object.freeze(['一级', '二级', '三级', '四级', '五级']);
|
||||
const SABC_TAGS = Object.freeze(['S', 'A', 'B', 'C', 'D']);
|
||||
|
||||
// 区块① 中作为脏数据占位、导入时跳过的品类值
|
||||
const IGNORED_CATEGORIES = Object.freeze(['-', '']);
|
||||
|
||||
// 有意不参与评级的品类(2026-09-11 决策 B)。
|
||||
// 与「未配置」语义不同:这里是明确决定不评级,不是遗漏,前端要能区分展示。
|
||||
// 存在 system_config 里,无需为它单独建表。
|
||||
const EXCLUDED_CATEGORIES_CONFIG_KEY = 'product_tag_excluded_categories';
|
||||
const DEFAULT_EXCLUDED_CATEGORIES = Object.freeze(['其他 - 配件']);
|
||||
|
||||
// 未评级原因
|
||||
const RATING_SKIP_EXCLUDED = 'excluded';
|
||||
const RATING_SKIP_UNCONFIGURED = 'unconfigured';
|
||||
|
||||
async function ensureTable(tableName, createSql) {
|
||||
await database.local.query(createSql);
|
||||
const rows = await database.local.query(
|
||||
`SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = ?
|
||||
LIMIT 1`,
|
||||
[tableName]
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
throw new Error(`Table ${tableName} create failed`);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProductTagConfigSchema() {
|
||||
await ensureTable(
|
||||
'product_scale_thresholds',
|
||||
`CREATE TABLE IF NOT EXISTS product_scale_thresholds (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
category VARCHAR(128) NOT NULL COMMENT 'jst_sku.category 的完整值,如 住宅家具 - 电脑桌',
|
||||
display_name VARCHAR(128) NULL COMMENT '配置表里的短名,如 电脑桌/书桌(仅用于展示与分组)',
|
||||
scale_tag VARCHAR(32) NOT NULL COMMENT '爆款/热卖款/动销款/低动销款',
|
||||
performance_min DECIMAL(18,4) NULL COMMENT '业绩起点(含)',
|
||||
performance_max DECIMAL(18,4) NULL COMMENT '业绩终点(不含)',
|
||||
sales_min DECIMAL(18,4) NULL COMMENT '日销起点(含)',
|
||||
sales_max DECIMAL(18,4) NULL COMMENT '日销终点(不含)',
|
||||
priority INT NOT NULL DEFAULT 0 COMMENT '同品类判定优先级,升序命中即停',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_category_tag (category, scale_tag),
|
||||
KEY idx_category_priority (category, enabled, priority),
|
||||
KEY idx_display_name (display_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模标签阈值配置'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'product_profit_levels',
|
||||
`CREATE TABLE IF NOT EXISTS product_profit_levels (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
level_code VARCHAR(16) NOT NULL COMMENT '一级~五级',
|
||||
profit_rate_max DECIMAL(8,4) NULL COMMENT '营业利润率上限(说明用)',
|
||||
description VARCHAR(128) NULL COMMENT '展示文案,如 营业利润率≤0.18',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_level_code (level_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='利润标签阈值配置'`
|
||||
);
|
||||
|
||||
await ensureTable(
|
||||
'product_sabc_mappings',
|
||||
`CREATE TABLE IF NOT EXISTS product_sabc_mappings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
scale_tag VARCHAR(32) NOT NULL,
|
||||
profit_level VARCHAR(16) NOT NULL,
|
||||
sabc_tag VARCHAR(8) NOT NULL COMMENT 'S/A/B/C/D',
|
||||
sort INT NOT NULL DEFAULT 0,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_scale_profit (scale_tag, profit_level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='规模×利润→SABC 映射'`
|
||||
);
|
||||
}
|
||||
|
||||
function toNullableNumber(value) {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
// 决策 5:只对「计算溢出型」脏数据取整,合法的小数(如日销起点 0.5)必须保留。
|
||||
// 判据:小数位数 > 2 位(如 7916.34311148132、2063.13142776357)→ 取整;
|
||||
// 0.5 / 1.25 这类 ≤2 位小数的正常阈值原样保留。
|
||||
function normalizeThreshold(value) {
|
||||
const num = toNullableNumber(value);
|
||||
if (num === null) return null;
|
||||
const decimals = String(num).split('.')[1];
|
||||
if (decimals && decimals.length > 2) return Math.round(num);
|
||||
return num;
|
||||
}
|
||||
|
||||
function toNumber(value, fallback = 0) {
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : fallback;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value === null || value === undefined ? '' : value).trim();
|
||||
}
|
||||
|
||||
function assertOneOf(value, allowed, label) {
|
||||
if (!allowed.includes(value)) {
|
||||
throw new Error(`${label} 取值非法:${value},允许值 ${allowed.join(' / ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 读取 ====================
|
||||
|
||||
async function loadScaleThresholds() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled
|
||||
FROM product_scale_thresholds
|
||||
ORDER BY category ASC, priority ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
displayName: row.display_name || row.category,
|
||||
scaleTag: row.scale_tag,
|
||||
performanceMin: toNullableNumber(row.performance_min),
|
||||
performanceMax: toNullableNumber(row.performance_max),
|
||||
salesMin: toNullableNumber(row.sales_min),
|
||||
salesMax: toNullableNumber(row.sales_max),
|
||||
priority: toNumber(row.priority),
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadProfitLevels() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, level_code, profit_rate_max, description, sort, enabled
|
||||
FROM product_profit_levels
|
||||
ORDER BY sort ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
levelCode: row.level_code,
|
||||
profitRateMax: toNullableNumber(row.profit_rate_max),
|
||||
description: row.description || '',
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadSabcMappings() {
|
||||
const rows = await database.local.query(
|
||||
`SELECT id, scale_tag, profit_level, sabc_tag, sort, enabled
|
||||
FROM product_sabc_mappings
|
||||
ORDER BY sort ASC, id ASC`
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
scaleTag: row.scale_tag,
|
||||
profitLevel: row.profit_level,
|
||||
sabcTag: row.sabc_tag,
|
||||
sort: toNumber(row.sort),
|
||||
enabled: Boolean(row.enabled),
|
||||
}));
|
||||
}
|
||||
|
||||
async function getProductTagConfig() {
|
||||
const [scaleThresholds, profitLevels, sabcMappings, excludedCategories] = await Promise.all([
|
||||
loadScaleThresholds(),
|
||||
loadProfitLevels(),
|
||||
loadSabcMappings(),
|
||||
loadExcludedCategories(),
|
||||
]);
|
||||
return {
|
||||
scaleThresholds,
|
||||
profitLevels,
|
||||
sabcMappings,
|
||||
excludedCategories,
|
||||
meta: {
|
||||
scaleTags: SCALE_TAG_ORDER,
|
||||
profitLevels: PROFIT_LEVELS,
|
||||
sabcTags: SABC_TAGS,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 排除品类(不参与评级) ====================
|
||||
|
||||
async function loadExcludedCategories() {
|
||||
try {
|
||||
const rows = await database.local.query(
|
||||
'SELECT config_value FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
// 没有配置记录时用默认值(配件不参与评级,决策 B)
|
||||
if (!rows.length || !rows[0].config_value) return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
const parsed = JSON.parse(rows[0].config_value);
|
||||
if (!Array.isArray(parsed)) return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
return parsed.map((item) => normalizeText(item)).filter(Boolean);
|
||||
} catch (_error) {
|
||||
return [...DEFAULT_EXCLUDED_CATEGORIES];
|
||||
}
|
||||
}
|
||||
|
||||
async function saveExcludedCategories(list) {
|
||||
const normalized = [...new Set((list || []).map((item) => normalizeText(item)).filter(Boolean))];
|
||||
const payload = JSON.stringify(normalized);
|
||||
const existing = await database.local.query(
|
||||
'SELECT id FROM system_config WHERE config_key = ? LIMIT 1',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
if (existing.length) {
|
||||
await database.local.query(
|
||||
'UPDATE system_config SET config_value = ?, config_type = ? WHERE config_key = ?',
|
||||
[payload, 'json', EXCLUDED_CATEGORIES_CONFIG_KEY]
|
||||
);
|
||||
} else {
|
||||
await database.local.query(
|
||||
'INSERT INTO system_config (config_key, config_value, config_type) VALUES (?, ?, ?)',
|
||||
[EXCLUDED_CATEGORIES_CONFIG_KEY, payload, 'json']
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// ==================== 校验 ====================
|
||||
|
||||
function validateScaleThresholds(rows) {
|
||||
const seen = new Set();
|
||||
rows.forEach((row, index) => {
|
||||
const line = index + 1;
|
||||
const category = normalizeText(row.category);
|
||||
if (!category) throw new Error(`第 ${line} 行:品类(jst_sku.category 完整值)不能为空`);
|
||||
if (IGNORED_CATEGORIES.includes(category)) throw new Error(`第 ${line} 行:品类 "${category}" 已标记为忽略,不应提交`);
|
||||
assertOneOf(normalizeText(row.scaleTag), SCALE_TAGS, `第 ${line} 行 规模标签`);
|
||||
|
||||
const key = `${category}||${row.scaleTag}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:品类「${category}」的「${row.scaleTag}」重复`);
|
||||
seen.add(key);
|
||||
|
||||
const pMin = toNullableNumber(row.performanceMin);
|
||||
const pMax = toNullableNumber(row.performanceMax);
|
||||
const sMin = toNullableNumber(row.salesMin);
|
||||
const sMax = toNullableNumber(row.salesMax);
|
||||
|
||||
if (pMin !== null && pMax !== null && pMin >= pMax) {
|
||||
throw new Error(`第 ${line} 行:业绩起点(${pMin}) 必须小于 业绩终点(${pMax})`);
|
||||
}
|
||||
if (sMin !== null && sMax !== null && sMin >= sMax) {
|
||||
throw new Error(`第 ${line} 行:日销起点(${sMin}) 必须小于 日销终点(${sMax})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function validateSabcMappings(rows) {
|
||||
const seen = new Set();
|
||||
rows.forEach((row, index) => {
|
||||
const line = index + 1;
|
||||
assertOneOf(normalizeText(row.scaleTag), SCALE_TAG_ORDER, `第 ${line} 行 规模标签`);
|
||||
assertOneOf(normalizeText(row.profitLevel), PROFIT_LEVELS, `第 ${line} 行 利润标签`);
|
||||
assertOneOf(normalizeText(row.sabcTag), SABC_TAGS, `第 ${line} 行 SABC`);
|
||||
|
||||
const key = `${row.scaleTag}||${row.profitLevel}`;
|
||||
if (seen.has(key)) throw new Error(`第 ${line} 行:规模标签「${row.scaleTag}」× 利润标签「${row.profitLevel}」重复`);
|
||||
seen.add(key);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== 保存(整表替换) ====================
|
||||
|
||||
async function replaceScaleThresholds(rows) {
|
||||
validateScaleThresholds(rows);
|
||||
await database.local.query('DELETE FROM product_scale_thresholds');
|
||||
if (!rows.length) return 0;
|
||||
|
||||
const values = [];
|
||||
const placeholders = rows.map((row) => {
|
||||
// priority 由文档顺序推导:爆款=1 热卖款=2 动销款=3 低动销款=4
|
||||
const priority = SCALE_TAGS.indexOf(normalizeText(row.scaleTag)) + 1;
|
||||
values.push(
|
||||
normalizeText(row.category),
|
||||
normalizeText(row.displayName) || normalizeText(row.category),
|
||||
normalizeText(row.scaleTag),
|
||||
normalizeThreshold(row.performanceMin),
|
||||
normalizeThreshold(row.performanceMax),
|
||||
normalizeThreshold(row.salesMin),
|
||||
normalizeThreshold(row.salesMax),
|
||||
priority,
|
||||
toNumber(row.sort, priority),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?,?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_scale_thresholds
|
||||
(category, display_name, scale_tag, performance_min, performance_max,
|
||||
sales_min, sales_max, priority, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async function replaceProfitLevels(rows) {
|
||||
const list = rows && rows.length ? rows : PROFIT_LEVELS.map((levelCode) => ({ levelCode }));
|
||||
list.forEach((row, index) => {
|
||||
assertOneOf(normalizeText(row.levelCode), PROFIT_LEVELS, `第 ${index + 1} 行 利润标签`);
|
||||
});
|
||||
|
||||
const seen = new Set();
|
||||
list.forEach((row, index) => {
|
||||
const code = normalizeText(row.levelCode);
|
||||
if (seen.has(code)) throw new Error(`第 ${index + 1} 行:利润标签「${code}」重复`);
|
||||
seen.add(code);
|
||||
});
|
||||
|
||||
await database.local.query('DELETE FROM product_profit_levels');
|
||||
const values = [];
|
||||
const placeholders = list.map((row, index) => {
|
||||
values.push(
|
||||
normalizeText(row.levelCode),
|
||||
toNullableNumber(row.profitRateMax),
|
||||
normalizeText(row.description) || null,
|
||||
toNumber(row.sort, index + 1),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_profit_levels (level_code, profit_rate_max, description, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return list.length;
|
||||
}
|
||||
|
||||
async function replaceSabcMappings(rows) {
|
||||
validateSabcMappings(rows);
|
||||
await database.local.query('DELETE FROM product_sabc_mappings');
|
||||
if (!rows.length) return 0;
|
||||
|
||||
const values = [];
|
||||
const placeholders = rows.map((row, index) => {
|
||||
values.push(
|
||||
normalizeText(row.scaleTag),
|
||||
normalizeText(row.profitLevel),
|
||||
normalizeText(row.sabcTag),
|
||||
toNumber(row.sort, index + 1),
|
||||
row.enabled === false ? 0 : 1
|
||||
);
|
||||
return '(?,?,?,?,?)';
|
||||
});
|
||||
|
||||
await database.local.query(
|
||||
`INSERT INTO product_sabc_mappings (scale_tag, profit_level, sabc_tag, sort, enabled)
|
||||
VALUES ${placeholders.join(',')}`,
|
||||
values
|
||||
);
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
// 一次性保存三个区块(前端「保存配置」按钮)
|
||||
async function saveProductTagConfig(payload = {}) {
|
||||
const result = {};
|
||||
if (Array.isArray(payload.scaleThresholds)) {
|
||||
result.scaleThresholdCount = await replaceScaleThresholds(payload.scaleThresholds);
|
||||
}
|
||||
if (Array.isArray(payload.profitLevels)) {
|
||||
result.profitLevelCount = await replaceProfitLevels(payload.profitLevels);
|
||||
}
|
||||
if (Array.isArray(payload.sabcMappings)) {
|
||||
result.sabcMappingCount = await replaceSabcMappings(payload.sabcMappings);
|
||||
}
|
||||
if (Array.isArray(payload.excludedCategories)) {
|
||||
result.excludedCategoryCount = (await saveExcludedCategories(payload.excludedCategories)).length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 运行时判定 ====================
|
||||
|
||||
// 决策 3 + 4:左闭右开,业绩与日销同时命中;决策 2:按 priority 升序命中即停
|
||||
//
|
||||
// 重要:必须区分两种「没命中」——
|
||||
// ① 该品类有配置阈值,但数值全部落在区间外 → 真·滞销款(兜底档)
|
||||
// ② 该品类**根本没有配置任何阈值** → 返回 null(未评级),不能算滞销款
|
||||
// 否则未配置的品类(如「其他 - 配件」47 万 SKU)会被整批误判成滞销款。
|
||||
function matchScaleTagOfCategory(thresholdsOfCategory, performance, sales) {
|
||||
const ordered = [...(thresholdsOfCategory || [])]
|
||||
.filter((row) => row.enabled !== false)
|
||||
.sort((a, b) => (a.priority - b.priority) || (a.id - b.id));
|
||||
|
||||
if (!ordered.length) return null;
|
||||
|
||||
for (const row of ordered) {
|
||||
const pIn = (row.performanceMin === null || performance >= row.performanceMin)
|
||||
&& (row.performanceMax === null || performance < row.performanceMax);
|
||||
const sIn = (row.salesMin === null || sales >= row.salesMin)
|
||||
&& (row.salesMax === null || sales < row.salesMax);
|
||||
if (pIn && sIn) return row.scaleTag;
|
||||
}
|
||||
return FALLBACK_SCALE_TAG;
|
||||
}
|
||||
|
||||
function buildScaleThresholdIndex(thresholds) {
|
||||
const index = new Map();
|
||||
(thresholds || []).forEach((row) => {
|
||||
const key = normalizeText(row.category);
|
||||
if (!index.has(key)) index.set(key, []);
|
||||
index.get(key).push(row);
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
function buildSabcIndex(mappings) {
|
||||
const index = new Map();
|
||||
(mappings || []).forEach((row) => {
|
||||
if (row.enabled === false) return;
|
||||
index.set(`${normalizeText(row.scaleTag)}||${normalizeText(row.profitLevel)}`, row.sabcTag);
|
||||
});
|
||||
return index;
|
||||
}
|
||||
|
||||
// 批量解析:items = [{ category, performance, sales, profitLevel }]
|
||||
async function resolveProductTags(items) {
|
||||
const [thresholds, mappings, excluded] = await Promise.all([
|
||||
loadScaleThresholds(),
|
||||
loadSabcMappings(),
|
||||
loadExcludedCategories(),
|
||||
]);
|
||||
const thresholdIndex = buildScaleThresholdIndex(thresholds);
|
||||
const sabcIndex = buildSabcIndex(mappings);
|
||||
const excludedSet = new Set(excluded);
|
||||
|
||||
return (items || []).map((item) => {
|
||||
const category = normalizeText(item.category);
|
||||
|
||||
// 有意排除的品类:直接返回未评级,且标明原因,不与「未配置」混淆
|
||||
if (excludedSet.has(category)) {
|
||||
return {
|
||||
...item,
|
||||
scaleTag: null,
|
||||
sabcTag: null,
|
||||
rated: false,
|
||||
skippedReason: RATING_SKIP_EXCLUDED,
|
||||
};
|
||||
}
|
||||
|
||||
const rows = thresholdIndex.get(category);
|
||||
const scaleTag = matchScaleTagOfCategory(rows, toNullableNumber(item.performance), toNullableNumber(item.sales));
|
||||
const profitLevel = normalizeText(item.profitLevel);
|
||||
const sabcTag = (scaleTag && profitLevel)
|
||||
? (sabcIndex.get(`${scaleTag}||${profitLevel}`) || null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...item,
|
||||
scaleTag,
|
||||
sabcTag,
|
||||
rated: scaleTag !== null,
|
||||
// 该品类没有任何阈值配置 → 未评级(不能算滞销款)
|
||||
skippedReason: scaleTag === null ? RATING_SKIP_UNCONFIGURED : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SCALE_TAGS,
|
||||
SCALE_TAG_ORDER,
|
||||
FALLBACK_SCALE_TAG,
|
||||
PROFIT_LEVELS,
|
||||
SABC_TAGS,
|
||||
IGNORED_CATEGORIES,
|
||||
DEFAULT_EXCLUDED_CATEGORIES,
|
||||
EXCLUDED_CATEGORIES_CONFIG_KEY,
|
||||
RATING_SKIP_EXCLUDED,
|
||||
RATING_SKIP_UNCONFIGURED,
|
||||
ensureProductTagConfigSchema,
|
||||
getProductTagConfig,
|
||||
saveProductTagConfig,
|
||||
loadExcludedCategories,
|
||||
saveExcludedCategories,
|
||||
resolveProductTags,
|
||||
matchScaleTagOfCategory,
|
||||
// 供单元测试或导入脚本复用
|
||||
normalizeThreshold,
|
||||
validateScaleThresholds,
|
||||
validateSabcMappings,
|
||||
};
|
||||
@ -2,12 +2,16 @@ const RETIRED_PAGE_KEYS = [
|
||||
'planning_upload', 'market_insight', 'agent', 'ai_skill_tags',
|
||||
'store_checkup', 'review_analysis', 'customer_service_checkup',
|
||||
'pricing_calculator', 'desktop_client_download', 'ai_buyer_show', 'ai_seeding_video',
|
||||
'product_monitoring', 'ops_b_incentive', 'marketing_events', 'promotion_module', 'product_analysis',
|
||||
];
|
||||
const RETIRED_ENTRY_KEYS = new Set([
|
||||
'market-insight', 'agent', 'ai-skill-tags', 'platform-switch',
|
||||
'store-management', 'store-checkup', 'review-analysis', 'customer-service-checkup',
|
||||
'operation-tools', 'pricing-calculator', 'desktop-client-download', 'ai-buyer-show', 'ai-seeding-video',
|
||||
'desktop-client-releases',
|
||||
'product-monitoring', 'ops-b-incentive',
|
||||
'marketing-management', 'marketing-events', 'promotion-module',
|
||||
'product-management', 'product-analysis',
|
||||
]);
|
||||
const RETIRED_CONFIG_KEYS = [
|
||||
'openclaw_chat_config', 'openclaw_chat_skill_tags',
|
||||
@ -15,6 +19,7 @@ const RETIRED_CONFIG_KEYS = [
|
||||
'cs_quality_ai_config', 'desktop_client_releases',
|
||||
'ai_buyer_show_ark_api_key', 'ai_buyer_show_ark_base_url',
|
||||
'ai_buyer_show_vision_model', 'ai_buyer_show_review_model',
|
||||
'product_tag_excluded_categories',
|
||||
];
|
||||
const RETIRED_DATABASE_CONFIG_NAMES = ['ai_buyer_show_database'];
|
||||
|
||||
@ -48,6 +53,15 @@ async function cleanupRetiredFeatureSettings(db) {
|
||||
await db.query(`DELETE FROM database_configs WHERE name IN (${RETIRED_DATABASE_CONFIG_NAMES.map(() => '?').join(',')})`, RETIRED_DATABASE_CONFIG_NAMES);
|
||||
await db.query('DROP TABLE IF EXISTS pricing_calculator_history');
|
||||
await db.query('DROP TABLE IF EXISTS pricing_calculator_user_prefs');
|
||||
await db.query('DROP TABLE IF EXISTS ops_b_incentive_snapshots');
|
||||
await db.query('DROP TABLE IF EXISTS ops_b_incentive_periods');
|
||||
await db.query('DROP TABLE IF EXISTS product_analysis_user_prefs');
|
||||
await db.query('DROP TABLE IF EXISTS pdd_goods_first_seen');
|
||||
await db.query('DROP TABLE IF EXISTS department_sku_owners');
|
||||
await db.query('DROP TABLE IF EXISTS product_tag_rules');
|
||||
await db.query('DROP TABLE IF EXISTS product_sabc_mappings');
|
||||
await db.query('DROP TABLE IF EXISTS product_profit_levels');
|
||||
await db.query('DROP TABLE IF EXISTS product_scale_thresholds');
|
||||
const rows = await db.query("SELECT config_value FROM system_config WHERE config_key = ?", ['sidebar_navigation']);
|
||||
if (rows.length) {
|
||||
const current = rows[0].config_value;
|
||||
|
||||
@ -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,246 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 需求(yanhui 2026-10-20):
|
||||
* MKH-BOM 列表左侧加一栏「仓储中心24小时发货时效」,下列 11 个仓:
|
||||
* 横林二仓 / 横林三仓 / 横林二仓品牌仓 / 崔桥工厂仓 / 崔桥工厂品牌仓 /
|
||||
* 崔桥工厂半成品仓 / 新苏北仓 / 睢宁仓 / 山东青岗集仓 / 山东品牌仓 / 河南洛宁仓
|
||||
* 从系统库存抓取「已发货订单」与「未发货订单」,按**订单创建时间**算是否及时。
|
||||
* 每天 12:00 核验「当天创建、7:00 前」的订单是否已发货;
|
||||
* 每天 16:00 核验「当天创建、16:00 前」的订单是否已发货。
|
||||
* 展示口径:**只显示超时未发**(业务确认)。
|
||||
*
|
||||
* 业务确认的口径(2026-10-20 AskUserQuestion):
|
||||
* ① 子仓归属主仓 —— **连子仓一起算**(如 横林二仓 含京东云仓/京东自营仓/运营C专用仓 等)
|
||||
* ② 崔桥工厂半成品仓 —— 系统无同名仓,**先留空占位**(不映射到 15718196)
|
||||
* ③ 展示结构 —— **只显示超时未发**
|
||||
*
|
||||
* ── 数据源 ────────────────────────────────────────────────────────────────
|
||||
* 订单:两张表**店铺互斥,必须都读**(见 mkhBomService 文件头)
|
||||
* rpt.jst_Orders —— 京东 / 拼多多 / 抖音 / 得物 / 唯品会
|
||||
* rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688
|
||||
* 字段口径:
|
||||
* · 创建时间:`order_date`(订单创建时间,业务要求按此算时效)
|
||||
* · 发货时间:`send_date`(为空 = 未发货)
|
||||
* · 发货仓: `wms_co_id`(订单**发货仓**,不是 SKU 的归属仓)
|
||||
* ⚠️ 用户原话「从系统库存抓取」—— 本实现取的是**订单维度**的仓库归属,
|
||||
* 因为 jst_inventoryInfo 只有「某 SKU 在某仓有多少件」,没有订单记录。
|
||||
*
|
||||
* ── 仓库归属(按名称包含匹配,最长匹配优先)────────────────────────────────
|
||||
* `rpt.jst_warehouseInfo` 实测(2026-10-20):
|
||||
* 横林二仓 15033302 + 15197138/15301814/15835893/16040225/16132681
|
||||
* 横林三仓 15043561
|
||||
* 横林二仓品牌仓 16132681(**同时命中也属于「横林二仓」,靠最长匹配优先归到品牌仓**)
|
||||
* 崔桥工厂仓 15038426 + 15301815/16040410
|
||||
* 崔桥工厂品牌仓 15870495
|
||||
* 崔桥工厂半成品仓 —— 无同名仓,留空
|
||||
* 新苏北仓 15038347 + 15315031/15301812
|
||||
* 睢宁仓 15032603 + 15051907/15051960/15052203/15301813/15386911
|
||||
* 山东青岗集仓 15137523 + 15283033/15406664/15835935/16040432/16129076
|
||||
* 山东品牌仓 15182705
|
||||
* 河南洛宁仓 15539084
|
||||
* ⟹ 仓库 ID → 主仓 的映射**在启动时从库表读取并缓存**(不用手写 ID 常量,
|
||||
* 避免上游新增子仓后要改代码;新增子仓只要名字里带主仓关键词就会自动归入)。
|
||||
* 缓存 TTL 1 小时,可用 clearWarehouseCache() 手动清。
|
||||
*
|
||||
* ── 时效判定 ──────────────────────────────────────────────────────────────
|
||||
* 截止时刻(每日两轮,取**当前已生效且最晚**的那一轮;未到 12:00 时无截止):
|
||||
* 12:00 → 核验「当天 00:00 ~ 07:00 创建」的订单
|
||||
* 16:00 → 核验「当天 00:00 ~ 16:00 创建」的订单
|
||||
* 判定:
|
||||
* · 超时未发 = `send_date IS NULL` 且 `order_date <= 截止时刻`
|
||||
* · 按时已发 = `send_date IS NOT NULL` 且 `send_date <= 截止时刻` → **不计入超时**
|
||||
* · 已发但超时 = `send_date > 截止时刻` → 也算超时(已发,但没赶上截止)
|
||||
* 业务只要「超时未发」,所以列表只返回 `send_date IS NULL` 的超时单;
|
||||
* 但 summary 里仍给出 `overdueShipped`,避免「已发但超时」被当成没超时。
|
||||
*
|
||||
* ── 时区 ──────────────────────────────────────────────────────────────────
|
||||
* 订单时间列存的是**东八区本地时间**(实测 `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: ['崔桥工厂品牌仓'] },
|
||||
// 系统内无同名仓(最接近的「简尚-崔桥工厂半成品包装项目仓」15718196 是配件仓,
|
||||
// 业务确认先留空占位,不并进来)
|
||||
{ key: 'CQ_WIP', name: '崔桥工厂半成品仓', keywords: [], placeholder: true },
|
||||
{ key: 'XSB', name: '新苏北仓', keywords: ['新苏北'] },
|
||||
{ key: 'SN', name: '睢宁仓', keywords: ['睢宁'] },
|
||||
{ key: 'SD_QGJ', name: '山东青岗集仓', keywords: ['山东青岗集'] },
|
||||
{ key: 'SD_BRAND', name: '山东品牌仓', keywords: ['山东品牌仓'] },
|
||||
{ key: 'HN_LN', name: '河南洛宁仓', keywords: ['河南洛宁'] },
|
||||
]);
|
||||
|
||||
// 「横林三仓」与「横林二仓」不含对方,安全;但「崔桥工厂品牌仓」含「崔桥工厂仓」片段,
|
||||
// 靠关键词长度倒序匹配即可(见 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);
|
||||
|
||||
// ── 时效轮次(业务给定)──────────────────────────────────────────────────
|
||||
// cutoffHour:当天几点截止;windowEndHour:只核验「创建时间不晚于该时刻」的订单
|
||||
const ROUNDS = Object.freeze([
|
||||
{ key: 'NOON', label: '12:00 轮', cutoffHour: 12, windowEndHour: 7 },
|
||||
{ key: 'AFTERNOON', label: '16:00 轮', cutoffHour: 16, windowEndHour: 16 },
|
||||
]);
|
||||
|
||||
// 只排除取消/合并单;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')`;
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 用「关键词最长匹配」把仓库名归到主仓。
|
||||
* @returns {object|null} CENTERS 里的元素;无法归属返回 null
|
||||
*/
|
||||
function resolveCenter(warehouseName) {
|
||||
if (!warehouseName) return null;
|
||||
for (const item of SORTED_KEYWORDS) {
|
||||
if (warehouseName.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 = [];
|
||||
for (const row of rows) {
|
||||
const center = resolveCenter(String(row.name || ''));
|
||||
if (center && byCenter.has(center.key)) {
|
||||
byCenter.get(center.key).push({
|
||||
wmsCoId: Number(row.wms_co_id),
|
||||
warehouseId: Number(row.Id),
|
||||
name: String(row.name || ''),
|
||||
});
|
||||
} else {
|
||||
unmatched.push({ wmsCoId: Number(row.wms_co_id), name: String(row.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 };
|
||||
return list;
|
||||
}
|
||||
|
||||
function clearWarehouseCache() {
|
||||
warehouseCache = { at: 0, rows: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算「当前已生效」的核验轮次。
|
||||
*
|
||||
* 12:00 之前 → **未到核验时间,返回 null**。
|
||||
* 业务明确要求(2026-10-20 确认):12:00 之前不做核验,
|
||||
* 前端显示「未到核验时间」,**不要回填昨天的数据** ——
|
||||
* 昨天已结算的结果留到昨天,拿到今天看容易被当成今天的超时。
|
||||
* 12:00~15:59 → 用 12:00 轮(窗口 00:00~07:00)
|
||||
* 16:00 之后 → 用 16:00 轮(窗口 00:00~16:00,覆盖范围更大)
|
||||
*
|
||||
* @param {Date} now 以**东八区本地时间**为准的 Date(生产/本地机器均为东八区)
|
||||
* @returns {object|null} null 表示当前不在任何核验时段内
|
||||
*/
|
||||
function resolveActiveRound(now = new Date()) {
|
||||
const hour = now.getHours();
|
||||
const minute = now.getMinutes();
|
||||
const minutes = hour * 60 + minute;
|
||||
let active = null;
|
||||
for (const round of ROUNDS) {
|
||||
if (minutes >= round.cutoffHour * 60) active = round;
|
||||
}
|
||||
|
||||
// 12:00 前:首轮核验还没开始,不做判定
|
||||
if (!active) return null;
|
||||
|
||||
const y = now.getFullYear();
|
||||
const m = pad2(now.getMonth() + 1);
|
||||
const d = pad2(now.getDate());
|
||||
return {
|
||||
...active,
|
||||
deadline: `${y}-${m}-${d} ${pad2(active.cutoffHour)}:00:00`,
|
||||
windowStart: `${y}-${m}-${d} 00:00:00`,
|
||||
windowEnd: `${y}-${m}-${d} ${pad2(active.windowEndHour)}:00:00`,
|
||||
date: `${y}-${m}-${d}`,
|
||||
settled: false,
|
||||
isPreviousDay: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取「当天两轮」的完整描述(页面顶部展示用),不论是否已到时间。
|
||||
*/
|
||||
function describeRounds(now = new Date()) {
|
||||
const y = now.getFullYear();
|
||||
const m = pad2(now.getMonth() + 1);
|
||||
const d = pad2(now.getDate());
|
||||
const date = `${y}-${m}-${d}`;
|
||||
return ROUNDS.map((r) => ({
|
||||
key: r.key,
|
||||
label: r.label,
|
||||
date,
|
||||
deadline: `${date} ${pad2(r.cutoffHour)}:00:00`,
|
||||
windowStart: `${date} 00:00:00`,
|
||||
windowEnd: `${date} ${pad2(r.windowEndHour)}:00:00`,
|
||||
active: now.getHours() * 60 + now.getMinutes() >= r.cutoffHour * 60,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CENTERS,
|
||||
ROUNDS,
|
||||
resolveCenter,
|
||||
loadCenterWarehouseMap,
|
||||
clearWarehouseCache,
|
||||
resolveActiveRound,
|
||||
describeRounds,
|
||||
EXCLUDED_STATUS_SQL,
|
||||
toNumber,
|
||||
};
|
||||
@ -1,528 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效 —— 取数/聚合服务(2026-10-20 新增)
|
||||
* ────────────────────────────────────────────────────────────────────────────
|
||||
* 配置与口径定义见 warehouseShipTimelinessRegistry.js(主仓关键词、轮次、时区说明)。
|
||||
*
|
||||
* ── 一次请求做几件事 ──────────────────────────────────────────────────────
|
||||
* ① 读仓库表 → 主仓 → wms_co_id 集合(缓存 1h)
|
||||
* ② 一次 SQL 同时取「超时未发」和「已超时已发」两类,按 wms_co_id 分组
|
||||
* ③ 归并到主仓,返回每仓的 count / orderAmount,以及全量超时明细
|
||||
*
|
||||
* ── SQL 设计要点(踩过的坑)───────────────────────────────────────────────
|
||||
* · 两张订单表**店铺互斥**,用 UNION ALL 合并后一次性分组,不要分别查再在
|
||||
* Node 侧合并 —— 两趟扫描在 rpt 视图上代价翻倍。
|
||||
* · `wms_co_id IN (...)` 直接内联仓库 ID(都是整数,无注入风险),因为仓库
|
||||
* 集合动态生成,用参数化会引入可变数量的 @p0..@pN,反而难维护。
|
||||
* · **不要在 ORDER BY 里对聚合列做算术**(见 MEMORY:会触发灾难计划)。
|
||||
* 这里 SQL 只 GROUP BY 取原始计数/金额,排序与派生字段全在 Node 侧算。
|
||||
* · 时间比较一律用**字面量字符串**(形如 '2026-10-20 16:00:00'),由
|
||||
* resolveActiveRound 在东八区算出;不用 GETDATE() 做相对偏移,避免
|
||||
* 数据库与应用的时区假设不一致。
|
||||
* · 明细行带 `TOP (@limit)`,`@limit` 走参数化,避免界面一次拉几万行。
|
||||
*/
|
||||
|
||||
const cloudDatabase = require('../config/cloudDatabase');
|
||||
const registry = require('./warehouseShipTimelinessRegistry');
|
||||
|
||||
const { CENTERS, EXCLUDED_STATUS_SQL, toNumber } = registry;
|
||||
|
||||
// 明细上限:只用于页面展示,超出部分用 truncated 标记提示
|
||||
const DEFAULT_DETAIL_LIMIT = 500;
|
||||
const MAX_DETAIL_LIMIT = 2000;
|
||||
|
||||
/**
|
||||
* 把 UTC Date / 字符串 统一格式化成 'YYYY-MM-DD HH:mm:ss'(按服务器本地时区)。
|
||||
* mssql 驱动默认把 datetime 解析为 UTC 语义的 Date,这里只做展示,统一成
|
||||
* 与库内一致的东八区墙钟时间。
|
||||
*/
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value.replace('T', ' ').slice(0, 19);
|
||||
const d = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} `
|
||||
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
/** 两个 'YYYY-MM-DD HH:mm:ss' 相差多少分钟(四舍五入) */
|
||||
function diffMinutes(fromText, toText) {
|
||||
const a = new Date(String(fromText).replace(' ', 'T')).getTime();
|
||||
const b = new Date(String(toText).replace(' ', 'T')).getTime();
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return 0;
|
||||
return Math.round((b - a) / 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造两表合并的子查询。
|
||||
*
|
||||
* 🔥 三条必须同时满足(2026-10-20 实测踩坑):
|
||||
* ① `order_date >= @windowStart` —— **必须有下界**。只写 `<= @deadline` 会把
|
||||
* 一年前创建、至今未发的历史单全捞进来(实测 13,553 条,最早 2025-09-01)。
|
||||
* ② `order_date <= @deadline` —— 上界:截止时刻后创建的订单不参与本轮考核。
|
||||
* ③ Split 已在 EXCLUDED_STATUS_SQL 里排掉(拆单父单永不发货)。
|
||||
*
|
||||
* @param {number[]} wmsIds 主仓下所有 wms_co_id
|
||||
* @param {string} deadline 截止时刻字面量
|
||||
* @param {'pending'|'shippedLate'} mode 取哪一类
|
||||
*/
|
||||
function buildUnionSql(wmsIds, deadline, mode) {
|
||||
const idList = wmsIds.join(',');
|
||||
// pending:未发货且窗口内已创建 → 超时未发
|
||||
// shippedLate:已发货但发货时间晚于截止时刻 → 已超时已发
|
||||
const cond = mode === 'pending'
|
||||
? `o.send_date IS NULL`
|
||||
: `o.send_date IS NOT NULL AND o.send_date > @deadline`;
|
||||
const branch = (table) => `
|
||||
SELECT N'${table}' AS src, o.o_id, o.so_id, o.shop_name, o.shop_id,
|
||||
o.order_date, o.send_date, o.status, o.shop_status,
|
||||
o.wms_co_id, o.pay_amount, o.labels
|
||||
FROM dbo.${table} o
|
||||
WHERE o.wms_co_id IN (${idList})
|
||||
AND o.order_date >= @windowStart
|
||||
AND o.order_date <= @deadline
|
||||
AND ${cond}
|
||||
AND ${EXCLUDED_STATUS_SQL}`;
|
||||
return `${branch('jst_Orders')}
|
||||
UNION ALL${branch('jst_qmcust_orderlist')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个主仓的仓库 ID 集合 → 逗号串(用于按主仓分桶时拼 IN 条件)
|
||||
*/
|
||||
function joinIds(ids) {
|
||||
return ids.join(',');
|
||||
}
|
||||
|
||||
/**
|
||||
* 主入口:计算某一天、某一轮次的仓储中心发货时效。
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} [options.round] 'NOON' | 'AFTERNOON',不传则按当前时刻自动判定
|
||||
* @param {Date} [options.now] 仅测试用
|
||||
* @param {number} [options.limit] 明细条数上限
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
async function getShipTimeliness(options = {}) {
|
||||
const now = options.now instanceof Date ? options.now : new Date();
|
||||
const limit = Math.min(
|
||||
Math.max(Number(options.limit) || DEFAULT_DETAIL_LIMIT, 1),
|
||||
MAX_DETAIL_LIMIT
|
||||
);
|
||||
|
||||
// ① 主仓 → 仓库映射
|
||||
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;
|
||||
}
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
||||
round = {
|
||||
...target,
|
||||
date,
|
||||
deadline: `${date} ${pad(target.cutoffHour)}:00:00`,
|
||||
windowStart: `${date} 00:00:00`,
|
||||
windowEnd: `${date} ${pad(target.windowEndHour)}:00:00`,
|
||||
active: now.getHours() * 60 + now.getMinutes() >= target.cutoffHour * 60,
|
||||
};
|
||||
} else {
|
||||
round = registry.resolveActiveRound(now);
|
||||
}
|
||||
|
||||
// 🔥 12:00 前没有任何轮次生效(业务口径:12:00 才开始第一轮核验)。
|
||||
// 这里**直接返回「未到核验时间」**,既不查库、也不回填昨天 ——
|
||||
// 让业务清楚知道「现在还没到看数据的时候」,而不是拿旧数字去对仓库。
|
||||
if (!round) {
|
||||
const centerList = await registry.loadCenterWarehouseMap();
|
||||
const nextRound = rounds.find((r) => r.cutoffHour === 12) || 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 : '',
|
||||
deadline: '',
|
||||
window: null,
|
||||
summary: emptySummary(),
|
||||
centers: buildEmptyCenters(centerList),
|
||||
orders: [],
|
||||
truncated: false,
|
||||
detailLimit: limit,
|
||||
detailDegraded: false,
|
||||
freshness: null,
|
||||
note: '今日 12:00 首轮核验开始后才展示超时数据',
|
||||
};
|
||||
}
|
||||
|
||||
// 没有归属任何主仓的仓库不用查
|
||||
const allWmsIds = [...new Set(
|
||||
centerList.flatMap((c) => c.warehouses.map((w) => w.wmsCoId)).filter((n) => Number.isFinite(n))
|
||||
)];
|
||||
|
||||
if (!allWmsIds.length) {
|
||||
return {
|
||||
generatedAt: formatDateTime(now),
|
||||
date: round.date,
|
||||
round: { key: round.key, label: round.label },
|
||||
isPreviousDay: Boolean(round.isPreviousDay),
|
||||
rounds,
|
||||
deadline: round.deadline,
|
||||
window: { start: round.windowStart, end: round.windowEnd },
|
||||
summary: emptySummary(),
|
||||
centers: buildEmptyCenters(centerList),
|
||||
orders: [],
|
||||
truncated: false,
|
||||
detailLimit: limit,
|
||||
note: '仓库表中未匹配到任何目标主仓,请检查仓库命名',
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 一趟 SQL 取全量分组结果。原实现拆成「超时未发 / 已超时已发 / 窗口总数」
|
||||
// 三条 SQL,在 rpt 视图上三趟全表扫描实测 213 秒;合并成一趟后按
|
||||
// `src + 是否超时` 打标记再 GROUP BY,只扫一次两表。
|
||||
//
|
||||
// 分组维度:主仓 不直接在 SQL 里 GROUP(仓库→主仓的映射在 Node 侧缓存里),
|
||||
// 所以按 wms_co_id 分组,回 Node 侧累加到主仓。
|
||||
//
|
||||
// 🔥 「算不算已发货」的三态判定(2026-10-20 实测新增):
|
||||
// 上游 `send_date` 回写**明显滞后** —— 实测 10-20 当天 700 单里只有 100 单
|
||||
// 有 send_date,且都集中在 08:16~08:44(刚回写的那几单)。若只看 send_date,
|
||||
// 中午 12:00 核验时当天早上的单会**全部被判超时**,页面毫无价值。
|
||||
// 因此引入出库表 `rpt.jst_salesOutboundInfo` 作为佐证:
|
||||
// · 判为「已发」= send_date 有值 **或** 出库表能查到该 o_id
|
||||
// · 判为「按时」= send_date <= 截止 **或** 出库行 io_date <= 截止
|
||||
// ⚠️ 出库表同样滞后(每天 04:00 起才有数据,当天往往 0 行,次日凌晨灌),
|
||||
// 所以它只能**减少误判**(把「已发的判成没发」纠正过来),
|
||||
// 不能凭空补出「没发的单已经发了」。最终仍可能有误报,故返回值里带
|
||||
// `freshness` 让前端明示「数据可能滞后」。
|
||||
// ⚠️ 出库表 `qty` 列全表恒 0(见 MEMORY),判断有无出库**只看行数 / io_date**。
|
||||
//
|
||||
// 每条订单归属哪一类(互斥):
|
||||
// pending —— 两源都查不到发货痕迹 → 超时未发
|
||||
// shippedLate —— 有发货痕迹但发货时间 > 截止 → 已发但超时
|
||||
// onTime —— 发货时间 <= 截止 → 按时已发
|
||||
// ⚠️ OUTER APPLY 里只取 `io_date`,用「io_date 是否为 NULL」判断出库表有无命中。
|
||||
// 别在外面引用 `ob.o_id` —— APPLY 子查询只 SELECT 了 io_date,引用不存在的列
|
||||
// 会报 `Invalid column name 'o_id'`(2026-10-20 已踩)。
|
||||
//
|
||||
// ⚡ 性能关键(2026-10-20 实测):
|
||||
// · 原实现「汇总 SQL + 明细 SQL」各扫一遍窗口,合计 108 秒;
|
||||
// · 窗口内订单只有几百条(实测 420 条),所以改成**一趟取回全部窗口订单明细**,
|
||||
// 汇总与明细都在 Node 里算 —— 少一次大表扫描。
|
||||
// · 返回列加上 pay_amount / status,供 Node 侧分桶。
|
||||
//
|
||||
// 🔥🔥 两表**不能 UNION ALL 成一条 SQL**(2026-10-20 实测,这是本文件最大的坑):
|
||||
// · `jst_qmcust_orderlist` 是**宽视图**(上百列),拆开单独跑很快,但一旦参与
|
||||
// UNION ALL + 派生表 + OUTER APPLY 的嵌套,优化器会放弃谓词下推,
|
||||
// 变成先物化整表再过滤 —— 实测单条 SQL 跑 200~320 秒。
|
||||
// · 两表**分别查、Node 侧合并**:jst_Orders 5.5 秒、jst_qmcust_orderlist 35 秒,
|
||||
// 合计约 40 秒,比合并版快 5~8 倍。
|
||||
// · 两表店铺完全互斥(京东/拼多多/抖音/得物/唯品会 vs 天猫/淘宝/1688),
|
||||
// o_id 不会有交集,直接拼接即可,不需要再按 o_id 去重(仍保留一次去重兜底)。
|
||||
//
|
||||
// 🔥 `jst_qmcust_orderlist` 单表也有 35 秒,而同样条件的 `jst_Orders` 只要 2 秒。
|
||||
// 曾尝试把它的窗口过滤换成 `created`(实测快 10 倍),但**已证伪,不能用**:
|
||||
// · 2026-10-20 实测 2,581 行里,`order_date` 与 `created` 完全相等的只有 4 行,
|
||||
// 相差 >1 小时的有 412 行,最大差 24.7 天,**198 行甚至跨日**;
|
||||
// · 典型形态:`order_date=10-18 23:58` 而 `created=10-19 04:00:08` ——
|
||||
// 上游每天凌晨 4 点批量补录,`created` 是**灌库时间**不是下单时间;
|
||||
// · 同一窗口下 `created` 比 `order_date` 多命中 30 单(全是补录单)。
|
||||
// 业务口径是「按**订单创建时间**算是否及时」,只有 `order_date` 是对的。
|
||||
// 所以:**两表都用 `order_date`**,性能只能靠「两表分开查 + Node 侧合并」。
|
||||
// ⚠️ 换回 `created` 之前必须先跑 `.workbuddy/tmp/probe-od-vs-created.cjs` 复测。
|
||||
const windowBranch = (table) => `
|
||||
SELECT N'${table}' AS src,
|
||||
o.wms_co_id, o.o_id, o.so_id, o.shop_name, o.order_date,
|
||||
o.send_date, o.status, o.shop_status, o.pay_amount, o.labels,
|
||||
ob.io_date AS ob_io_date
|
||||
FROM dbo.${table} o
|
||||
OUTER APPLY (
|
||||
SELECT TOP 1 s.io_date
|
||||
FROM dbo.jst_salesOutboundInfo s
|
||||
WHERE s.o_id = o.o_id
|
||||
ORDER BY s.io_date ASC
|
||||
) ob
|
||||
WHERE o.wms_co_id IN (${joinIds(allWmsIds)})
|
||||
AND o.order_date >= @windowStart
|
||||
AND o.order_date <= @deadline
|
||||
AND ${EXCLUDED_STATUS_SQL}`;
|
||||
|
||||
const params = { deadline: round.deadline, windowStart: round.windowStart };
|
||||
// ⚠️ 超时给足:qmcust 单表实测 35~80 秒(视图谓词下推较差),
|
||||
// 不能按 jst_Orders 的 5 秒去设,否则会被误杀成 500。
|
||||
const queryOpts = { requestTimeout: 300000, connectRequestTimeoutMs: 320000 };
|
||||
|
||||
// 两表并行查(互不依赖),Node 侧合并
|
||||
const [ordersRows, qmcustRows] = await Promise.allSettled([
|
||||
cloudDatabase.query(windowBranch('jst_Orders'), params, queryOpts),
|
||||
cloudDatabase.query(windowBranch('jst_qmcust_orderlist'), params, queryOpts),
|
||||
]);
|
||||
|
||||
// 🔥 用 allSettled 而不是 all:一张表超时/报错时,另一张表的数据仍然可用,
|
||||
// 否则整页变空白。降级情况记录在 detailDegraded + note 里。
|
||||
const mergeWarnings = [];
|
||||
const windowRows = [];
|
||||
if (ordersRows.status === 'fulfilled') {
|
||||
windowRows.push(...(ordersRows.value || []));
|
||||
} else {
|
||||
mergeWarnings.push(`jst_Orders 查询失败:${ordersRows.reason?.message || '未知错误'}`);
|
||||
}
|
||||
if (qmcustRows.status === 'fulfilled') {
|
||||
windowRows.push(...(qmcustRows.value || []));
|
||||
} else {
|
||||
mergeWarnings.push(`jst_qmcust_orderlist 查询失败:${qmcustRows.reason?.message || '未知错误'}`);
|
||||
}
|
||||
if (!windowRows.length && mergeWarnings.length) {
|
||||
// 两表全挂 —— 这才是真正的失败,直接抛给上层
|
||||
const err = new Error(mergeWarnings.join(';'));
|
||||
err.statusCode = 502;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ④ 数据新鲜度:出库表最新 io_date。
|
||||
// ⚠️ 只用于「提示用户数据可能滞后」,不参与超时判定。
|
||||
// 两个源都滞后时(出库表当天 0 行 + send_date 昨天就停了),说明上游还没灌数,
|
||||
// 此时「超时未发」的准确性无法保证 —— 前端必须显式告警,不能让业务拿着
|
||||
// 一份假数据去考核仓库。
|
||||
const freshness = await loadShipFreshness(round);
|
||||
|
||||
// ⑤ 三态归类(在 Node 侧做,SQL 只负责取数)
|
||||
const wmsToCenter = new Map();
|
||||
for (const c of centerList) {
|
||||
for (const w of c.warehouses) wmsToCenter.set(w.wmsCoId, c.key);
|
||||
}
|
||||
|
||||
const acc = new Map(centerList.map((c) => [c.key, {
|
||||
pendingCount: 0,
|
||||
pendingAmount: 0,
|
||||
shippedLateCount: 0,
|
||||
onTimeCount: 0,
|
||||
}]));
|
||||
|
||||
const pendingRows = [];
|
||||
// 两表店铺互斥、o_id 理论不重叠,但历史上出现过同一 o_id 被两边都收录的情况,
|
||||
// 按 `${src}-${o_id}` 去重(跨表同 o_id 也算重复,取先到的 jst_Orders)。
|
||||
const seenKey = new Set();
|
||||
for (const row of windowRows || []) {
|
||||
const bucket = acc.get(wmsToCenter.get(Number(row.wms_co_id)));
|
||||
if (!bucket) continue;
|
||||
const dedupeKey = String(row.o_id || '');
|
||||
if (dedupeKey) {
|
||||
if (seenKey.has(dedupeKey)) continue;
|
||||
seenKey.add(dedupeKey);
|
||||
}
|
||||
const kind = bucketOf(row, round.deadline);
|
||||
if (kind === 'pending') {
|
||||
bucket.pendingCount += 1;
|
||||
bucket.pendingAmount += toNumber(row.pay_amount);
|
||||
pendingRows.push(row);
|
||||
} else if (kind === 'shippedLate') {
|
||||
bucket.shippedLateCount += 1;
|
||||
} else {
|
||||
bucket.onTimeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 明细:只要「超时未发」,最早创建的最急,排前面
|
||||
// 用 formatDateTime 转成 'YYYY-MM-DD HH:mm:ss' 再比字符串 —— 字典序与时间序一致,
|
||||
// 且不受 mssql 驱动把 datetime 解析成 UTC Date 的影响(见文件头时区说明)。
|
||||
pendingRows.sort((a, b) => {
|
||||
const x = formatDateTime(a.order_date);
|
||||
const y = formatDateTime(b.order_date);
|
||||
if (x < y) return -1;
|
||||
if (x > y) return 1;
|
||||
return 0;
|
||||
});
|
||||
const detailRows = pendingRows.slice(0, limit);
|
||||
|
||||
const centers = centerList.map((c) => {
|
||||
const b = acc.get(c.key) || { pendingCount: 0, pendingAmount: 0, shippedLateCount: 0, onTimeCount: 0 };
|
||||
const totalCount = b.pendingCount + b.shippedLateCount + b.onTimeCount;
|
||||
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,
|
||||
overdueUnshippedAmount: Math.round(b.pendingAmount * 100) / 100,
|
||||
// 辅助信息
|
||||
overdueShipped: b.shippedLateCount,
|
||||
windowOrderCount: totalCount,
|
||||
onTimeRate: totalCount > 0
|
||||
? Math.round((b.onTimeCount / totalCount) * 1000) / 10
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
const orders = (detailRows || []).map((r) => {
|
||||
const orderDate = formatDateTime(r.order_date);
|
||||
const center = centerList.find((c) => c.key === wmsToCenter.get(Number(r.wms_co_id)));
|
||||
const warehouse = center && center.warehouses.find((w) => w.wmsCoId === Number(r.wms_co_id));
|
||||
return {
|
||||
oId: String(r.o_id || ''),
|
||||
soId: String(r.so_id || ''),
|
||||
source: r.src === 'jst_qmcust_orderlist' ? 'qmcust' : 'orders',
|
||||
shopName: String(r.shop_name || ''),
|
||||
orderDate,
|
||||
sendDate: formatDateTime(r.send_date),
|
||||
status: String(r.status || ''),
|
||||
shopStatus: String(r.shop_status || ''),
|
||||
wmsCoId: Number(r.wms_co_id),
|
||||
warehouseName: warehouse ? warehouse.name : '',
|
||||
centerKey: center ? center.key : '',
|
||||
centerName: center ? center.name : '',
|
||||
payAmount: toNumber(r.pay_amount),
|
||||
labels: String(r.labels || ''),
|
||||
// 相对截止时刻的超时时长(分钟)
|
||||
overdueMinutes: diffMinutes(orderDate, round.deadline),
|
||||
};
|
||||
});
|
||||
|
||||
const summary = centers.reduce((acc2, c) => {
|
||||
acc2.overdueUnshipped += c.overdueUnshipped;
|
||||
acc2.overdueShipped += c.overdueShipped;
|
||||
acc2.overdueUnshippedAmount += c.overdueUnshippedAmount;
|
||||
acc2.windowOrderCount += c.windowOrderCount;
|
||||
if (c.overdueUnshipped > 0) acc2.overdueCenterCount += 1;
|
||||
return acc2;
|
||||
}, emptySummary());
|
||||
summary.overdueUnshippedAmount = Math.round(summary.overdueUnshippedAmount * 100) / 100;
|
||||
// 按时率统一口径:按时 = 窗口内总数 - 超时未发 - 已发超时
|
||||
// (与各仓算法完全一致,避免两处各算一遍算出不同数字)
|
||||
summary.onTimeRate = summary.windowOrderCount > 0
|
||||
? Math.round(((summary.windowOrderCount - summary.overdueUnshipped - summary.overdueShipped)
|
||||
/ summary.windowOrderCount) * 1000) / 10
|
||||
: null;
|
||||
|
||||
const totalPending = summary.overdueUnshipped;
|
||||
|
||||
return {
|
||||
generatedAt: formatDateTime(now),
|
||||
date: round.date,
|
||||
// ⚠️ 前端要展示「核验 07:00 / 16:00 前」,必须把 windowEnd 一起带出去。
|
||||
// 早先只回了 key/label,前端拿不到 cutoff,页面就渲染成「核验 前」。
|
||||
round: {
|
||||
key: round.key,
|
||||
label: round.label,
|
||||
cutoffHour: round.cutoffHour,
|
||||
windowEnd: round.windowEnd,
|
||||
},
|
||||
// 12:00 前不会走到这里(见上方 notStarted 分支),留着兼容显式指定 round 的调用
|
||||
notStarted: false,
|
||||
isPreviousDay: false,
|
||||
rounds,
|
||||
deadline: round.deadline,
|
||||
window: { start: round.windowStart, end: round.windowEnd },
|
||||
summary,
|
||||
centers,
|
||||
orders,
|
||||
truncated: totalPending > orders.length,
|
||||
detailLimit: limit,
|
||||
// 任一张订单表查询失败 → 数字不完整,前端要显式告警(不能拿残缺数据考核仓库)
|
||||
detailDegraded: mergeWarnings.length > 0,
|
||||
freshness,
|
||||
note: mergeWarnings.length ? `${mergeWarnings.join(';')}(数据不完整)` : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算发货数据新鲜度。
|
||||
*
|
||||
* 🔥 业务上这是**最容易被误判成系统 bug** 的地方(见 MEMORY 入口 1):
|
||||
* 上游 send_date / 出库表都滞后,「超时未发」数量会虚高。
|
||||
* 这里只如实报告滞后程度,不做任何「猜测补数」。
|
||||
*
|
||||
* @returns {Promise<{outboundMaxIo: string, sendDateMax: string, stale: boolean, hint: string}>}
|
||||
*/
|
||||
async function loadShipFreshness(round) {
|
||||
try {
|
||||
const rows = await cloudDatabase.query(
|
||||
`SELECT CONVERT(varchar(19), MAX(io_date), 120) AS outbound_max
|
||||
FROM dbo.jst_salesOutboundInfo`,
|
||||
{},
|
||||
{ requestTimeout: 60000 }
|
||||
);
|
||||
const outboundMaxIo = rows && rows[0] ? String(rows[0].outbound_max || '') : '';
|
||||
// 出库表最新一行早于今天 → 当天发货数据尚未灌入
|
||||
const outboundStale = !outboundMaxIo || outboundMaxIo.slice(0, 10) < round.date;
|
||||
return {
|
||||
outboundMaxIo,
|
||||
stale: outboundStale,
|
||||
hint: outboundStale
|
||||
? `出库表最新数据 ${outboundMaxIo || '暂无'},今天(${round.date})的发货记录尚未灌入,`
|
||||
+ '「超时未发」可能偏多,请以仓库实际发货情况为准'
|
||||
: '',
|
||||
};
|
||||
} catch (e) {
|
||||
// 新鲜度查询失败绝不能影响主结果
|
||||
console.warn('[ShipTimeliness] 取新鲜度失败:', e.message);
|
||||
return { outboundMaxIo: '', stale: true, hint: '发货数据新鲜度未知' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单条订单的三态归类(与服务层口径一一对应):
|
||||
* pending —— send_date 为空 **且** 出库表无命中 → 超时未发
|
||||
* shippedLate —— 有发货痕迹但发货时间晚于截止 → 已发但超时
|
||||
* onTime —— 发货时间不晚于截止 → 按时已发
|
||||
*
|
||||
* 「发货时间」取值优先级:send_date 有值就用 send_date,否则用出库表 io_date。
|
||||
* (两个都滞后时以 send_date 为准,因为它是订单系统自己的回写,语义最直接。)
|
||||
*
|
||||
* @param {object} row 含 send_date / ob_io_date
|
||||
* @param {string} deadline 'YYYY-MM-DD HH:mm:ss'
|
||||
*/
|
||||
function bucketOf(row, deadline) {
|
||||
const sendDate = formatDateTime(row.send_date);
|
||||
const ioDate = formatDateTime(row.ob_io_date);
|
||||
if (!sendDate && !ioDate) return 'pending';
|
||||
const shippedAt = sendDate || ioDate;
|
||||
return shippedAt > deadline ? 'shippedLate' : 'onTime';
|
||||
}
|
||||
|
||||
function emptySummary() {
|
||||
return {
|
||||
overdueUnshipped: 0,
|
||||
overdueShipped: 0,
|
||||
overdueUnshippedAmount: 0,
|
||||
overdueCenterCount: 0,
|
||||
windowOrderCount: 0,
|
||||
onTimeRate: null,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
overdueUnshippedAmount: 0,
|
||||
overdueShipped: 0,
|
||||
windowOrderCount: 0,
|
||||
onTimeRate: null,
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getShipTimeliness,
|
||||
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);
|
||||
});
|
||||
54
backend/test/dashboardRoutes.test.js
Normal file
54
backend/test/dashboardRoutes.test.js
Normal file
@ -0,0 +1,54 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const Module = require('node:module');
|
||||
const express = require('express');
|
||||
|
||||
function loadDashboardRouter() {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(id, parent, ...rest) {
|
||||
if (parent?.filename.endsWith('/api/dashboard.js') && id.endsWith('/jianSunDatabase')) {
|
||||
return {
|
||||
query: async (sql) => {
|
||||
if (sql.includes('SELECT DISTINCT shopName')) return [{ shopName: '测试店铺' }];
|
||||
if (sql.includes('FROM dbo.pdd_DataGoods g')) {
|
||||
return [{
|
||||
statDate: '2026-09-12', latestDate: '2026-09-12', payAmount: 100,
|
||||
buyerCount: 2, visitorCount: 10, goodsPv: 20, payGoodsQty: 3,
|
||||
payOrderCount: 2, favoriteCount: 1, impressionCount: 30,
|
||||
}];
|
||||
}
|
||||
if (sql.includes('FROM dbo.pdd_MallServiceScore')) return [{ shopCount: 1, cstmrServScore: 4.8 }];
|
||||
if (sql.includes('FROM dbo.pdd_ShopStars')) return [{ shopCount: 1, mallStarTomms: 5 }];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
}
|
||||
return originalLoad.call(this, id, parent, ...rest);
|
||||
};
|
||||
try {
|
||||
return require('../src/api/dashboard');
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
}
|
||||
}
|
||||
|
||||
test('dashboard keeps its read-only data endpoints after product analysis is retired', async () => {
|
||||
const app = express();
|
||||
app.use('/api/dashboard', loadDashboardRouter());
|
||||
const server = await new Promise((resolve) => {
|
||||
const listener = app.listen(0, '127.0.0.1', () => resolve(listener));
|
||||
});
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const meta = await (await fetch(`${base}/api/dashboard/meta`)).json();
|
||||
assert.deepEqual(meta.data.shops, ['测试店铺']);
|
||||
|
||||
const summary = await (await fetch(`${base}/api/dashboard/summary?period=day`)).json();
|
||||
assert.equal(summary.success, true);
|
||||
assert.equal(summary.data.latestDate, '2026-09-12');
|
||||
assert.equal(summary.data.metrics.find((item) => item.key === 'payAmount').value, 100);
|
||||
assert.equal((await fetch(`${base}/api/product-analysis/products`)).status, 404);
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@ -92,11 +92,9 @@ test('desktop capabilities follow resolved page permissions', () => {
|
||||
shopManagement: false,
|
||||
execution: false,
|
||||
logs: true,
|
||||
marketingEvents: false,
|
||||
promotion: false,
|
||||
productTest: false,
|
||||
});
|
||||
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).promotion, true);
|
||||
assert.equal(buildDesktopCapabilities(resolvedUser({ role: 'admin', allowedPageKeys: [] })).productTest, true);
|
||||
});
|
||||
|
||||
test('SSO ticket is consumed exactly once and produces a normal web token', async () => {
|
||||
|
||||
@ -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' }
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,194 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
buildAlertSignature,
|
||||
buildMkhReplenishNotification,
|
||||
buildNotifiedSnapshot,
|
||||
chinaDateKey,
|
||||
diffAlertState,
|
||||
formatQty,
|
||||
} = require('../src/jobs/mkhReplenishAlertJob');
|
||||
|
||||
const alert = (skuId, level = '缺货', extra = {}) => ({
|
||||
skuId,
|
||||
warningLevel: level,
|
||||
productName: `商品 ${skuId}`,
|
||||
dailySale: 1,
|
||||
stock: 0,
|
||||
replenishQty: 3,
|
||||
...extra,
|
||||
});
|
||||
|
||||
test('diffAlertState splits fresh / persisted / restored', () => {
|
||||
const state = { notified: { A: { level: '缺货' }, B: { level: '预警' }, Z: { level: '缺货' } } };
|
||||
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B'), alert('C')], state);
|
||||
|
||||
assert.deepEqual(fresh.map((r) => r.skuId), ['C']);
|
||||
assert.deepEqual(persisted.map((r) => r.skuId), ['A', 'B']);
|
||||
// Z 已补货恢复,应从已推送记录里剔除
|
||||
assert.deepEqual(restored, ['Z']);
|
||||
});
|
||||
|
||||
test('diffAlertState treats empty state as all fresh', () => {
|
||||
const { fresh, persisted, restored } = diffAlertState([alert('A'), alert('B')], {});
|
||||
assert.equal(fresh.length, 2);
|
||||
assert.equal(persisted.length, 0);
|
||||
assert.deepEqual(restored, []);
|
||||
});
|
||||
|
||||
test('buildNotifiedSnapshot keeps firstNotifiedAt and drops restored skus', () => {
|
||||
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-01T01:00:00.000Z' } } };
|
||||
const next = buildNotifiedSnapshot([alert('A'), alert('B')], state, '2026-09-17T01:00:00.000Z');
|
||||
|
||||
assert.deepEqual(Object.keys(next).sort(), ['A', 'B']);
|
||||
assert.equal(next.A.firstNotifiedAt, '2026-09-01T01:00:00.000Z');
|
||||
assert.equal(next.A.lastNotifiedAt, '2026-09-17T01:00:00.000Z');
|
||||
assert.equal(next.B.firstNotifiedAt, '2026-09-17T01:00:00.000Z');
|
||||
});
|
||||
|
||||
test('buildNotifiedSnapshot with touch=false does not refresh lastNotifiedAt', () => {
|
||||
const state = { notified: { A: { firstNotifiedAt: '2026-09-01T01:00:00.000Z', lastNotifiedAt: '2026-09-05T01:00:00.000Z' } } };
|
||||
const next = buildNotifiedSnapshot([alert('A')], state, '2026-09-17T01:00:00.000Z', { touch: false });
|
||||
assert.equal(next.A.lastNotifiedAt, '2026-09-05T01:00:00.000Z');
|
||||
});
|
||||
|
||||
test('buildAlertSignature is order independent but content sensitive', () => {
|
||||
const a = alert('A');
|
||||
const b = alert('B', '预警');
|
||||
assert.equal(buildAlertSignature([a, b]), buildAlertSignature([b, a]));
|
||||
assert.notEqual(buildAlertSignature([a, b]), buildAlertSignature([a]));
|
||||
// 等级变化也算变化(缺货 → 预警 应产生新指纹)
|
||||
assert.notEqual(buildAlertSignature([alert('A', '缺货')]), buildAlertSignature([alert('A', '预警')]));
|
||||
});
|
||||
|
||||
test('chinaDateKey renders Beijing date regardless of UTC offset', () => {
|
||||
// 2026-09-16T17:30:00Z === 2026-09-17 01:30 (北京) → 跨日必须算 09-17
|
||||
assert.equal(chinaDateKey(new Date('2026-09-16T17:30:00Z')), '20260917');
|
||||
assert.equal(chinaDateKey(new Date('2026-09-16T01:00:00Z')), '20260916');
|
||||
});
|
||||
|
||||
test('formatQty trims trailing zeros and tolerates bad input', () => {
|
||||
assert.equal(formatQty(3), '3');
|
||||
assert.equal(formatQty(2.9700000001), '2.97');
|
||||
assert.equal(formatQty(0.28), '0.28');
|
||||
assert.equal(formatQty(null), '—');
|
||||
assert.equal(formatQty('abc'), '—');
|
||||
});
|
||||
|
||||
test('notification carries recipient, idempotency key and shortfall details', () => {
|
||||
const fresh = [alert('MKHDNZ0409260700428', '缺货', { dailySale: 2.97, replenishQty: 9 })];
|
||||
const all = [...fresh, alert('MKHDNZ0409260700486', '预警', { dailySale: 2, stock: 1, replenishQty: 5 })];
|
||||
const snapshot = {
|
||||
monitorDate: '2026-09-16',
|
||||
saleWindowDays: 15,
|
||||
replenishDaysThreshold: 3,
|
||||
outOfStockCount: 1,
|
||||
warningOnlyCount: 1,
|
||||
replenishQtyTotal: 14,
|
||||
};
|
||||
|
||||
const payload = buildMkhReplenishNotification({
|
||||
alerts: fresh,
|
||||
allAlerts: all,
|
||||
snapshot,
|
||||
recipientAlias: '苏适',
|
||||
dateKey: '20260917',
|
||||
});
|
||||
|
||||
assert.equal(payload.recipientAlias, '苏适');
|
||||
assert.equal(payload.idempotencyKey, `mkh_replenish_alert:苏适:20260917:${buildAlertSignature(fresh)}:v1`);
|
||||
assert.ok(payload.title.includes('新增 1 个'));
|
||||
assert.ok(payload.title.includes('含 1 个已缺货'));
|
||||
assert.ok(payload.content.includes('MKHDNZ0409260700428'));
|
||||
assert.ok(payload.content.includes('现有库存 0'));
|
||||
assert.ok(payload.content.includes('建议补货 9'));
|
||||
// 汇总用全量口径,不是本批新增
|
||||
assert.ok(payload.content.includes('当前共 **2** 个'));
|
||||
assert.equal(payload.actionTitle, '查看补货清单');
|
||||
assert.ok(payload.content.length <= 4800);
|
||||
});
|
||||
|
||||
test('notification truncates long lists but keeps the totals', () => {
|
||||
const many = Array.from({ length: 40 }, (_, i) => alert(`MKH${String(i).padStart(4, '0')}`));
|
||||
const payload = buildMkhReplenishNotification({
|
||||
alerts: many,
|
||||
allAlerts: many,
|
||||
snapshot: { outOfStockCount: 40, warningOnlyCount: 0, replenishQtyTotal: 120 },
|
||||
recipientAlias: '苏适',
|
||||
dateKey: '20260917',
|
||||
});
|
||||
|
||||
assert.ok(payload.content.includes('仅列出前 20 个'));
|
||||
assert.ok(payload.content.includes('当前共 **40** 个'));
|
||||
assert.equal(payload.content.includes('MKH0039'), false);
|
||||
assert.ok(payload.content.length <= 4800);
|
||||
});
|
||||
|
||||
test('notification flags degraded work-order source', () => {
|
||||
const payload = buildMkhReplenishNotification({
|
||||
alerts: [alert('A')],
|
||||
allAlerts: [alert('A')],
|
||||
snapshot: { outOfStockCount: 1, warningOnlyCount: 0, replenishQtyTotal: 3, workOrderDegraded: true },
|
||||
recipientAlias: '苏适',
|
||||
dateKey: '20260917',
|
||||
});
|
||||
assert.ok(payload.content.includes('加工单数据源暂不可用'));
|
||||
});
|
||||
|
||||
// ── 配件不足(见 mkhBomService 文件头 ⑪)──────────────────────────
|
||||
test('notification spells out 配件不足 and drops the replenish target', () => {
|
||||
const shortage = alert('MKHDNZ0409260600843', '缺货', {
|
||||
dailySale: 0,
|
||||
stock: 0,
|
||||
replenishQty: 1,
|
||||
componentShortage: true,
|
||||
assemblableQty: 0,
|
||||
bottleneckComponents: [
|
||||
{ code: 'DNZ040958N1440V01', name: '白架通用前拉杆', perQty: 1, stock: 0, occupied: 0, available: 0, can: 0 },
|
||||
{ code: 'DNZ041106B01V01', name: '通用小K', perQty: 1, stock: 23, occupied: 0, available: 23, can: 23 },
|
||||
],
|
||||
});
|
||||
|
||||
const payload = buildMkhReplenishNotification({
|
||||
alerts: [shortage],
|
||||
allAlerts: [shortage],
|
||||
snapshot: {
|
||||
outOfStockCount: 1,
|
||||
warningOnlyCount: 0,
|
||||
replenishQtyTotal: 1,
|
||||
componentShortageCount: 1,
|
||||
},
|
||||
recipientAlias: '苏适',
|
||||
dateKey: '20260917',
|
||||
});
|
||||
|
||||
assert.ok(payload.content.includes('配件不足,无法补货'));
|
||||
// 补不出来的商品不再给补货数字,避免业务照着一个补不到的目标去采购
|
||||
assert.ok(payload.content.includes('建议补货 —'));
|
||||
assert.equal(payload.content.includes('建议补货 1'), false);
|
||||
// 只列真正卡住的短板(可支配数 ≤ 0),库存充足的不列
|
||||
assert.ok(payload.content.includes('DNZ040958N1440V01'));
|
||||
assert.equal(payload.content.includes('DNZ041106B01V01'), false);
|
||||
// 汇总段说明本批里有几个配件不足
|
||||
assert.ok(payload.content.includes('因**配件库存不足**'));
|
||||
});
|
||||
|
||||
test('notification flags degraded component stock source', () => {
|
||||
const payload = buildMkhReplenishNotification({
|
||||
alerts: [alert('A')],
|
||||
allAlerts: [alert('A')],
|
||||
snapshot: {
|
||||
outOfStockCount: 1,
|
||||
warningOnlyCount: 0,
|
||||
replenishQtyTotal: 3,
|
||||
componentStockDegraded: true,
|
||||
},
|
||||
recipientAlias: '苏适',
|
||||
dateKey: '20260917',
|
||||
});
|
||||
assert.ok(payload.content.includes('配件库存数据源暂不可用'));
|
||||
assert.ok(payload.content.includes('未做'));
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -12,6 +12,10 @@ test('retired sidebar cleanup is recursive and idempotent', () => {
|
||||
{ key: 'agent' },
|
||||
{ key: 'store-management', children: [{ key: 'store-checkup' }] },
|
||||
{ key: 'operation-tools', children: [{ key: 'pricing-calculator' }] },
|
||||
{ key: 'marketing-management', children: [{ key: 'marketing-events' }] },
|
||||
{ key: 'product-management', children: [{ key: 'product-analysis' }] },
|
||||
{ key: 'product-monitoring' },
|
||||
{ key: 'ops-b-incentive' },
|
||||
{ key: 'manage', children: [{ key: 'ai-skill-tags' }, { key: 'desktop-client-releases' }, { key: 'settings', label: '管理设置' }] },
|
||||
] });
|
||||
const clean = pruneRetiredSidebarConfig(saved);
|
||||
@ -20,6 +24,7 @@ test('retired sidebar cleanup is recursive and idempotent', () => {
|
||||
assert.equal(pruneRetiredSidebarConfig('{invalid'), '{invalid');
|
||||
assert.equal(isRetiredSystemConfigKey('openclaw_chat_skill_tags'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('cs_quality_ai_config'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('product_tag_excluded_categories'), true);
|
||||
assert.equal(isRetiredDatabaseConfigName('ai_buyer_show_database'), true);
|
||||
assert.equal(isRetiredSystemConfigKey('site_name'), false);
|
||||
});
|
||||
@ -28,12 +33,12 @@ test('upgrade removes only retired settings and permission rows', async () => {
|
||||
const calls = [];
|
||||
await cleanupRetiredFeatureSettings({ query: async (sql, params) => {
|
||||
calls.push({ sql, params });
|
||||
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"}]}' }] : [];
|
||||
return sql.startsWith('SELECT') ? [{ config_value: '{"entries":[{"key":"agent"},{"key":"product-analysis"},{"key":"settings"}]}' }] : [];
|
||||
} });
|
||||
const deletes = calls.filter((call) => call.sql.startsWith('DELETE'));
|
||||
assert.equal(deletes.length, 5);
|
||||
assert.ok(deletes.every((call) => call.sql.includes(' WHERE ') && call.params.length > 0));
|
||||
assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 2);
|
||||
assert.equal(calls.filter((call) => call.sql.startsWith('DROP TABLE IF EXISTS')).length, 11);
|
||||
const update = calls.find((call) => call.sql.startsWith('UPDATE'));
|
||||
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'product-analysis' }]);
|
||||
assert.deepEqual(JSON.parse(update.params[0]).entries, [{ key: 'settings' }]);
|
||||
});
|
||||
|
||||
@ -3,14 +3,11 @@ const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
const express = require('express');
|
||||
|
||||
test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => {
|
||||
test('retired analysis endpoints return 404 while retained selection endpoint works', async () => {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function (id, parent, ...rest) {
|
||||
if (/[\\/]api[\\/]planningUpload\.js$/.test(parent?.filename || '')) {
|
||||
if (parent?.filename.endsWith('/api/planningUpload.js')) {
|
||||
if (id.endsWith('/pageAccess')) return { getResolvedUserWithPageAccess: async () => ({ role: 'admin' }) };
|
||||
if (id.endsWith('/opsBIncentiveBoardService')) return {
|
||||
getOpsBIncentiveBoard: async () => ({ rows: [] }), getOpsBIncentiveBatches: async () => [], getOpsBIncentiveDaily: async () => [],
|
||||
};
|
||||
if (id.endsWith('/alibaba1688ProductService')) return { getAlibaba1688Product: async (id) => ({ id }) };
|
||||
if (id.endsWith('/alibaba1688ProductEncodingRegistry')) return { attachEncodingDetail: async (value) => value };
|
||||
}
|
||||
@ -24,10 +21,10 @@ test('retired market endpoints return 404 while retained incentive and calculato
|
||||
const server = await new Promise((resolve) => { const listener = app.listen(0, '127.0.0.1', () => resolve(listener)); });
|
||||
try {
|
||||
const base = `http://127.0.0.1:${server.address().port}/api/planning-upload`;
|
||||
for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans']) {
|
||||
for (const path of ['/analysis/rank-board', '/analysis/market-selection/categories', '/analysis/market-replica/insight', '/analysis/social-market/jd', '/selection-fission/plans', '/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily']) {
|
||||
assert.equal((await fetch(base + path)).status, 404, path);
|
||||
}
|
||||
for (const path of ['/analysis/ops-b-incentive', '/analysis/ops-b-incentive/batches', '/analysis/ops-b-incentive/daily', '/selection-fission/1688-products/123']) {
|
||||
for (const path of ['/selection-fission/1688-products/123']) {
|
||||
const response = await fetch(base + path);
|
||||
assert.equal(response.status, 200, path);
|
||||
assert.equal((await response.json()).success, true);
|
||||
|
||||
@ -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,233 +0,0 @@
|
||||
/**
|
||||
* 仓储中心 24 小时发货时效 —— 口径回归测试(2026-10-20 新增)
|
||||
*
|
||||
* 这个功能最容易出错的是「口径」,不是 SQL 语法。本文件锁定四条关键语义:
|
||||
* ① 主仓归属用**关键词最长匹配** —— 「横林二仓品牌仓」不能被「横林二仓」抢走,
|
||||
* 否则品牌仓的订单会被重复计入两个仓;
|
||||
* ② 核验轮次的时刻选择 —— 12:00 前**不做核验(返回 null)**、12:00~15:59 用
|
||||
* 7:00 窗口、16:00 后用 16:00 窗口;
|
||||
* ③ 状态排除表 —— Cancelled / Merged / Split 必须排除(Split 是拆单父单,
|
||||
* 永不发货,不排除会把一年前的单全算成超时未发);
|
||||
* ④ 服务层分桶算术 —— pending/shippedLate/onTime 三态互斥,按时率不为负。
|
||||
*
|
||||
* ⚠️ 本文件**不连数据库**,纯逻辑测试。
|
||||
*/
|
||||
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 = [
|
||||
['京仓-横林二仓京东云仓', 'HL2'],
|
||||
['简尚-运营C横林二仓专用仓', '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('resolveCenter 不会把无关代发仓误并入(山东/河南是高频前缀)', () => {
|
||||
// 这是最容易翻车的地方:仓库表里有上百个「工厂-山东XX代发仓」,
|
||||
// 如果关键词写成「山东」就会全被并进山东品牌仓/青岗集仓。
|
||||
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, true);
|
||||
assert.deepEqual(c.keywords, []);
|
||||
});
|
||||
|
||||
// ── ② 核验轮次 ──────────────────────────────────────────────────────────
|
||||
const at = (h, m) => new Date(2026, 9, 20, h, m, 0); // 2026-10-20 本机时区(东八区)
|
||||
|
||||
test('12:00 前不做核验,返回 null(不回填昨日数据)', () => {
|
||||
// 业务口径(2026-10-20 确认):中午 12:00 才开始第一轮核验,
|
||||
// 12:00 之前页面显示「未到核验时间」,**不能用昨天的结算结果冒充今天**。
|
||||
assert.equal(registry.resolveActiveRound(at(9, 30)), null);
|
||||
assert.equal(registry.resolveActiveRound(at(11, 59)), null);
|
||||
assert.equal(registry.resolveActiveRound(at(0, 5)), null);
|
||||
});
|
||||
|
||||
test('12:00~15:59 用 12:00 轮,核验窗口是当天 00:00~07:00', () => {
|
||||
const r = registry.resolveActiveRound(at(12, 0));
|
||||
assert.ok(r);
|
||||
assert.equal(r.key, 'NOON');
|
||||
assert.equal(r.deadline, '2026-10-20 12:00:00');
|
||||
assert.equal(r.windowStart, '2026-10-20 00:00:00');
|
||||
assert.equal(r.windowEnd, '2026-10-20 07:00:00');
|
||||
});
|
||||
|
||||
test('16:00 之后用 16:00 轮,核验窗口扩到当天 00:00~16:00', () => {
|
||||
const r = registry.resolveActiveRound(at(16, 0));
|
||||
assert.equal(r.key, 'AFTERNOON');
|
||||
assert.equal(r.deadline, '2026-10-20 16:00:00');
|
||||
assert.equal(r.windowEnd, '2026-10-20 16:00:00');
|
||||
|
||||
// 深夜仍沿用当天 16:00 轮(午夜后又回到新的一天)
|
||||
assert.equal(registry.resolveActiveRound(at(23, 59)).key, 'AFTERNOON');
|
||||
});
|
||||
|
||||
// ── ③ 状态排除 ──────────────────────────────────────────────────────────
|
||||
test('排除 Cancelled / Merged / Split,但保留 Question(仍算待发)', () => {
|
||||
const sql = registry.EXCLUDED_STATUS_SQL;
|
||||
assert.match(sql, /Cancelled/);
|
||||
assert.match(sql, /Merged/);
|
||||
assert.match(sql, /Split/);
|
||||
assert.doesNotMatch(sql, /Question/);
|
||||
});
|
||||
|
||||
// ── ④ 服务层分桶(注入假查询,不连库)──
|
||||
function stubDb(dbPath, realDb) {
|
||||
const wmsId = 15038426;
|
||||
// 重构后服务层是「一趟取回窗口内全部订单,Node 侧分桶」,
|
||||
// 所以 stub 要返回**明细行**而不是聚合行。三态各造几条:
|
||||
// · 超时未发 3 单 / 合计 300
|
||||
// · 已发但超时 1 单(send_date 晚于 16:00)
|
||||
// · 按时 6 单(send_date 早于 16:00)
|
||||
const mk = (sendDate, amount, oId) => ({
|
||||
src: 'jst_Orders',
|
||||
wms_co_id: wmsId,
|
||||
o_id: oId,
|
||||
so_id: String(oId),
|
||||
shop_name: '测试店',
|
||||
order_date: '2026-10-20 01:00:00',
|
||||
send_date: sendDate,
|
||||
status: 'Delivering',
|
||||
shop_status: '',
|
||||
pay_amount: amount,
|
||||
labels: '',
|
||||
ob_io_date: null,
|
||||
});
|
||||
|
||||
const stubRows = [
|
||||
mk(null, 100, 1), mk(null, 100, 2), mk(null, 100, 3),
|
||||
mk('2026-10-20 18:30:00', 50, 4),
|
||||
mk('2026-10-20 09:00:00', 100, 5), mk('2026-10-20 09:00:00', 100, 6),
|
||||
mk('2026-10-20 10:00:00', 100, 7), mk('2026-10-20 10:00:00', 100, 8),
|
||||
mk('2026-10-20 11:00:00', 100, 9), mk('2026-10-20 11:00:00', 100, 10),
|
||||
];
|
||||
|
||||
// 记录业务表被查了几次 —— 12:00 前应当为 0
|
||||
// ⚠️ 现在是**两表并行分别查**(jst_Orders + jst_qmcust_orderlist),
|
||||
// 所以两表都返回同一批 stubRows;服务层按 o_id 去重后仍应算 10 条,
|
||||
// 这也顺带验证了「跨表同 o_id 去重」逻辑。
|
||||
const calls = { order: 0 };
|
||||
require.cache[dbPath] = {
|
||||
id: dbPath,
|
||||
filename: dbPath,
|
||||
loaded: true,
|
||||
exports: {
|
||||
query: async (sql) => {
|
||||
if (/jst_warehouseInfo/u.test(sql)) {
|
||||
return [{ Id: 1, name: '简尚-崔桥工厂仓', wms_co_id: wmsId }];
|
||||
}
|
||||
if (/jst_salesOutboundInfo/u.test(sql) && /MAX\(io_date\)/u.test(sql)) {
|
||||
// 新鲜度查询:返回空,走 stale 分支也不影响主结果
|
||||
return [{ outbound_max: '2026-10-19 23:00:00' }];
|
||||
}
|
||||
calls.order += 1;
|
||||
return stubRows;
|
||||
},
|
||||
},
|
||||
};
|
||||
void realDb;
|
||||
return { calls, wmsId };
|
||||
}
|
||||
|
||||
test('服务层三态分桶互斥,按时率不为负', async () => {
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
const realDb = require.cache[dbPath];
|
||||
stubDb(dbPath, realDb);
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||
|
||||
try {
|
||||
const svc = require('../src/services/warehouseShipTimelinessService');
|
||||
const data = await svc.getShipTimeliness({ now: at(16, 5), limit: 10 });
|
||||
const c = data.centers.find((x) => x.key === 'CQ');
|
||||
assert.ok(c, '应能归到崔桥工厂仓');
|
||||
assert.equal(c.overdueUnshipped, 3);
|
||||
assert.equal(c.overdueShipped, 1);
|
||||
assert.equal(c.windowOrderCount, 10, '总数 = pending + shippedLate + onTime');
|
||||
assert.equal(c.onTimeRate, 60, '按时 6/10 = 60%(跨表重复 o_id 必须去重,否则总数会翻倍)');
|
||||
|
||||
// 汇总口径与各仓一致,且按时率不会是负数
|
||||
assert.equal(data.summary.overdueUnshipped, 3);
|
||||
assert.equal(data.summary.windowOrderCount, 10);
|
||||
assert.equal(data.summary.onTimeRate, 60);
|
||||
assert.ok(data.summary.onTimeRate >= 0);
|
||||
|
||||
// 前端要展示「核验 16:00 前」,round 必须带出 windowEnd ——
|
||||
// 只回 key/label 的话页面会渲染成「核验 前」(2026-10-20 真渲染时抓到)
|
||||
assert.ok(data.round, 'round 不能为空');
|
||||
assert.equal(data.round.key, 'AFTERNOON');
|
||||
assert.equal(data.round.cutoffHour, 16);
|
||||
assert.match(String(data.round.windowEnd), /16:00:00$/, 'round.windowEnd 必须带出截止时刻');
|
||||
} 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('服务层 12:00 前返回 notStarted,且不去查订单表', async () => {
|
||||
const dbPath = require.resolve('../src/config/cloudDatabase');
|
||||
const realDb = require.cache[dbPath];
|
||||
const { calls } = stubDb(dbPath, realDb);
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessService')];
|
||||
delete require.cache[require.resolve('../src/services/warehouseShipTimelinessRegistry')];
|
||||
|
||||
try {
|
||||
const svc = require('../src/services/warehouseShipTimelinessService');
|
||||
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((c) => c.overdueUnshipped === 0));
|
||||
assert.equal(calls.order, 0, '未到核验时间不应扫订单表');
|
||||
assert.match(data.note, /12:00/);
|
||||
// 下一轮核验时刻要告诉前端,方便提示「12:00 开始」
|
||||
assert.equal(data.nextRoundAt, '2026-10-20 12:00:00');
|
||||
} 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')];
|
||||
}
|
||||
});
|
||||
@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
FRONTEND_DIR="$REPO_DIR/frontend"
|
||||
SOURCE_GIT_FULL_SHA="$(cd "$REPO_DIR" && git rev-parse HEAD)"
|
||||
SOURCE_GIT_SHA="$(cd "$REPO_DIR" && git rev-parse --short HEAD)"
|
||||
SOURCE_GIT_REF="$(cd "$REPO_DIR" && git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
|
||||
SOURCE_GIT_REF="${SOURCE_GIT_REF:-main}"
|
||||
|
||||
test -f "$FRONTEND_DIR/package-lock.json" || {
|
||||
echo 'frontend/package-lock.json 不存在,停止构建' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
docker run --rm \
|
||||
-e "SOURCE_GIT_SHA=$SOURCE_GIT_SHA" \
|
||||
-e "SOURCE_GIT_FULL_SHA=$SOURCE_GIT_FULL_SHA" \
|
||||
-e "SOURCE_GIT_REF=$SOURCE_GIT_REF" \
|
||||
-v "$FRONTEND_DIR:/host-frontend" \
|
||||
node:22-bookworm-slim \
|
||||
sh -lc '
|
||||
set -eu
|
||||
mkdir -p /workspace
|
||||
tar -C /host-frontend --exclude=node_modules --exclude=dist -cf - . | tar -C /workspace -xf -
|
||||
cd /workspace
|
||||
npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
mkdir -p /host-frontend/dist
|
||||
find /host-frontend/dist -mindepth 1 -delete
|
||||
cp -a /workspace/dist/. /host-frontend/dist/
|
||||
'
|
||||
|
||||
node - "$FRONTEND_DIR/dist/__build_version__.json" "$SOURCE_GIT_FULL_SHA" <<'NODE'
|
||||
const fs = require('node:fs');
|
||||
const [versionPath, expectedSha] = process.argv.slice(2);
|
||||
const metadata = JSON.parse(fs.readFileSync(versionPath, 'utf8'));
|
||||
if (metadata.sourceGitFullSha !== expectedSha) {
|
||||
throw new Error(`前端构建 SHA 不匹配:${metadata.sourceGitFullSha} != ${expectedSha}`);
|
||||
}
|
||||
process.stdout.write(`前端构建已验证:${metadata.version}\n`);
|
||||
NODE
|
||||
@ -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 列即可接入。
|
||||
|
||||
## 验收标准
|
||||
|
||||
补表后,任一业务日应满足行内恒等式:
|
||||
|
||||
```
|
||||
期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入
|
||||
− 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库
|
||||
```
|
||||
|
||||
页面会以该恒等式做逐仓自校验,全部通过后才会撤下「暂缺单据来源」的提示。
|
||||
1034
frontend/package-lock.json
generated
1034
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -37,6 +37,9 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"vite": "7.2.7"
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:rolldown-vite@7.2.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -143,18 +143,6 @@ export const testSelectionImageGenerationSettings = () => {
|
||||
|
||||
|
||||
|
||||
// 商品标签配置(规模标签 / 利润标签 / SABC 映射)
|
||||
export const getProductTagConfig = () => {
|
||||
return request.get('/api/admin/product-tag-config');
|
||||
};
|
||||
|
||||
export const updateProductTagConfig = (data) => {
|
||||
return request.put('/api/admin/product-tag-config', data);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
export const getAuthConfig = () => {
|
||||
return request.get('/api/admin/auth-config');
|
||||
};
|
||||
|
||||
@ -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 } });
|
||||
16
frontend/src/api/dashboard.js
Normal file
16
frontend/src/api/dashboard.js
Normal file
@ -0,0 +1,16 @@
|
||||
import request from './request';
|
||||
|
||||
let dashboardMetaInFlight = null;
|
||||
|
||||
export const getDashboardMeta = () => {
|
||||
if (!dashboardMetaInFlight) {
|
||||
dashboardMetaInFlight = request.get('/api/dashboard/meta').finally(() => {
|
||||
dashboardMetaInFlight = null;
|
||||
});
|
||||
}
|
||||
return dashboardMetaInFlight;
|
||||
};
|
||||
|
||||
export const getDashboardSummary = (params, config = {}) => (
|
||||
request.get('/api/dashboard/summary', { ...config, params })
|
||||
);
|
||||
@ -1,17 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
export const getMarketingEventsList = (params) => (
|
||||
request.get('/api/marketing-events/list', { params })
|
||||
);
|
||||
|
||||
export const getMarketingEventsLatestDate = () => (
|
||||
request.get('/api/marketing-events/latest-date')
|
||||
);
|
||||
|
||||
export const getMarketingEventsSummary = (params) => (
|
||||
request.get('/api/marketing-events/summary', { params })
|
||||
);
|
||||
|
||||
export const getMarketingEventsStatusCounts = () => (
|
||||
request.get('/api/marketing-events/status-counts')
|
||||
);
|
||||
@ -1,58 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const DETAIL_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
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' },
|
||||
}
|
||||
);
|
||||
|
||||
// 单个 MKH 组合商品的 BOM 配件明细(列表展开 / 打印预览共用)
|
||||
export const getMkhBomDetail = (skuId) => request.get(
|
||||
'/api/mkh-bom/detail',
|
||||
{ params: { skuId }, timeout: DETAIL_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 导出:当前筛选条件下的全部数据(不分页)
|
||||
// mode=products → 商品清单;mode=components → 配件明细摊平
|
||||
// ⚠️ 「全部」范围下官方 BOM 明细要分批拉取,耗时明显高于列表接口,故单独放大超时
|
||||
export const getMkhBomExportRows = (params = {}) => request.get(
|
||||
'/api/mkh-bom/export',
|
||||
{ params: { ...params, live: 1 }, timeout: EXPORT_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 补货预警推送设置(接收人 / 开关 / 推送范围)
|
||||
export const getMkhReplenishAlertConfig = () => request.get('/api/mkh-bom/alert-config');
|
||||
|
||||
// 保存补货预警推送设置
|
||||
export const saveMkhReplenishAlertConfig = (payload = {}) => request.put(
|
||||
'/api/mkh-bom/alert-config',
|
||||
payload
|
||||
);
|
||||
|
||||
// 立即检查并推送(dryRun 只预览 / force 忽略「只推新增」/ reset 先清空已推送记录)
|
||||
export const checkMkhReplenishAlert = (payload = {}) => request.post(
|
||||
'/api/mkh-bom/alert-check',
|
||||
payload,
|
||||
{ timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 保存统一通知中心凭据(补货预警的推送通道)
|
||||
export const saveMkhNotificationCenter = (payload = {}) => request.put(
|
||||
'/api/mkh-bom/notification-center',
|
||||
payload
|
||||
);
|
||||
|
||||
// 手动同步:服务端清空全部 MKH 缓存,并在同一次请求中返回当前筛选条件的数据库新快照
|
||||
export const syncMkhBom = (query = {}) => request.post(
|
||||
'/api/mkh-bom/sync',
|
||||
{ query },
|
||||
{ timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
@ -1,29 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const META_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
// 配件库存监控明细(默认仅「简尚-崔桥工厂半成品包装项目仓」)
|
||||
export const getModularStockList = (params = {}) => request.get(
|
||||
'/api/modular-stock-monitor/list',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 筛选下拉选项(品类 / 预警等级)
|
||||
export const getModularStockFilters = () => request.get(
|
||||
'/api/modular-stock-monitor/filters',
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 采购在途明细列表(目标仓全部「采购数量 > 已入库数量」的采购单明细)
|
||||
export const getPurchaseInTransitList = (params = {}) => request.get(
|
||||
'/api/modular-stock-monitor/purchase-in-transit',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 手动同步:后端重新取最新监控日,前端随后再拉一次列表即拿到最新快照
|
||||
export const syncModularStock = () => request.post(
|
||||
'/api/modular-stock-monitor/sync',
|
||||
null,
|
||||
{ timeout: META_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 }
|
||||
);
|
||||
@ -2,21 +2,6 @@ import request from './request';
|
||||
|
||||
const PLANNING_TABLE_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
|
||||
export const getOpsBIncentiveBatches = () => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive/batches',
|
||||
{ timeout: 60 * 1000 }
|
||||
);
|
||||
|
||||
export const getOpsBIncentiveBoard = (params = {}) => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive',
|
||||
{ params, timeout: PLANNING_TABLE_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const getOpsBIncentiveDaily = (params = {}) => request.get(
|
||||
'/api/planning-upload/analysis/ops-b-incentive/daily',
|
||||
{ params, timeout: PLANNING_TABLE_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const getPlanningAlibaba1688ProductDetail = (productId) => {
|
||||
return request.get(`/api/planning-upload/selection-fission/1688-products/${productId}`, {
|
||||
headers: {
|
||||
|
||||
@ -1,42 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
let productAnalysisMetaInFlight = null;
|
||||
const productAnalysisProductsInFlight = new Map();
|
||||
|
||||
export const getProductAnalysisMeta = () => {
|
||||
if (!productAnalysisMetaInFlight) {
|
||||
productAnalysisMetaInFlight = request.get('/api/product-analysis/meta')
|
||||
.finally(() => {
|
||||
productAnalysisMetaInFlight = null;
|
||||
});
|
||||
}
|
||||
return productAnalysisMetaInFlight;
|
||||
};
|
||||
|
||||
export const getProductAnalysisSignalThresholds = () => (
|
||||
request.get('/api/product-analysis/signal-thresholds', { silentError: true })
|
||||
);
|
||||
|
||||
export const saveProductAnalysisSignalThresholds = (thresholds) => (
|
||||
request.put('/api/product-analysis/signal-thresholds', { thresholds }, { silentError: true })
|
||||
);
|
||||
|
||||
export const getDevelopmentDashboard = (params, config = {}) => (
|
||||
request.get('/api/product-analysis/dashboard', { ...config, params })
|
||||
);
|
||||
|
||||
export const getProductAnalysisProducts = (params) => {
|
||||
const requestKey = JSON.stringify(params || {});
|
||||
if (!productAnalysisProductsInFlight.has(requestKey)) {
|
||||
const inFlight = request.get('/api/product-analysis/products', { params })
|
||||
.finally(() => {
|
||||
productAnalysisProductsInFlight.delete(requestKey);
|
||||
});
|
||||
productAnalysisProductsInFlight.set(requestKey, inFlight);
|
||||
}
|
||||
return productAnalysisProductsInFlight.get(requestKey);
|
||||
};
|
||||
|
||||
export const getProductAnalysisTab = (goodsId, tabKey, params) => (
|
||||
request.get(`/api/product-analysis/products/${encodeURIComponent(goodsId)}/tabs/${tabKey}`, { params })
|
||||
);
|
||||
@ -1,34 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
const LIST_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
const META_TIMEOUT_MS = 60 * 1000;
|
||||
|
||||
// 明细列表(公司标签 / 部门标签 / 负责人 / 汇总)
|
||||
export const getProductMonitoringList = (params = {}) => request.get(
|
||||
'/api/product-monitoring/list',
|
||||
{ params, timeout: LIST_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 筛选下拉选项(品类 / 供应商 / 仓库 / 采购负责人 / 销售状态)
|
||||
export const getProductMonitoringFilters = () => request.get(
|
||||
'/api/product-monitoring/filters',
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 行下钻:该 SKU 的运营人员明细(运营 / 日销 / 主渠道)
|
||||
export const getProductMonitoringOperators = (sku) => request.get(
|
||||
'/api/product-monitoring/operators',
|
||||
{ params: { sku }, timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
// 部门标签(G-S/G-A/G-B/G-C)+ 部门 SKU 负责人配置
|
||||
export const getDepartmentTagConfig = () => request.get(
|
||||
'/api/product-monitoring/department-config',
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
|
||||
export const saveDepartmentTagConfig = (payload = {}) => request.put(
|
||||
'/api/product-monitoring/department-config',
|
||||
payload,
|
||||
{ timeout: META_TIMEOUT_MS }
|
||||
);
|
||||
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