Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

155 changed files with 901 additions and 36936 deletions

9
.gitignore vendored
View File

@ -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-*/

View File

@ -6,7 +6,7 @@
- 保留他人的未提交修改和已有提交,仅暂存本任务内容;不得清理或覆盖未知工作。
- 推送前同步远端,逐项检查最终差异并验证;被拒绝后先查明并合并更新,禁止任何形式的强推。
- 不改写已发布历史,不批量选择冲突的某一方;不明确的行为取舍需要负责人确认。
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;默认由非提交者审核。仓库所有者或管理员明确授权某个 PR 合并时,该书面授权可以代替独立审核,但仍必须同步最新主分支、验证最终提交并保留授权记录。
- 所有变更通过 PR 进入 `main`,不得直接推送主分支;独立审核与最新内容的验证通过后由维护者合并。
- 不提交凭据、私钥、环境秘密或真实业务数据;不把合并授权当成生产部署授权。
- 新建或修改数据表、映射表、查询和同步任务时,必须按实际过滤、关联、排序和唯一性设计索引,用执行计划和有代表性数据验证负载;禁止无边界全表扫描、无节制并发和重复全量同步。
- 开发机器使用中央系统数据库和公网 TLS Redis,无需安装本地数据库;生产部署必须先将代码通过 PR 合入 `main`,并在 `/opt/cangku/deploy.lock` 锁内从服务器 Git 工作区快进拉取和验证。

View File

@ -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` 到后端。
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。上架测算旧偏好表中的商品分析阈值会先迁移到商品分析专用表;其他保留模块的数据不受影响。迁移仅作用于实际配置给此实例的系统数据库。
启动时会迁移旧侧栏配置,并删除已退役页面的权限、专属设置和专用数据表。迁移仅作用于实际配置给此实例的系统数据库。
## 参与修改

View File

@ -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 . .

View File

@ -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",

View File

@ -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);
});

View File

@ -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;
});

View File

@ -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));

View File

@ -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,
};

View File

@ -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);
});

View File

@ -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负责人配置';

View File

@ -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;

View File

@ -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;

View 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;

View File

@ -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;

View File

@ -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 };

View File

@ -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;

View File

@ -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;

View File

@ -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

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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: '系统设置',

View File

@ -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.

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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 };

View File

@ -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,
};

View File

@ -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 };

View File

@ -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 };

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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 };

View File

@ -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,
};

View File

@ -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'),
};
}

View File

@ -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,
};

View File

@ -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

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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 };

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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;

View File

@ -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,
};

View File

@ -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

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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,
};

View File

@ -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 反复淘汰(否则每个请求都要多打一次系统库)'
);
});

View File

@ -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'),
/第一个参数必须是数据库实例/
);
});

View File

@ -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();
}
});

View File

@ -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);
});

View 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));
}
});

View File

@ -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 () => {

View File

@ -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

View File

@ -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('未做'));
});

View File

@ -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');
});

View File

@ -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' }]);
});

View File

@ -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);

View File

@ -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"));
});

View File

@ -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')];
}
});

View File

@ -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

View File

@ -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 列即可接入。
## 验收标准
补表后,任一业务日应满足行内恒等式:
```
期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入
− 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库
```
页面会以该恒等式做逐仓自校验,全部通过后才会撤下「暂缺单据来源」的提示。

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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');
};

View File

@ -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 } }
);

View File

@ -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 } });

View 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 })
);

View File

@ -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')
);

View File

@ -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 }
);

View File

@ -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 }
);

View File

@ -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 }
);

View File

@ -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: {

View File

@ -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 })
);

View File

@ -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 }
);

View File

@ -1,17 +0,0 @@
import request from './request';
export const getPromotionModuleList = (params) => (
request.get('/api/promotion-module/list', { params })
);
export const getPromotionModuleLatestDate = () => (
request.get('/api/promotion-module/latest-date')
);
export const getPromotionModuleSummary = (params) => (
request.get('/api/promotion-module/summary', { params })
);
export const getPromotionModuleShops = () => (
request.get('/api/promotion-module/shops')
);

View File

@ -14,56 +14,6 @@ const request = axios.create({
});
const AUTH_USER_STORAGE_KEY = 'auth_user';
// 后端重启窗口内的自动重连
// ------------------------------------------------------------------
// 本地/线上后端在「隧道掉线 -> 监督器重启后端」期间会有 20~90 秒不可用,
// 此时 Vite 代理拿不到后端,会直接回 500(响应体是 HTML,没有 error 字段),
// 用户看到的就是一句没头没尾的 "Request failed with status code 500"。
// 这里对**幂等请求**(GET/HEAD/OPTIONS)在网络错误或 5xx 时做退避重试,
// 重启窗口过去后请求会自己成功,页面不再报错。
// 累计等待约 80 秒,覆盖最坏情况下的后端启动耗时。
const RETRY_BACKOFF_MS = [1500, 3000, 5000, 8000, 13000, 21000, 30000];
const RETRYABLE_METHODS = ['get', 'head', 'options'];
const RETRY_NOTICE_KEY = 'api-reconnect-notice';
function isRetryableRequest(config) {
if (!config || config.retry === false) {
return false;
}
const method = String(config.method || 'get').toLowerCase();
return RETRYABLE_METHODS.includes(method);
}
function isRetryableError(error) {
if (axios.isCancel?.(error) || error?.code === 'ERR_CANCELED') {
return false;
}
const status = error?.response?.status;
// 无响应:网络中断、连接被拒(后端还没起来)、代理转发失败
if (!status) {
return true;
}
// 5xx:后端内部错误 / 网关与代理错误
return status >= 500 && status <= 599;
}
function sleep(ms) {
return new Promise((resolve) => { setTimeout(resolve, ms); });
}
function notifyReconnecting() {
message.open({
key: RETRY_NOTICE_KEY,
type: 'loading',
content: '服务正在重启,正在自动重连…',
duration: 0,
});
}
function clearReconnectNotice() {
message.destroy(RETRY_NOTICE_KEY);
}
function clearAuthSession() {
localStorage.removeItem('token');
localStorage.removeItem(AUTH_USER_STORAGE_KEY);
@ -144,34 +94,11 @@ request.interceptors.response.use(
}
return Promise.reject(new Error(res.message || 'Error'));
}
clearReconnectNotice();
return res;
},
async (error) => {
const config = error?.config;
// ---- 后端重启窗口内自动重试(只对幂等请求、只对网络错误/5xx)----
// 说明:这里用 `return request(config)`(不带 await)而不是 await,
// 这样重试失败时由「内层」这一分支继续退避;外层调用栈不会重新进入本函数,
// 因此最终失败提示天然只会出现一次,也不会把重试次数叠加错。
if (isRetryableRequest(config) && !config.__retryExhausted && isRetryableError(error)) {
const attempt = Number(config.__retryAttempt || 0);
if (attempt < RETRY_BACKOFF_MS.length) {
config.__retryAttempt = attempt + 1;
if (attempt === 0 && !config.silentError) {
notifyReconnecting();
}
await sleep(RETRY_BACKOFF_MS[attempt]);
return request(config);
}
// 次数用尽:标记后不再重试,并把连接提示收掉
config.__retryExhausted = true;
config.__retryAttempt = 0;
clearReconnectNotice();
}
(error) => {
const authSessionFailure = isAuthSessionFailure(error.response);
if (!authSessionFailure && !error?.config?.silentError) {
if (!authSessionFailure && !error.config?.silentError) {
message.error(getRequestErrorMessage(error));
}
if (authSessionFailure && !shouldBypass401Redirect(error)) {

Some files were not shown because too many files have changed in this diff Show More