diff --git a/.gitignore b/.gitignore index 82c5087..84188f3 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,7 @@ tmall-board-speed-check.json .remote-access.backup-*/ .workbuddy/ **/node_modules + +# Local verification artifacts +/outputs/ +.git-status.txt diff --git a/AGENTS.md b/AGENTS.md index 5dddc37..f0d1dce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,9 @@ - 保留他人的未提交修改和已有提交,仅暂存本任务内容;不得清理或覆盖未知工作。 - 推送前同步远端,逐项检查最终差异并验证;被拒绝后先查明并合并更新,禁止任何形式的强推。 - 不改写已发布历史,不批量选择冲突的某一方;不明确的行为取舍需要负责人确认。 -- 所有变更通过 PR 进入 `main`,不得直接推送主分支;独立审核与最新内容的验证通过后由维护者合并。 +- 所有变更通过 PR 进入 `main`,不得直接推送主分支;默认由非提交者审核。仓库所有者或管理员明确授权某个 PR 合并时,该书面授权可以代替独立审核,但仍必须同步最新主分支、验证最终提交并保留授权记录。 - 不提交凭据、私钥、环境秘密或真实业务数据;不把合并授权当成生产部署授权。 +- 新建或修改数据表、映射表、查询和同步任务时,必须按实际过滤、关联、排序和唯一性设计索引,用执行计划和有代表性数据验证负载;禁止无边界全表扫描、无节制并发和重复全量同步。 - 开发机器使用中央系统数据库和公网 TLS Redis,无需安装本地数据库;生产部署必须先将代码通过 PR 合入 `main`,并在 `/opt/cangku/deploy.lock` 锁内从服务器 Git 工作区快进拉取和验证。 - 最终交付报告实际分支、提交 SHA、验证结果及 PR 状态,不能将“已推送”报告为“已合并”。 diff --git a/README.md b/README.md index c51db3c..c36d8af 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ npm run remote:dev 首次启动时填写配置请求地址和部署 Key,系统自动写入被 Git 忽略的本机配置并重启后端。保存一次性显示的管理员账号和密码后直接登录,不需要在本地创建数据库或继续执行设置向导。前端默认访问 http://localhost:5173,API 默认代理到本地后端。 +拉取主仓库更新后必须停止旧的 `npm run remote:dev` 进程并重新启动,保证前后端使用同一版本。MKH 页的强制刷新带有版本协议校验;如果新前端仍代理到未重启的旧后端,页面会停止展示可能过期的库存并明确提示重启,不再将旧值冒充为“最新数据”。 + ## 验证与构建 ```bash diff --git a/backend/package-lock.json b/backend/package-lock.json index 07231f5..f6eb63a 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,6 +28,7 @@ "multer": "^2.1.1", "mysql2": "^3.6.5", "node-cron": "^3.0.3", + "pg": "8.17.2", "playwright": "^1.60.0", "sharp": "^0.33.5", "uuid": "^9.0.1", diff --git a/backend/package.json b/backend/package.json index 7287236..a649155 100644 --- a/backend/package.json +++ b/backend/package.json @@ -32,6 +32,7 @@ "multer": "^2.1.1", "mysql2": "^3.6.5", "node-cron": "^3.0.3", + "pg": "8.17.2", "playwright": "^1.60.0", "sharp": "^0.33.5", "uuid": "^9.0.1", diff --git a/backend/scripts/gen-daily-inventory-baseline.cjs b/backend/scripts/gen-daily-inventory-baseline.cjs new file mode 100644 index 0000000..8ed220f --- /dev/null +++ b/backend/scripts/gen-daily-inventory-baseline.cjs @@ -0,0 +1,106 @@ +process.env.CANGKU_SKIP_BOOTSTRAP = '1'; +const fs = require('fs'); +const path = require('path'); +const XLSX = require('xlsx'); +const cloudDb = require('../src/config/cloudDatabase'); + +// 表格的 12 个流水列 → 配置字段名 +const FLOW_FIELDS = [ + ['加工进仓', 'processIn'], + ['采购退货', 'purchaseReturn'], + ['采购进仓', 'purchaseIn'], + ['其它进仓', 'otherIn'], + ['其它退货', 'otherReturn'], + ['销售出仓', 'salesOut'], + ['盘点', 'stocktaking'], + ['加工出仓', 'processOut'], + ['调拨出', 'transferOut'], + ['销售退货', 'salesReturn'], + ['调拨入', 'transferIn'], + ['其它出库', 'otherOut'], +]; + +(async () => { + const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx'); + const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' }); + const h = rows[0]; + const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i])); + + const level1 = String(rows[1][idx['一级功能按键']]).trim(); + const level2 = String(rows[1][idx['二级功能按键']]).trim(); + console.log('一级功能:', level1, '| 二级功能:', level2); + + const whs = await cloudDb.query(`SELECT name, wms_co_id FROM rpt.jst_warehouseInfo`); + const byName = new Map(whs.map((w) => [String(w.name).trim(), w.wms_co_id])); + + const records = []; + const missing = []; + for (const r of rows.slice(1)) { + const sheetName = String(r[idx['仓库']] || '').trim(); + if (!sheetName) continue; + const base = sheetName.replace(/主仓$/, ''); + const wmsCoId = byName.get(base) ?? byName.get(sheetName) ?? null; + if (wmsCoId == null) missing.push(sheetName); + + const num = (k) => { + const v = r[idx[k]]; + return typeof v === 'number' ? v : Number(String(v || '').replace(/,/g, '')) || 0; + }; + + const flows = Object.fromEntries(FLOW_FIELDS.map(([cn, en]) => [en, num(cn)])); + const openExtra = r[idx['期初']]; // 第二个「期初」列,索引会与「期初数量」冲突,单独处理 + records.push({ + warehouse: sheetName, + dbWarehouse: base, + wmsCoId, + openingQty: num('期初数量'), + openingQtyExtra: typeof openExtra === 'number' ? openExtra : Number(openExtra) || 0, + ...flows, + }); + } + + console.log('记录数:', records.length, '| 缺 wms_co_id:', missing.length, missing.join(',') || '(无)'); + + const FLOW_COLUMNS_DEF = FLOW_FIELDS.map(([cn, en]) => ({ label: cn, field: en })); + const out = `/** + * 每日进销存 —— 2026-09-22 进销存基准流水 + * + * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出) + * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库 + * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。 + * + * 口径约定: + * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储) + * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确) + * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算 + */ + +/** 一级/二级菜单名(取自表格) */ +const DAILY_INVENTORY_LEVEL1 = '${level1}'; +const DAILY_INVENTORY_LEVEL2 = '${level2}'; + +/** 基准数据日期 */ +const BASELINE_DATE = '2026-09-22'; + +/** 12 个流水列的中文表头与字段映射(顺序与表格一致) */ +const FLOW_COLUMNS = ${JSON.stringify(FLOW_COLUMNS_DEF, null, 2)}; + +/** 25 个仓库的 22 号基准流水 */ +const DAILY_INVENTORY_BASELINE = ${JSON.stringify(records, null, 2)}; + +module.exports = { + DAILY_INVENTORY_LEVEL1, + DAILY_INVENTORY_LEVEL2, + BASELINE_DATE, + FLOW_COLUMNS, + DAILY_INVENTORY_BASELINE, +}; +`; + + const target = path.join(__dirname, '../src/config/dailyInventoryBaseline.js'); + fs.writeFileSync(target, out, 'utf8'); + console.log('已生成:', target); + console.log('\n前 2 条预览:'); + console.log(JSON.stringify(records.slice(0, 2), null, 1)); + process.exit(0); +})().catch((e) => { console.log('ERR', e.message); process.exit(1); }); diff --git a/backend/scripts/verify-daily-inventory.js b/backend/scripts/verify-daily-inventory.js new file mode 100644 index 0000000..38c1ec1 --- /dev/null +++ b/backend/scripts/verify-daily-inventory.js @@ -0,0 +1,96 @@ +process.env.CANGKU_SKIP_BOOTSTRAP = '1'; +const http = require('http'); +const XLSX = require('xlsx'); + +const FIELD_MAP = [ + ['期初数量', 'openingQty'], + ['加工进仓', 'processIn'], + ['采购退货', 'purchaseReturn'], + ['采购进仓', 'purchaseIn'], + ['其它进仓', 'otherIn'], + ['其它退货', 'otherReturn'], + ['销售出仓', 'salesOut'], + ['盘点', 'stocktaking'], + ['加工出仓', 'processOut'], + ['调拨出', 'transferOut'], + ['销售退货', 'salesReturn'], + ['调拨入', 'transferIn'], + ['期初', 'openingQtyExtra'], + ['其它出库', 'otherOut'], + ['期末数量', 'closingQty'], +]; + +function req(method, path, token, body) { + return new Promise((resolve, reject) => { + const data = body ? JSON.stringify(body) : null; + const r = http.request({ + host: '127.0.0.1', port: 4001, path, method, + headers: { + 'Content-Type': 'application/json', + ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}), + ...(token ? { Authorization: 'Bearer ' + token } : {}), + }, + }, (resp) => { let s = ''; resp.on('data', (d) => { s += d; }); resp.on('end', () => resolve({ code: resp.statusCode, body: s })); }); + r.on('error', reject); + if (data) r.write(data); + r.end(); + }); +} + +(async () => { + const login = JSON.parse((await req('POST', '/api/auth/local-preview', null, {})).body); + const token = login.access_token || login.token; + const res = await req('GET', '/api/daily-inventory', token); + if (res.code !== 200) { console.log('接口失败', res.code, res.body.slice(0, 300)); process.exit(1); } + const d = JSON.parse(res.body).data; + + const wb = XLSX.readFile('C:/Users/15018737611/Desktop/库存监控.xlsx'); + const rows = XLSX.utils.sheet_to_json(wb.Sheets['Sheet1'], { header: 1, defval: '' }); + const h = rows[0]; + const idx = Object.fromEntries(h.map((c, i) => [String(c).trim(), i])); + const byName = new Map(d.items.map((it) => [it.warehouse, it])); + + console.log('接口: 基准日 %s | 快照日 %s | asOf %s | 仓数 %d', d.baselineDate, d.latestBizDate, d.asOf, d.items.length); + console.log(); + + const diffCounts = {}; + let totalCells = 0; + let diffCells = 0; + const samples = []; + + for (const r of rows.slice(1)) { + const name = String(r[idx['仓库']] || '').trim(); + if (!name) continue; + const item = byName.get(name); + if (!item) { console.log('接口缺少仓库:', name); continue; } + for (const [cn, field] of FIELD_MAP) { + const raw = r[idx[cn]]; + const expected = typeof raw === 'number' ? raw : Number(String(raw || '').replace(/,/g, '')) || 0; + const actual = Number(item[field] ?? 0); + totalCells += 1; + // 期末允许 1 件内的副本同步误差 + const tol = field === 'closingQty' ? 10 : 0; + if (Math.abs(expected - actual) > tol) { + diffCells += 1; + diffCounts[cn] = (diffCounts[cn] || 0) + 1; + if (samples.length < 12) samples.push(`${name} | ${cn} | 表格 ${expected} vs 接口 ${actual}`); + } + } + } + + console.log('比对单元格总数: %d,不一致: %d', totalCells, diffCells); + if (diffCells) { + console.log('\n不一致列统计:', JSON.stringify(diffCounts)); + console.log('样例:'); + samples.forEach((s) => console.log(' ' + s)); + } else { + console.log('✓ 全部单元格与表格一致'); + } + + const withGrade = d.items.filter((x) => x.hasGrade).length; + console.log('\n有分级数据仓: %d/%d', withGrade, d.items.length); + console.log('无分级的仓:', d.items.filter((x) => !x.hasGrade).map((x) => x.warehouse).join('、')); + console.log('\n合计: 期初 %s,期末 %s', d.totals.openingQty, d.totals.closingQty); + console.log('崔桥样例:', JSON.stringify(d.items[0], null, 1).replace(/\n\s*/g, ' ')); + process.exit(0); +})().catch((e) => { console.log('ERR', e.message); process.exit(1); }); diff --git a/backend/src/api/admin.js b/backend/src/api/admin.js index 99e9adb..ad66fb1 100644 --- a/backend/src/api/admin.js +++ b/backend/src/api/admin.js @@ -98,10 +98,6 @@ const { testSelectionFissionAiConfig, } = require('../services/selectionFissionAiSettings'); const { testSelectionImageGenerationConfig } = require('../services/selectionImageGenerationService'); -const { - getProductTagConfig, - saveProductTagConfig, -} = require('../services/productTagConfigService'); const backfillJobs = new Map(); const productCodeImportUpload = multer({ @@ -1215,40 +1211,6 @@ router.post('/selection-fission-ai-settings/image-generation/test', async (_req, -// ==================== 商品标签配置(规模标签 / 利润标签 / SABC 映射) ==================== - -router.get('/product-tag-config', async (_req, res) => { - try { - res.json({ success: true, data: await getProductTagConfig() }); - } catch (error) { - console.error('获取商品标签配置失败:', error); - res.status(error.statusCode || error.status || 500).json({ - success: false, - error: error.message || '获取商品标签配置失败', - }); - } -}); - -router.put('/product-tag-config', async (req, res) => { - try { - const result = await saveProductTagConfig(req.body || {}); - const config = await getProductTagConfig(); - res.json({ - success: true, - message: '商品标签配置已保存到系统数据库', - data: { ...config, saved: result }, - }); - } catch (error) { - console.error('保存商品标签配置失败:', error); - // 校验类错误回 400,方便前端直接展示原因 - const status = /行|重复|不能为空|取值非法|必须小于/.test(error.message || '') ? 400 : (error.statusCode || error.status || 500); - res.status(status).json({ - success: false, - error: error.message || '保存商品标签配置失败', - }); - } -}); - // ==================== 1688 专用以图搜图 ==================== @@ -1517,6 +1479,13 @@ function getDefaultPortByDriver(driver) { return 3306; } +function normalizeDatabaseDriver(driver) { + const normalized = String(driver || '').trim().toLowerCase(); + if (normalized === 'postgresql' || normalized === 'postgres') return 'postgres'; + if (normalized === 'mssql') return 'mssql'; + return 'mysql'; +} + async function getStoredDatabaseConfig(configName) { const normalizedName = normalizeDatabaseConfigName(configName); let configs = await database.local.query( @@ -1655,7 +1624,9 @@ router.put('/database', async (req, res) => { } // 根据数据库类型设置默认端口 - const resolvedDriver = driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql'); + const resolvedDriver = normalizeDatabaseDriver( + driver || existingConfig?.driver || (useEnvConfig ? envSummary.driver : 'mysql') + ); const defaultPort = getDefaultPortByDriver(resolvedDriver); const configData = { @@ -1749,6 +1720,8 @@ router.post('/database/test', async (req, res) => { return res.status(400).json({ success: false, error: '请完整填写数据库连接信息' }); } + driver = normalizeDatabaseDriver(driver); + if (driver === 'mssql') { // SQL Server 连接测试 const sql = require('mssql'); @@ -1768,6 +1741,26 @@ router.post('/database/test', async (req, res) => { const pool = await sql.connect(config); await pool.request().query('SELECT 1 as test'); await pool.close(); + } else if (driver === 'postgres') { + const { Client } = require('pg'); + const client = new Client({ + host, + port: port || 5432, + database: dbName, + user: username, + password, + ssl: ssl ? { rejectUnauthorized: false } : false, + connectionTimeoutMillis: 10000, + query_timeout: 10000, + statement_timeout: 10000, + }); + + try { + await client.connect(); + await client.query('SELECT 1 AS test'); + } finally { + await client.end().catch(() => {}); + } } else { // MySQL 连接测试 const mysql = require('mysql2/promise'); diff --git a/backend/src/api/dailyInventory.js b/backend/src/api/dailyInventory.js new file mode 100644 index 0000000..7bdc9a1 --- /dev/null +++ b/backend/src/api/dailyInventory.js @@ -0,0 +1,179 @@ +const express = require('express'); +const cloudDb = require('../config/cloudDatabase'); +const { + DAILY_INVENTORY_BASELINE, + BASELINE_DATE, + FLOW_COLUMNS, +} = require('../config/dailyInventoryBaseline'); + +const router = express.Router(); + +/** 库存衡量基准时刻:快照日次日 01:00(报表库每日凌晨 1 点跑批,标注 T-1) */ +const AS_OF_TIME = '01:00'; + +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}`; +} + +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; +} + +/** + * 派生列(SQL CASE WHEN 下沉计算) + * - 库龄(天) = DATEDIFF(asOf, created) + * - 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100% + * 月度发货总件数 = threeAvgSale × 30;月度日均库存件数 = jsStock + * - 四级分级:库龄 + 周转率双维度 + */ +function buildDerivedColumns(asOf) { + const A = '@asOf'; + void A; + const ageDays = `DATEDIFF(DAY, m.created, @asOf)`; + const turnover = `(ISNULL(m.threeAvgSale, 0) * 30.0 * 100.0 / NULLIF(m.jsStock, 0))`; + return { + ageDays, + turnoverDays: `CAST(${turnover} AS DECIMAL(18, 4))`, + levelExpr: ` + CASE + WHEN m.created IS NULL OR ISNULL(m.jsStock, 0) = 0 OR ${turnover} IS NULL THEN NULL + WHEN ${ageDays} < 90 AND ${turnover} BETWEEN 30 AND 199 THEN 'warn' + WHEN ${ageDays} < 90 AND ${turnover} >= 200 THEN 'slow' + WHEN ${ageDays} >= 90 AND ${turnover} BETWEEN 30 AND 99 THEN 'slow' + WHEN ${ageDays} BETWEEN 90 AND 365 AND ${turnover} >= 100 THEN 'stale' + WHEN ${ageDays} > 365 AND ${turnover} BETWEEN 100 AND 199 THEN 'stale' + WHEN ${ageDays} > 365 AND ${turnover} >= 200 THEN 'zombie' + ELSE NULL + END`, + }; +} + +/** 该仓快照里的四级库存量与平均周转天数 */ +async function fetchGradeMetrics(dbWarehouse, bizDate, asOf) { + const D = buildDerivedColumns(asOf); + const rows = await cloudDb.query( + `SELECT COUNT(*) AS skuCount, + SUM(CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2))) AS gradeStock, + AVG(${D.turnoverDays}) AS avgTurnover, + SUM(CASE WHEN ${D.levelExpr} = 'warn' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS warnLevel, + SUM(CASE WHEN ${D.levelExpr} = 'slow' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS slowLevel, + SUM(CASE WHEN ${D.levelExpr} = 'stale' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS staleLevel, + SUM(CASE WHEN ${D.levelExpr} = 'zombie' THEN CAST(ISNULL(m.jsStock, 0) AS DECIMAL(18, 2)) ELSE 0 END) AS zombieLevel + FROM rpt.pm_stockMonitor m + WHERE m.warehouse = @warehouse + AND CONVERT(VARCHAR(10), m.date, 23) = @bizDate`, + { warehouse: dbWarehouse, bizDate, asOf } + ); + const r = rows[0] || {}; + if (!Number(r.skuCount || 0)) return null; + return { + skuCount: toNumber(r.skuCount), + gradeStock: toNumber(r.gradeStock), + turnoverDays: toNumber(r.avgTurnover), + warnLevel: toNumber(r.warnLevel), + slowLevel: toNumber(r.slowLevel), + staleLevel: toNumber(r.staleLevel), + zombieLevel: toNumber(r.zombieLevel), + }; +} + +/** 实时期末库存(单仓 wms_co_id),并带回最近更新时间便于核对时效 */ +async function fetchClosingQty(wmsCoId) { + if (wmsCoId == null) return null; + const rows = await cloudDb.query( + `SELECT ISNULL(SUM(CAST(qty AS DECIMAL(18, 2))), 0) AS qty, + COUNT(CASE WHEN ISNULL(qty, 0) <> 0 THEN 1 END) AS skuCount, + MAX(modified) AS takenAt + FROM rpt.jst_inventoryInfo + WHERE wms_co_id = @wmsCoId`, + { wmsCoId } + ); + const r = rows[0] || {}; + return { + closingQty: toNumber(r.qty), + closingSkuCount: toNumber(r.skuCount) || 0, + closingTakenAt: r.takenAt || null, + }; +} + +/** + * GET /api/daily-inventory + * 每日进销存:25 个仓库 × 进销存流水 + 期末 + 分级库存量 + * + * 数据源分工: + * - 期初数量 与 12 个流水列:22 号基准(dailyInventoryBaseline.js) + * - 期末数量:jst_inventoryInfo 实时库存 + * - 库存周转天数 / 四级库存量:pm_stockMonitor 快照 + */ +router.get('/', async (req, res) => { + try { + const latestBizDate = await fetchLatestBizDate().catch(() => null); + const asOf = nextDayAsOf(latestBizDate); + + const items = []; + for (const base of DAILY_INVENTORY_BASELINE) { + const flows = {}; + for (const col of FLOW_COLUMNS) flows[col.field] = base[col.field] ?? 0; + + const closing = await fetchClosingQty(base.wmsCoId).catch(() => null); + const grade = base.dbWarehouse && latestBizDate + ? await fetchGradeMetrics(base.dbWarehouse, latestBizDate, asOf).catch(() => null) + : null; + + items.push({ + warehouse: base.warehouse, + dbWarehouse: base.dbWarehouse, + openingQty: base.openingQty, + openingQtyExtra: base.openingQtyExtra, + ...flows, + closingQty: closing?.closingQty ?? null, + closingSkuCount: closing?.closingSkuCount ?? null, + closingTakenAt: closing?.closingTakenAt ?? null, + turnoverDays: grade?.turnoverDays ?? null, + gradeStock: grade?.gradeStock ?? null, + warnLevel: grade?.warnLevel ?? null, + slowLevel: grade?.slowLevel ?? null, + staleLevel: grade?.staleLevel ?? null, + zombieLevel: grade?.zombieLevel ?? null, + hasGrade: Boolean(grade), + }); + } + + // 合计行:便于与表格总额核对 + 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']) { + totals[k] = Number(sum(k).toFixed(2)); + } + + res.json({ + success: true, + data: { + baselineDate: BASELINE_DATE, + latestBizDate, + asOf, + flowColumns: FLOW_COLUMNS, + items, + totals, + }, + }); + } catch (error) { + console.error('[daily-inventory] 查询失败:', error.message); + res.status(500).json({ success: false, error: '每日进销存数据查询失败', detail: error.message }); + } +}); + +module.exports = router; diff --git a/backend/src/api/mkhBom.js b/backend/src/api/mkhBom.js new file mode 100644 index 0000000..a4aa503 --- /dev/null +++ b/backend/src/api/mkhBom.js @@ -0,0 +1,127 @@ +'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 }; diff --git a/backend/src/api/modularStockMonitor.js b/backend/src/api/modularStockMonitor.js new file mode 100644 index 0000000..08deddf --- /dev/null +++ b/backend/src/api/modularStockMonitor.js @@ -0,0 +1,59 @@ +'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; diff --git a/backend/src/config/cloudDatabase.js b/backend/src/config/cloudDatabase.js index 5edbe1d..37c7d2f 100644 --- a/backend/src/config/cloudDatabase.js +++ b/backend/src/config/cloudDatabase.js @@ -19,7 +19,8 @@ const DEFAULT_REPORT_REPLICA_APP_SCHEMA = 'app'; let pool = null; let activeCloudConfig = null; let cachedCloudConfigCandidates = null; -let lastConnectFailure = null; +// 负缓存(失败态)的过期时间戳。0 表示没有失败态缓存。 +let cachedCloudConfigExpiresAt = 0; function isPoolUsable(activePool) { return Boolean(activePool && activePool.connected && activePool.healthy !== false); @@ -31,15 +32,15 @@ const parsePositiveInt = (value, fallback) => { }; const DEFAULT_CONNECT_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_TIMEOUT_MS, 30000); -const DEFAULT_REQUEST_TIMEOUT_MS = parsePositiveInt(process.env.CLOUD_DB_REQUEST_TIMEOUT_MS, 600000); +// 原先默认 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 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); const LOCAL_CONFIG_DB_CONNECTION_LIMIT = parsePositiveInt(process.env.LOCAL_CONFIG_DB_CONNECTION_LIMIT, 2); -// 连接建立失败后的冷静期(毫秒)。 -// 若业务库被防火墙「丢包」(而非拒绝),每次连接都要死等到 connectTimeout 才会失败; -// 短时间内重复请求没必要把整套失败流程重跑一遍。 -const CONNECT_FAILURE_COOLDOWN_MS = parsePositiveInt(process.env.CLOUD_DB_CONNECT_FAILURE_COOLDOWN_MS, 15000); const DEFAULT_ENV_KEYS = Object.freeze({ driver: ['CLOUD_DB_TYPE', 'APP_DB_TYPE', 'SQLSERVER_DRIVER'], @@ -678,7 +679,15 @@ async function loadCandidateConfig(localPool, profile) { } async function resolveCloudConfigCandidates() { - if (cachedCloudConfigCandidates) { + // 缓存有效判据:**非 null** 且在有效期内。 + // + // 旧实现是 `if (cachedCloudConfigCandidates)`,而 [] 在 JS 里是 truthy —— 一旦系统库 + // 读取失败一次(隧道抖动),就会把空数组当作合法缓存永久返回,之后永不重试, + // 直接抛「业务数据库尚未配置」500,直到重启后端。这是典型的「永久负缓存」反模式。 + // + // 现在:成功态 expiresAt = Infinity(靠 resetPool 显式失效);失败态带 30s TTL, + // 到期自动重新解析,隧道恢复后无需人工干预即可自愈。 + if (cachedCloudConfigCandidates !== null && Date.now() < cachedCloudConfigExpiresAt) { return cachedCloudConfigCandidates; } @@ -694,14 +703,13 @@ async function resolveCloudConfigCandidates() { if (!candidate?.mssqlConfig) { continue; } - // 按真实连接目标去重:public / local 两个标签经常指向同一个 host:port, - // 若按 selectedProfile 去重则视为两个候选,一次请求会把同一个不可达地址串行连接两次。 const dedupeKey = [ + candidate.runtimeInfo.selectedProfile, + candidate.runtimeInfo.source, candidate.runtimeInfo.host, candidate.runtimeInfo.port, candidate.runtimeInfo.database, candidate.runtimeInfo.username, - candidate.mssqlConfig?.password || '', ].join('::'); if (seen.has(dedupeKey)) { continue; @@ -711,6 +719,7 @@ async function resolveCloudConfigCandidates() { } cachedCloudConfigCandidates = candidates; + cachedCloudConfigExpiresAt = Number.POSITIVE_INFINITY; return cachedCloudConfigCandidates; } catch (error) { console.warn('[CloudDB] 无法从数据库读取配置:', error.message); @@ -722,15 +731,23 @@ async function resolveCloudConfigCandidates() { } } - cachedCloudConfigCandidates = []; + // 失败态:按需回退到环境变量配置,并把结果记为「带 TTL 的负缓存」。 + const fallbacks = []; if (hasCompleteEnvConfig(process.env, 'default')) { console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_*)'); - cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'default')); + fallbacks.push(buildMssqlPoolConfigFromEnv(process.env, 'default')); } if (hasCompleteEnvConfig(process.env, 'public')) { console.log('[CloudDB] 使用环境变量配置(CLOUD_DB_PUBLIC_*)'); - cachedCloudConfigCandidates.push(buildMssqlPoolConfigFromEnv(process.env, 'public')); + fallbacks.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; } @@ -931,20 +948,11 @@ async function createPinnedQueryClient(options = {}) { /** * 获取连接池 */ -const getPool = async (options = {}) => { +const getPool = async () => { if (isPoolUsable(pool)) { return pool; } - if ( - lastConnectFailure - && !options.bypassConnectFailureCache - && Date.now() - lastConnectFailure.at < CONNECT_FAILURE_COOLDOWN_MS - ) { - // 冷静期内直接复用上次的失败结果,避免重复等待连接超时 - throw lastConnectFailure.error; - } - if (pool) { console.warn('[CloudDB] 检测到失效连接池,准备重建连接'); await resetPool(); @@ -963,33 +971,34 @@ const getPool = async (options = {}) => { + `${candidate.runtimeInfo.host}:${candidate.runtimeInfo.port}/${candidate.runtimeInfo.database}` ); await connectWithCandidate(candidate); - lastConnectFailure = null; console.log('[CloudDB] MSSQL 云数据库连接成功'); return pool; } catch (error) { - // 附带候选配置信息,便于上层把报错翻译成「连不上哪台库」的可读提示 - error.cloudDbRuntime = candidate.runtimeInfo; errors.push(error); console.warn(`[CloudDB] ${candidate.runtimeInfo.selectedProfile} 配置连接失败: ${error.message}`); } } const lastError = errors[errors.length - 1]; - if (lastError) { - // 标记为「连接建立失败」:这类失败重试没有意义,且要计入冷静期 - lastError.cloudDbConnectFailure = true; - lastConnectFailure = { at: Date.now(), error: lastError }; - } console.error('[CloudDB] MSSQL 连接失败:', lastError?.message || NOT_CONFIGURED_MESSAGE); throw lastError || new Error(NOT_CONFIGURED_MESSAGE); }; -const resetPool = async () => { - lastConnectFailure = null; +/** + * 统一失效「云配置候选」缓存。 + * + * 抽成单点:数组与负缓存 TTL 时间戳必须同时清,只清数组会让陈旧的 expiresAt 残留, + * 下次读取时 `Date.now() < expiresAt` 仍可能成立 → 又退化回「负缓存不失效」。 + */ +function invalidateCloudConfigCache() { + cachedCloudConfigCandidates = null; + cachedCloudConfigExpiresAt = 0; +} +const resetPool = async () => { if (!pool) { activeCloudConfig = null; - cachedCloudConfigCandidates = null; + invalidateCloudConfigCache(); return; } @@ -1000,7 +1009,7 @@ const resetPool = async () => { } finally { pool = null; activeCloudConfig = null; - cachedCloudConfigCandidates = null; + invalidateCloudConfigCache(); } }; @@ -1014,7 +1023,7 @@ const query = async (sqlText, params = {}, options = {}) => { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { try { - const activePool = await getPool(options); + const activePool = await getPool(); const resolvedSqlText = rewriteReportReplicaSql(sqlText, activeCloudConfig?.runtimeInfo); const request = activePool.request(); const requestTimeout = parsePositiveInt(options.requestTimeout, 0); @@ -1032,10 +1041,7 @@ const query = async (sqlText, params = {}, options = {}) => { const result = await request.query(resolvedSqlText); return result.recordset; } catch (error) { - // 连接建立失败不重试:地址不可达时重试只是把同样的超时再跑一遍 - const shouldRetry = attempt < maxAttempts - && !error.cloudDbConnectFailure - && isTransientError(error); + const shouldRetry = attempt < maxAttempts && isTransientError(error); if (!shouldRetry) { throw error; } @@ -1054,6 +1060,14 @@ 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); } @@ -1080,6 +1094,9 @@ const queryWithFreshConnection = async (sqlText, params = {}, options = {}) => { */ const test = async () => { try { + // 「测试连接」是用户在系统设置里验证配置的入口,必须读最新配置: + // 先显式失效,避免 30s 内的失败态负缓存让用户看到「刚改好却连不上」。 + invalidateCloudConfigCache(); const candidates = await resolveCloudConfigCandidates(); if (!Array.isArray(candidates) || candidates.length === 0) { return { success: false, message: NOT_CONFIGURED_MESSAGE }; @@ -1108,8 +1125,7 @@ const close = async () => { } pool = null; activeCloudConfig = null; - cachedCloudConfigCandidates = null; - lastConnectFailure = null; + invalidateCloudConfigCache(); }; /** diff --git a/backend/src/config/dailyInventoryBaseline.js b/backend/src/config/dailyInventoryBaseline.js new file mode 100644 index 0000000..f8a9c5f --- /dev/null +++ b/backend/src/config/dailyInventoryBaseline.js @@ -0,0 +1,558 @@ +/** + * 每日进销存 —— 2026-09-22 进销存基准流水 + * + * 来源:桌面《库存监控.xlsx》(聚水潭 22 号进销存导出) + * 用途:业务库 JSReportReplica 没有按天的历史库存结存表,也缺少调拨/盘点/加工/其它出入库 + * 单据表,期初与各流水列无法从业务库还原,故以该导出作为 22 号基准值。 + * + * 口径约定: + * - openingQty 与 12 个流水列:22 号基准值(本文件静态存储) + * - 期末数量:运行时取 jst_inventoryInfo 实时库存(与本文件基准期末一致,24/25 精确) + * - 库存周转天数 / 四级库存量:运行时由 pm_stockMonitor 计算 + */ + +/** 一级/二级菜单名(取自表格) */ +const DAILY_INVENTORY_LEVEL1 = '大货库存监控'; +const DAILY_INVENTORY_LEVEL2 = '每日进销存'; + +/** 基准数据日期 */ +const BASELINE_DATE = '2026-09-22'; + +/** 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" + } +]; + +/** 25 个仓库的 22 号基准流水 */ +const DAILY_INVENTORY_BASELINE = [ + { + "warehouse": "简尚-崔桥工厂仓主仓", + "dbWarehouse": "简尚-崔桥工厂仓", + "wmsCoId": 15038426, + "openingQty": 92388, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -3924, + "stocktaking": -1, + "processOut": 0, + "transferOut": -1461, + "salesReturn": 0, + "transferIn": 5437, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东青岗集仓主仓", + "dbWarehouse": "简尚-山东青岗集仓", + "wmsCoId": 15137523, + "openingQty": 88377, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 886, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1058, + "stocktaking": -1, + "processOut": 0, + "transferOut": -11, + "salesReturn": 0, + "transferIn": 23, + "otherOut": 0 + }, + { + "warehouse": "简尚-河南洛宁仓主仓", + "dbWarehouse": "简尚-河南洛宁仓", + "wmsCoId": 15539084, + "openingQty": 83305, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -314, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 15, + "otherOut": 0 + }, + { + "warehouse": "简尚-新苏北仓主仓", + "dbWarehouse": "简尚-新苏北仓", + "wmsCoId": 15038347, + "openingQty": 77145, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1744, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 649, + "otherOut": 0 + }, + { + "warehouse": "简尚-横林二仓主仓", + "dbWarehouse": "简尚-横林二仓", + "wmsCoId": 15033302, + "openingQty": 63780, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -982, + "stocktaking": 0, + "processOut": 0, + "transferOut": -17, + "salesReturn": 0, + "transferIn": 728, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东品牌仓主仓", + "dbWarehouse": "简尚-山东品牌仓", + "wmsCoId": 15182705, + "openingQty": 41025, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 150, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -157, + "stocktaking": 0, + "processOut": 0, + "transferOut": -5, + "salesReturn": 0, + "transferIn": 4, + "otherOut": 0 + }, + { + "warehouse": "简尚-睢宁仓主仓", + "dbWarehouse": "简尚-睢宁仓", + "wmsCoId": 15032603, + "openingQty": 25761, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -922, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 333, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部专用仓主仓", + "dbWarehouse": "简尚-分销部专用仓", + "wmsCoId": 15045426, + "openingQty": 6380, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -264, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 200, + "otherOut": 0 + }, + { + "warehouse": "简尚-新外贸仓主仓", + "dbWarehouse": "简尚-新外贸仓", + "wmsCoId": 15045308, + "openingQty": 5165, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "京仓-横林二仓京东自营仓主仓", + "dbWarehouse": "京仓-横林二仓京东自营仓", + "wmsCoId": 15835893, + "openingQty": 4260, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部睢宁专用仓主仓", + "dbWarehouse": "简尚-分销部睢宁专用仓", + "wmsCoId": 15051960, + "openingQty": 3711, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-崔桥工厂品牌仓主仓", + "dbWarehouse": "简尚-崔桥工厂品牌仓", + "wmsCoId": 15870495, + "openingQty": 1831, + "openingQtyExtra": 0, + "processIn": 94, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -53, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部苏北专用仓主仓", + "dbWarehouse": "简尚-分销部苏北专用仓", + "wmsCoId": 15045325, + "openingQty": 1440, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C横林二仓专用仓主仓", + "dbWarehouse": "简尚-运营C横林二仓专用仓", + "wmsCoId": 15301814, + "openingQty": 2141, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -992, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-济南外贸仓主仓", + "dbWarehouse": "简尚-济南外贸仓", + "wmsCoId": 15051888, + "openingQty": 712, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-分销部崔桥专用仓主仓", + "dbWarehouse": "简尚-分销部崔桥专用仓", + "wmsCoId": 15404402, + "openingQty": 620, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C崔桥工厂仓专用仓主仓", + "dbWarehouse": "简尚-运营C崔桥工厂仓专用仓", + "wmsCoId": 15301815, + "openingQty": 286, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D苏北专用仓主仓", + "dbWarehouse": "简尚-运营D苏北专用仓", + "wmsCoId": 15406876, + "openingQty": 280, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C睢宁专用仓主仓", + "dbWarehouse": "简尚-运营C睢宁专用仓", + "wmsCoId": 15301813, + "openingQty": 193, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营C新苏北专用仓主仓", + "dbWarehouse": "简尚-运营C新苏北专用仓", + "wmsCoId": 15301812, + "openingQty": 155, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D睢宁专用仓主仓", + "dbWarehouse": "简尚-运营D睢宁专用仓", + "wmsCoId": 15051907, + "openingQty": 109, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -1, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-山东青岗集京东仓主仓", + "dbWarehouse": "简尚-山东青岗集京东仓", + "wmsCoId": 15406664, + "openingQty": 90, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-运营D专用仓库主仓", + "dbWarehouse": "简尚-运营D专用仓库", + "wmsCoId": 15051915, + "openingQty": 180, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -125, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "简尚-崔桥工厂京东仓主仓", + "dbWarehouse": "简尚-崔桥工厂京东仓", + "wmsCoId": 15045345, + "openingQty": 1, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": 0, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + }, + { + "warehouse": "京仓-横林二仓京东云仓主仓", + "dbWarehouse": "京仓-横林二仓京东云仓", + "wmsCoId": 15197138, + "openingQty": -3082, + "openingQtyExtra": 0, + "processIn": 0, + "purchaseReturn": 0, + "purchaseIn": 0, + "otherIn": 0, + "otherReturn": 0, + "salesOut": -48, + "stocktaking": 0, + "processOut": 0, + "transferOut": 0, + "salesReturn": 0, + "transferIn": 0, + "otherOut": 0 + } +]; + +module.exports = { + DAILY_INVENTORY_LEVEL1, + DAILY_INVENTORY_LEVEL2, + BASELINE_DATE, + FLOW_COLUMNS, + DAILY_INVENTORY_BASELINE, +}; diff --git a/backend/src/config/productInformationDatabase.js b/backend/src/config/productInformationDatabase.js new file mode 100644 index 0000000..3bc5606 --- /dev/null +++ b/backend/src/config/productInformationDatabase.js @@ -0,0 +1,169 @@ +/** + * 产品资料系统数据库连接模块(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, +}; diff --git a/backend/src/constants/sidebarPages.js b/backend/src/constants/sidebarPages.js index 0546a50..b9294ef 100644 --- a/backend/src/constants/sidebarPages.js +++ b/backend/src/constants/sidebarPages.js @@ -29,6 +29,16 @@ const SIDEBAR_PAGES = [ 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' }), { pageKey: 'settings', diff --git a/backend/src/index.js b/backend/src/index.js index ebac255..f80fda5 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,884 +1,894 @@ -// 后端服务入口 -const path = require('path'); - -require('dotenv').config(); -require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); - -const express = require('express'); -const cors = require('cors'); -const { CubejsServer } = require('@cubejs-backend/server'); -const mysql = require('mysql2/promise'); - -const installRoutes = require('./api/install'); -const authMiddleware = require('./middleware/auth'); -const { createPageDataCache } = require('./middleware/pageDataCache'); -const adminRoutes = require('./api/admin'); -const planningUploadRoutes = require('./api/planningUpload'); -const planningMayProductPublicRoutes = require('./api/planningMayProductPublic'); -const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal'); -const presaleExternalRoutes = require('./api/presaleExternal'); -const delistExternalRoutes = require('./api/delistExternal'); -const campaignCloudRoutes = require('./api/campaignCloud'); -const desktopClientRoutes = require('./api/desktopClient'); -const desktopPresenceRoutes = require('./api/desktopPresence'); -const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks'); -const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal'); -const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus'); -const thirdPartySsoRoutes = require('./api/thirdPartySso'); -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 modularStockMonitoringRoutes = require('./api/modularStockMonitoring'); -const database = require('./config/database'); -const cloudDb = require('./config/cloudDatabase'); -const jianSunDb = require('./config/jianSunDatabase'); -const redis = require('./config/redis'); -const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh'); -const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob'); -const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob'); -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 { - startAlibaba1688ImageSearchExpiryScheduler, -} = require('./jobs/alibaba1688ImageSearchExpiryJob'); -const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); -const { ensureLocalSchema } = require('./services/localSchema'); -const { - prewarmChannelHotData, - prewarmChannelIdDefaultSnapshots, - prewarmChannelIdDashboardDefaultSnapshot, - prewarmChannelStockMonitorDashboardDefaultSnapshot, -} = require('./services/channelAnalysis'); -const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis'); -const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard'); -const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService'); -const { - getPublicSystemConfigCache, - getStalePublicSystemConfigCache, - setPublicSystemConfigCache, -} = require('./services/publicSystemConfigCache'); -const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility'); -const { - shouldRequireImageSearchCredentials, - verifyImageSearchCredentials, -} = require('./services/imageSearchCredentialGuard'); - -const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max( - Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60, - 60 -); -const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max( - Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000, - 5 * 1000 -); -const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max( - Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300, - 30 -) * 1000; -const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max( - Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800, - 500 -); -const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '') - .trim() - .toLowerCase() === 'true'; - -function getBuildIdentity() { - return { - sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null, - sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null, - }; -} -const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max( - Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000, - 10000 -); -const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max( - Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000, - 10000 -); - -function shouldCachePlanningUploadResponse(body = {}) { - if (!body || body.error || body.success === false) { - return false; - } - - const payload = body.data || body; - if (payload?.pending || payload?.cache?.backgroundRefresh) { - return false; - } - - return true; -} - -function shouldHandlePlanningUploadPageCache(req) { - // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。 - // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。 - // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。 - // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。 - // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。 - if ( - 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; - } - - return true; -} - -const analysisPageCache = createPageDataCache({ - namespace: 'analysis', - redisTtlSeconds: 300, -}); -const channelPageCache = createPageDataCache({ - namespace: 'channel', - redisTtlSeconds: 300, - // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。 - shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path) - && !req.path.startsWith('/export-jobs'), -}); -const planningUploadPageCache = createPageDataCache({ - namespace: 'planning-upload', - version: 'v27-bypass-may-snapshot-pages-20260423', - redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS, - shouldHandleRequest: shouldHandlePlanningUploadPageCache, - shouldCacheBody: shouldCachePlanningUploadResponse, -}); -let serverMode = 'install'; - -function isStartupPrewarmEnabled() { - return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true'; -} - -function runGuardedPrewarm(label, handler) { - return runHeavyDatabaseTask(`prewarm:${label}`, handler); -} - -function withTimeout(promise, timeoutMs, fallback) { - let timer = null; - return Promise.race([ - promise.finally(() => { - if (timer) { - clearTimeout(timer); - } - }), - new Promise((resolve) => { - timer = setTimeout(() => resolve(fallback), timeoutMs); - if (typeof timer.unref === 'function') { - timer.unref(); - } - }), - ]); -} - -function hasCompleteCloudEnvConfig() { - return cloudDb.hasCompleteEnvConfig(); -} - -function getCloudEnvConfig() { - const config = cloudDb.getEnvBusinessDatabaseConfig(); - return { - driver: config.driver || 'mssql', - host: config.host, - port: config.port || 1433, - database: config.database, - user: config.user, - password: config.password, - ssl: Boolean(config.ssl), - }; -} - -function getBootstrapCloudConfig() { - return { - driver: 'mysql', - host: process.env.LOCAL_DB_HOST, - port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10), - database: process.env.LOCAL_DB_NAME, - user: process.env.LOCAL_DB_USER, - password: process.env.LOCAL_DB_PASSWORD, - ssl: false, - bootstrap: true, - }; -} - -// ===================== -// 从数据库获取云数据库配置 -// ===================== -async function getCloudDbConfig() { - let localPool; - try { - // 先连接本地数据库读取配置 - localPool = mysql.createPool({ - host: process.env.LOCAL_DB_HOST, - port: parseInt(process.env.LOCAL_DB_PORT || '3306'), - user: process.env.LOCAL_DB_USER, - password: process.env.LOCAL_DB_PASSWORD, - database: process.env.LOCAL_DB_NAME, - charset: 'utf8mb4', - waitForConnections: true, - connectionLimit: 2, - maxIdle: 2, - idleTimeout: 60000, - queueLimit: 10, - connectTimeout: 5000, - enableKeepAlive: true, - keepAliveInitialDelay: 0, - }); - - const [rows] = await localPool.query( - 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1', - ['active'] - ); - - if (rows.length > 0) { - const config = rows[0]; - if (config.use_env_config === 1 || config.use_env_config === true) { - if (hasCompleteCloudEnvConfig()) { - console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)'); - return getCloudEnvConfig(); - } - console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置'); - } - - console.log('📌 使用数据库配置(来自 database_configs 表)'); - return { - driver: config.driver || 'mysql', - host: config.host, - port: config.port || 3306, - database: config.database_name, - user: config.username, - password: config.password, - ssl: config.ssl === 1, - }; - } - } catch (error) { - console.warn('⚠️ 无法从数据库读取配置:', error.message); - } finally { - if (localPool) { - await localPool.end().catch((error) => { - console.warn('⚠️ 关闭系统库临时连接池失败:', error.message); - }); - } - } - - // 回退到环境变量配置 - if (hasCompleteCloudEnvConfig()) { - console.log('📌 使用环境变量配置(CLOUD_DB_*)'); - return getCloudEnvConfig(); - } - - console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式'); - return getBootstrapCloudConfig(); -} - -function applyCommonMiddleware(app) { - app.set('trust proxy', 1); - app.use(cors()); - app.use(express.json({ - limit: '15mb', - verify: (req, _res, buffer) => { - req.rawBody = buffer.toString('utf8'); - }, - })); -} - -function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) { - app.get('/api/admin/system-config', async (_req, res) => { - const now = Date.now(); - const cachedConfig = getPublicSystemConfigCache(now); - if (cachedConfig) { - return res.json(cachedConfig); - } - - try { - const configs = await Promise.race([ - database.local.query('SELECT config_key, config_value, config_type FROM system_config'), - new Promise((_, reject) => { - setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS); - }), - ]); - const result = {}; - filterPublicSystemConfigs(configs).forEach((config) => { - if (config.config_type === 'boolean') { - result[config.config_key] = config.config_value === 'true' || config.config_value === '1'; - } else if (config.config_type === 'number') { - result[config.config_key] = Number(config.config_value); - } else { - result[config.config_key] = config.config_value; - } - }); - setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now); - res.json(result); - } catch (error) { - const staleConfig = getStalePublicSystemConfigCache(); - if (staleConfig) { - return res.json(staleConfig); - } - - if (serverMode === 'install' || serverMode === 'database_unavailable') { - return res.json({ - site_name: fallbackConfig.siteName, - site_description: fallbackConfig.siteDescription, - logo: '', - }); - } - - console.warn('获取系统配置超时或失败,返回兜底配置:', error.message); - return res.json({ - site_name: fallbackConfig.siteName, - site_description: fallbackConfig.siteDescription, - logo: '', - }); - } - }); -} - -function registerInstallModeRoutes(app, installStatus) { - const unavailableHandler = (_req, res) => { - res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' }); - }; - - app.get('/health', async (_req, res) => { - const status = await getInstallStatus(); - res.json({ - status: 'install_required', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - install: status, - }); - }); - - app.use('/api/install', installRoutes); - registerPublicSystemConfigRoute(app); - - app.get('/api/auth/providers', (_req, res) => { - res.json({ - authBaseUrl: '', - dingtalkEnabled: false, - googleEnabled: false, - installRequired: true, - }); - }); - app.get('/api/auth/dingtalk/login', unavailableHandler); - app.get('/api/auth/google/login', unavailableHandler); - app.post('/api/auth/login', unavailableHandler); - app.post('/api/auth/callback', unavailableHandler); - app.get('/api/auth/verify', (_req, res) => { - res.status(401).json({ valid: false, installRequired: true }); - }); - - app.use('/api/admin', (_req, res) => { - res.status(503).json({ - error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号', - }); - }); - - console.log('='.repeat(50)); - console.log('🛠️ 系统已进入首次安装模式'); - console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); - console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`); - console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`); - console.log('='.repeat(50)); -} - -async function startInstallServer(installStatus) { - serverMode = 'install'; - const app = express(); - applyCommonMiddleware(app); - registerInstallModeRoutes(app, installStatus); - - const port = parseInt(process.env.PORT || '4000', 10); - await new Promise((resolve) => { - app.listen(port, resolve); - }); -} - -function registerDatabaseUnavailableRoutes(app, installStatus) { - const unavailableHandler = (_req, res) => { - res.status(503).json({ - error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务', - databaseUnavailable: true, - installRequired: false, - }); - }; - - app.get('/health', async (_req, res) => { - const status = await getInstallStatus(); - res.status(503).json({ - status: 'database_unavailable', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - install: status, - }); - }); - - app.use('/api/install', installRoutes); - registerPublicSystemConfigRoute(app); - - app.get('/api/auth/providers', (_req, res) => { - res.json({ - authBaseUrl: '', - dingtalkEnabled: false, - googleEnabled: false, - installRequired: false, - databaseUnavailable: true, - }); - }); - app.get('/api/auth/verify', (_req, res) => { - res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false }); - }); - app.post('/api/auth/login', unavailableHandler); - app.post('/api/auth/callback', unavailableHandler); - app.get('/api/auth/dingtalk/login', unavailableHandler); - app.get('/api/auth/google/login', unavailableHandler); - app.use('/api', unavailableHandler); - - console.log('='.repeat(50)); - console.log('🚧 系统数据库暂时无法连接,已进入降级模式'); - console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); - console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`); - console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务'); - console.log('='.repeat(50)); -} - -async function startDatabaseUnavailableServer(installStatus) { - serverMode = 'database_unavailable'; - const app = express(); - applyCommonMiddleware(app); - registerDatabaseUnavailableRoutes(app, installStatus); - - const port = parseInt(process.env.PORT || '4000', 10); - await new Promise((resolve) => { - app.listen(port, resolve); - }); -} - -async function createFullModeApp(cloudConfig) { - const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql'; - console.log(`📊 数据库类型: ${dbType}`); - - const port = parseInt(process.env.PORT || '4000', 10); - - if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') { - console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务'); - const app = express(); - await new Promise((resolve) => { - app.listen(port, resolve); - }); - - return { - app, - cubeMode: 'disabled', - cubeError: 'DISABLE_CUBEJS=true', - }; - } - - try { - const cubejsServer = new CubejsServer({ - schemaPath: 'model', - dbType: dbType, - logger: (msg, params) => { - console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : ''); - }, - driverFactory: () => { - // 根据 driver 类型选择驱动 - if (cloudConfig.driver === 'mssql') { - const MSSQLDriver = require('@cubejs-backend/mssql-driver'); - return new MSSQLDriver({ - server: cloudConfig.host, - port: cloudConfig.port, - database: cloudConfig.database, - user: cloudConfig.user, - password: cloudConfig.password, - options: { - encrypt: cloudConfig.ssl, - trustServerCertificate: true, - }, - }); - } - - // 默认使用 MySQL - const MySQLDriver = require('@cubejs-backend/mysql-driver'); - return new MySQLDriver({ - database: cloudConfig.database, - host: cloudConfig.host, - port: cloudConfig.port, - user: cloudConfig.user, - password: cloudConfig.password, - ssl: cloudConfig.ssl, - }); - }, - apiSecret: process.env.CUBEJS_API_SECRET, - scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER, - jwt: { - key: process.env.CUBEJS_SECRET, - algorithms: ['HS256'], - }, - }); - - const { app } = await cubejsServer.listen(); - return { - app, - cubeMode: 'cube', - cubeError: null, - }; - } catch (error) { - console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message); - const app = express(); - await new Promise((resolve) => { - app.listen(port, resolve); - }); - - return { - app, - cubeMode: 'express-fallback', - cubeError: error.message, - }; - } -} - -// ===================== -// 启动服务 -// ===================== -async function startServer(options = {}) { - const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true'; - const { skipLocalSchema = envSkipLocalSchema } = options; - serverMode = 'full'; - if (skipLocalSchema) { - console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API'); - } else { - await ensureLocalSchema(); - } - - if (shouldRequireImageSearchCredentials()) { - const credentialStatus = await verifyImageSearchCredentials(); - console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`); - } - - // 获取云数据库配置 - const cloudConfig = await getCloudDbConfig(); - if (cloudConfig.bootstrap) { - console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); - } else { - console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); - } - - // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。 - const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig); - - applyCommonMiddleware(cubejsApp); - - // ===================== - // 健康检查 (多数据源) - // ===================== - cubejsApp.get('/health', async (_req, res) => { - const [systemTest, businessTest, redisTest] = await Promise.all([ - withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }), - withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }), - withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }), - ]); - - res.json({ - status: 'ok', - timestamp: new Date().toISOString(), - build: getBuildIdentity(), - databases: { - system: systemTest, - local: systemTest, - cloud: businessTest, - }, - services: { - redis: redisTest, - cube: { - success: cubeMode === 'cube', - mode: cubeMode, - message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`, - }, - }, - }); - }); - - // ===================== - // 认证 API - // ===================== - cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders); - cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin); - cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin); - cubejsApp.post('/api/auth/login', require('./api/auth').login); - cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview); - cubejsApp.post('/api/auth/callback', require('./api/auth').callback); - cubejsApp.get('/api/auth/verify', require('./api/auth').verify); - - // ===================== - // 公开访问的 API(无需认证) - // ===================== - cubejsApp.use('/api/install', installRoutes); - cubejsApp.use('/api/sso', thirdPartySsoRoutes); - cubejsApp.use('/api/desktop-client', desktopClientRoutes); - cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes); - cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes); - cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes); - cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes); - cubejsApp.use('/api/campaign', campaignCloudRoutes); - cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes); - cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes); - cubejsApp.use('/api/external/presale', presaleExternalRoutes); - - // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo) - registerPublicSystemConfigRoute(cubejsApp); - - // ===================== - // 需要认证的 API - // ===================== - cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes); - cubejsApp.use('/api/admin', authMiddleware, adminRoutes); - 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/planning-upload', authMiddleware, planningUploadPageCache, planningUploadRoutes); - cubejsApp.use('/api/channel', authMiddleware, channelPageCache, channelRoutes); - cubejsApp.use('/api/product-monitoring', authMiddleware, productMonitoringRoutes); - cubejsApp.use('/api/modular-stock-monitoring', authMiddleware, modularStockMonitoringRoutes); - - // ===================== - // 数据分析 API(公开访问,可根据需要添加认证) - // ===================== - cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes); - - // ===================== - // 数据刷新 API(需要认证) - // ===================== - cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => { - try { - const { date } = req.body; - console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`); - const result = await manualRefresh(date); - res.json({ success: true, ...result }); - } catch (error) { - res.status(500).json({ success: false, error: error.message }); - } - }); - - // ===================== - // 重新加载云数据库配置 API - // ===================== - cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => { - try { - console.log('🔄 正在重新加载云数据库配置...'); - res.json({ - success: true, - message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang' - }); - } catch (error) { - res.status(500).json({ success: false, message: error.message }); - } - }); - - console.log('='.repeat(50)); - console.log('🚀 后端服务已启动'); - console.log(`📡 服务地址: http://localhost:4000`); - console.log(cubeMode === 'cube' - ? '📊 Cube.js API: http://localhost:4000/cubejs-api' - : '📊 Cube.js API: 已降级不可用,业务 API 继续运行'); - - // 测试数据库连接 - console.log('\n📦 数据库连接测试:'); - const [systemTest, businessTest, redisTest] = await Promise.all([ - withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }), - withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }), - withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }), - ]); - console.log(` [系统] ${systemTest.message}`); - console.log(` [业务] ${businessTest.message}`); - console.log(` [缓存] ${redisTest.message}`); - console.log('='.repeat(50)); - - if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') { - console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理'); - return; - } - - startScheduler(); - startStockSelectionSchedulers(); - startTaobaoProductEmbeddingSchedulers(); - startPddSkuDimensionSnapshotSchedulers(); - startPlanningMayProductSnapshotSchedulers(); - startPlanningDingWorkflowSyncSchedulers(); - startOpsBIncentiveSnapshotSchedulers(); - startGoodsFirstSeenScheduler(); - startAlibaba1688ImageSearchExpiryScheduler(); - - // Keep the first selection-pool page request from paying the remote schema - // inspection round-trip over the planning database tunnel. - setTimeout(() => { - warmSelectionFissionReadSchema() - .then(() => console.log('[SelectionFission] 列表读取结构预热完成')) - .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message)); - }, 1000); - - if (isStartupPrewarmEnabled()) { - setTimeout(() => { - if (typeof prewarmChannelHotData === 'function') { - runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => { - console.warn('⚠️ 渠道默认查询预热失败:', error.message); - }); - } - }, 8000); - - setTimeout(() => { - if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { - runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { - console.warn('⚠️ 分析页默认查询预热失败:', error.message); - }); - } - }, 10000); - - - - if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { - const analysisPrewarmTimer = setInterval(() => { - runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { - console.warn('⚠️ 分析页默认查询定时预热失败:', error.message); - }); - }, 10 * 60 * 1000); - - if (typeof analysisPrewarmTimer.unref === 'function') { - analysisPrewarmTimer.unref(); - } - } - - - } else { - console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启'); - } - - if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') { - if (typeof prewarmChannelIdDefaultSnapshots === 'function') { - setTimeout(() => { - runGuardedPrewarm( - 'channel-id-analysis-resilience', - () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message); - }); - }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS); - } - - if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') { - setTimeout(() => { - runGuardedPrewarm( - 'channel-id-dashboard-resilience', - () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message); - }); - }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000); - } - - setTimeout(() => { - runGuardedPrewarm( - 'channel-stock-monitor-resilience', - () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true }) - ).catch((error) => { - console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message); - }); - }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS); - } - - if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false' - && typeof prewarmBrandAnalysisDefault === 'function') { - setTimeout(() => { - runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault()) - .then((result) => { - console.log( - `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}` - ); - }) - .catch((error) => { - console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message); - }); - }, BRAND_ANALYSIS_PREWARM_DELAY_MS); - } - - const cron = require('node-cron'); - - if (typeof prewarmChannelHotData === 'function') { - cron.schedule('0 1 * * *', async () => { - console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...'); - await runGuardedPrewarm( - 'channel-hot-data:daily', - () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true }) - ); - }, { - timezone: 'Asia/Shanghai', - }); - console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行'); - } - - if (process.env.NODE_ENV === 'production') { - const alertChecker = require('./services/alertChecker'); - - cron.schedule('*/5 * * * *', async () => { - console.log('🔔 执行预警检查...'); - await alertChecker.checkAllRules(); - }); - } -} - -async function bootstrap() { - const installStatus = await getInstallStatus(); - if (installStatus.required) { - await startInstallServer(installStatus); - return; - } - - if (installStatus.databaseUnavailable) { - console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`); - await startServer({ skipLocalSchema: true }); - return; - } - - await startServer(); -} - -bootstrap().catch((error) => { - console.error('❌ 服务启动失败:', error); - process.exit(1); -}); - -async function shutdownGracefully() { - await Promise.allSettled([ - cloudDb.close(), - jianSunDb.close(), - database.close(), - redis.close(), - ]); -} - -// 优雅关闭 -process.on('SIGTERM', async () => { - console.log('正在关闭服务...'); - await shutdownGracefully(); - process.exit(0); -}); - -process.on('SIGINT', async () => { - console.log('正在关闭服务...'); - await shutdownGracefully(); - process.exit(0); -}); +// 后端服务入口 +const path = require('path'); + +require('dotenv').config(); +require('dotenv').config({ path: path.resolve(__dirname, '../.env') }); + +const express = require('express'); +const cors = require('cors'); +const { CubejsServer } = require('@cubejs-backend/server'); +const mysql = require('mysql2/promise'); + +const installRoutes = require('./api/install'); +const authMiddleware = require('./middleware/auth'); +const { createPageDataCache } = require('./middleware/pageDataCache'); +const adminRoutes = require('./api/admin'); +const planningUploadRoutes = require('./api/planningUpload'); +const planningMayProductPublicRoutes = require('./api/planningMayProductPublic'); +const planningSelectionFissionExternalRoutes = require('./api/planningSelectionFissionExternal'); +const presaleExternalRoutes = require('./api/presaleExternal'); +const delistExternalRoutes = require('./api/delistExternal'); +const campaignCloudRoutes = require('./api/campaignCloud'); +const desktopClientRoutes = require('./api/desktopClient'); +const desktopPresenceRoutes = require('./api/desktopPresence'); +const desktopExecutionTaskRoutes = require('./api/desktopExecutionTasks'); +const alibaba1688ProductExternalRoutes = require('./api/alibaba1688ProductExternal'); +const taobaoDetailPlusRoutes = require('./api/taobaoDetailPlus'); +const thirdPartySsoRoutes = require('./api/thirdPartySso'); +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 modularStockMonitoringRoutes = require('./api/modularStockMonitoring'); +const mkhBomRoutes = require('./api/mkhBom'); +const dailyInventoryRoutes = require('./api/dailyInventory'); +const database = require('./config/database'); +const cloudDb = require('./config/cloudDatabase'); +const jianSunDb = require('./config/jianSunDatabase'); +const redis = require('./config/redis'); +const { startScheduler, manualRefresh } = require('./jobs/dailyRefresh'); +const { startStockSelectionSchedulers } = require('./jobs/stockSelectionSyncJob'); +const { startTaobaoProductEmbeddingSchedulers } = require('./jobs/taobaoProductEmbeddingSyncJob'); +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 { + startAlibaba1688ImageSearchExpiryScheduler, +} = require('./jobs/alibaba1688ImageSearchExpiryJob'); +const { + startMkhReplenishAlertScheduler, +} = require('./jobs/mkhReplenishAlertJob'); +const { DEFAULT_SITE_CONFIG, getInstallStatus } = require('./services/installService'); +const { ensureLocalSchema } = require('./services/localSchema'); +const { + prewarmChannelHotData, + prewarmChannelIdDefaultSnapshots, + prewarmChannelIdDashboardDefaultSnapshot, + prewarmChannelStockMonitorDashboardDefaultSnapshot, +} = require('./services/channelAnalysis'); +const { prewarmBrandAnalysisDefault } = require('./services/brandAnalysis'); +const { runHeavyDatabaseTask } = require('./services/databaseLoadGuard'); +const { warmSelectionFissionReadSchema } = require('./services/planningSelectionFissionService'); +const { + getPublicSystemConfigCache, + getStalePublicSystemConfigCache, + setPublicSystemConfigCache, +} = require('./services/publicSystemConfigCache'); +const { filterPublicSystemConfigs } = require('./services/systemConfigVisibility'); +const { + shouldRequireImageSearchCredentials, + verifyImageSearchCredentials, +} = require('./services/imageSearchCredentialGuard'); + +const PLANNING_PAGE_REDIS_TTL_SECONDS = Math.max( + Number(process.env.PLANNING_PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 23 * 60 * 60, + 60 +); +const BRAND_ANALYSIS_PREWARM_DELAY_MS = Math.max( + Number(process.env.BRAND_ANALYSIS_PREWARM_DELAY_MS) || 20 * 1000, + 5 * 1000 +); +const PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS = Math.max( + Number(process.env.PUBLIC_SYSTEM_CONFIG_CACHE_TTL_SECONDS) || 300, + 30 +) * 1000; +const PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS = Math.max( + Number(process.env.PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS) || 1800, + 500 +); +const CUBE_SCHEDULED_REFRESH_TIMER = String(process.env.CUBEJS_SCHEDULED_REFRESH_TIMER || '') + .trim() + .toLowerCase() === 'true'; + +function getBuildIdentity() { + return { + sourceGitSha: String(process.env.SOURCE_GIT_SHA || '').trim() || null, + sourceGitRef: String(process.env.SOURCE_GIT_REF || '').trim() || null, + }; +} +const CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS = Math.max( + Number(process.env.CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS) || 90000, + 10000 +); +const CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS = Math.max( + Number(process.env.CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS) || 30000, + 10000 +); + +function shouldCachePlanningUploadResponse(body = {}) { + if (!body || body.error || body.success === false) { + return false; + } + + const payload = body.data || body; + if (payload?.pending || payload?.cache?.backgroundRefresh) { + return false; + } + + return true; +} + +function shouldHandlePlanningUploadPageCache(req) { + // 企划页首页会聚合 MAY 快照统计;MAY 商品页本身也依赖最新成功批次。 + // MAY 渠道洞察也依赖最新店铺/覆盖快照,外层整页缓存会把旧列口径整天顶住。 + // 榜单数据分析现在也依赖实时机会/竞争诊断结果,外层整页缓存会把旧字段结构整天顶住。 + // 抖音/小红书市场页支持实时批次切换,整页缓存会把旧批次和旧字段结构顶住。 + // 这些接口必须直接读实体表/轻缓存,不能再被 23 小时整页 Redis 顶住。 + if ( + 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; + } + + return true; +} + +const analysisPageCache = createPageDataCache({ + namespace: 'analysis', + redisTtlSeconds: 300, +}); +const channelPageCache = createPageDataCache({ + namespace: 'channel', + redisTtlSeconds: 300, + // ID 分析内部已有按口径版本控制的缓存;导出接口返回 CSV,不能走整页 JSON 缓存。 + shouldHandleRequest: (req) => !['/id-analysis', '/export'].includes(req.path) + && !req.path.startsWith('/export-jobs'), +}); +const planningUploadPageCache = createPageDataCache({ + namespace: 'planning-upload', + version: 'v27-bypass-may-snapshot-pages-20260423', + redisTtlSeconds: PLANNING_PAGE_REDIS_TTL_SECONDS, + shouldHandleRequest: shouldHandlePlanningUploadPageCache, + shouldCacheBody: shouldCachePlanningUploadResponse, +}); +let serverMode = 'install'; + +function isStartupPrewarmEnabled() { + return String(process.env.ENABLE_STARTUP_PREWARM || '').toLowerCase() === 'true'; +} + +function runGuardedPrewarm(label, handler) { + return runHeavyDatabaseTask(`prewarm:${label}`, handler); +} + +function withTimeout(promise, timeoutMs, fallback) { + let timer = null; + return Promise.race([ + promise.finally(() => { + if (timer) { + clearTimeout(timer); + } + }), + new Promise((resolve) => { + timer = setTimeout(() => resolve(fallback), timeoutMs); + if (typeof timer.unref === 'function') { + timer.unref(); + } + }), + ]); +} + +function hasCompleteCloudEnvConfig() { + return cloudDb.hasCompleteEnvConfig(); +} + +function getCloudEnvConfig() { + const config = cloudDb.getEnvBusinessDatabaseConfig(); + return { + driver: config.driver || 'mssql', + host: config.host, + port: config.port || 1433, + database: config.database, + user: config.user, + password: config.password, + ssl: Boolean(config.ssl), + }; +} + +function getBootstrapCloudConfig() { + return { + driver: 'mysql', + host: process.env.LOCAL_DB_HOST, + port: parseInt(process.env.LOCAL_DB_PORT || '3306', 10), + database: process.env.LOCAL_DB_NAME, + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + ssl: false, + bootstrap: true, + }; +} + +// ===================== +// 从数据库获取云数据库配置 +// ===================== +async function getCloudDbConfig() { + let localPool; + try { + // 先连接本地数据库读取配置 + localPool = mysql.createPool({ + host: process.env.LOCAL_DB_HOST, + port: parseInt(process.env.LOCAL_DB_PORT || '3306'), + user: process.env.LOCAL_DB_USER, + password: process.env.LOCAL_DB_PASSWORD, + database: process.env.LOCAL_DB_NAME, + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: 2, + maxIdle: 2, + idleTimeout: 60000, + queueLimit: 10, + connectTimeout: 5000, + enableKeepAlive: true, + keepAliveInitialDelay: 0, + }); + + const [rows] = await localPool.query( + 'SELECT host, port, database_name, username, password, `ssl`, driver, use_env_config FROM database_configs WHERE is_default = 1 AND status = ? LIMIT 1', + ['active'] + ); + + if (rows.length > 0) { + const config = rows[0]; + if (config.use_env_config === 1 || config.use_env_config === true) { + if (hasCompleteCloudEnvConfig()) { + console.log('📌 使用 .env 业务数据库配置(CLOUD_DB_*)'); + return getCloudEnvConfig(); + } + console.warn('⚠️ 已开启 .env 业务数据库配置,但 CLOUD_DB_* 不完整,继续使用 database_configs 表配置'); + } + + console.log('📌 使用数据库配置(来自 database_configs 表)'); + return { + driver: config.driver || 'mysql', + host: config.host, + port: config.port || 3306, + database: config.database_name, + user: config.username, + password: config.password, + ssl: config.ssl === 1, + }; + } + } catch (error) { + console.warn('⚠️ 无法从数据库读取配置:', error.message); + } finally { + if (localPool) { + await localPool.end().catch((error) => { + console.warn('⚠️ 关闭系统库临时连接池失败:', error.message); + }); + } + } + + // 回退到环境变量配置 + if (hasCompleteCloudEnvConfig()) { + console.log('📌 使用环境变量配置(CLOUD_DB_*)'); + return getCloudEnvConfig(); + } + + console.log('⚙️ 业务数据库尚未配置,已进入系统初始化模式'); + return getBootstrapCloudConfig(); +} + +function applyCommonMiddleware(app) { + app.set('trust proxy', 1); + app.use(cors()); + app.use(express.json({ + limit: '15mb', + verify: (req, _res, buffer) => { + req.rawBody = buffer.toString('utf8'); + }, + })); +} + +function registerPublicSystemConfigRoute(app, fallbackConfig = DEFAULT_SITE_CONFIG) { + app.get('/api/admin/system-config', async (_req, res) => { + const now = Date.now(); + const cachedConfig = getPublicSystemConfigCache(now); + if (cachedConfig) { + return res.json(cachedConfig); + } + + try { + const configs = await Promise.race([ + database.local.query('SELECT config_key, config_value, config_type FROM system_config'), + new Promise((_, reject) => { + setTimeout(() => reject(new Error('public system config query timeout')), PUBLIC_SYSTEM_CONFIG_QUERY_TIMEOUT_MS); + }), + ]); + const result = {}; + filterPublicSystemConfigs(configs).forEach((config) => { + if (config.config_type === 'boolean') { + result[config.config_key] = config.config_value === 'true' || config.config_value === '1'; + } else if (config.config_type === 'number') { + result[config.config_key] = Number(config.config_value); + } else { + result[config.config_key] = config.config_value; + } + }); + setPublicSystemConfigCache(result, PUBLIC_SYSTEM_CONFIG_CACHE_TTL_MS, now); + res.json(result); + } catch (error) { + const staleConfig = getStalePublicSystemConfigCache(); + if (staleConfig) { + return res.json(staleConfig); + } + + if (serverMode === 'install' || serverMode === 'database_unavailable') { + return res.json({ + site_name: fallbackConfig.siteName, + site_description: fallbackConfig.siteDescription, + logo: '', + }); + } + + console.warn('获取系统配置超时或失败,返回兜底配置:', error.message); + return res.json({ + site_name: fallbackConfig.siteName, + site_description: fallbackConfig.siteDescription, + logo: '', + }); + } + }); +} + +function registerInstallModeRoutes(app, installStatus) { + const unavailableHandler = (_req, res) => { + res.status(503).json({ error: '系统尚未完成安装,请先访问 /install 完成初始化' }); + }; + + app.get('/health', async (_req, res) => { + const status = await getInstallStatus(); + res.json({ + status: 'install_required', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + install: status, + }); + }); + + app.use('/api/install', installRoutes); + registerPublicSystemConfigRoute(app); + + app.get('/api/auth/providers', (_req, res) => { + res.json({ + authBaseUrl: '', + dingtalkEnabled: false, + googleEnabled: false, + installRequired: true, + }); + }); + app.get('/api/auth/dingtalk/login', unavailableHandler); + app.get('/api/auth/google/login', unavailableHandler); + app.post('/api/auth/login', unavailableHandler); + app.post('/api/auth/callback', unavailableHandler); + app.get('/api/auth/verify', (_req, res) => { + res.status(401).json({ valid: false, installRequired: true }); + }); + + app.use('/api/admin', (_req, res) => { + res.status(503).json({ + error: '系统尚未完成安装,请先访问 /install 初始化系统数据库并创建管理员账号', + }); + }); + + console.log('='.repeat(50)); + console.log('🛠️ 系统已进入首次安装模式'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(`🧭 安装入口: http://localhost:${process.env.PORT || 4000}/install`); + console.log(`📦 安装状态: ${installStatus.systemDatabase.reason}`); + console.log('='.repeat(50)); +} + +async function startInstallServer(installStatus) { + serverMode = 'install'; + const app = express(); + applyCommonMiddleware(app); + registerInstallModeRoutes(app, installStatus); + + const port = parseInt(process.env.PORT || '4000', 10); + await new Promise((resolve) => { + app.listen(port, resolve); + }); +} + +function registerDatabaseUnavailableRoutes(app, installStatus) { + const unavailableHandler = (_req, res) => { + res.status(503).json({ + error: '系统数据库暂时无法连接,请检查 MySQL 网络、白名单或安全组后重启服务', + databaseUnavailable: true, + installRequired: false, + }); + }; + + app.get('/health', async (_req, res) => { + const status = await getInstallStatus(); + res.status(503).json({ + status: 'database_unavailable', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + install: status, + }); + }); + + app.use('/api/install', installRoutes); + registerPublicSystemConfigRoute(app); + + app.get('/api/auth/providers', (_req, res) => { + res.json({ + authBaseUrl: '', + dingtalkEnabled: false, + googleEnabled: false, + installRequired: false, + databaseUnavailable: true, + }); + }); + app.get('/api/auth/verify', (_req, res) => { + res.status(503).json({ valid: false, databaseUnavailable: true, installRequired: false }); + }); + app.post('/api/auth/login', unavailableHandler); + app.post('/api/auth/callback', unavailableHandler); + app.get('/api/auth/dingtalk/login', unavailableHandler); + app.get('/api/auth/google/login', unavailableHandler); + app.use('/api', unavailableHandler); + + console.log('='.repeat(50)); + console.log('🚧 系统数据库暂时无法连接,已进入降级模式'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(`📦 数据库状态: ${installStatus.systemDatabase.reason}`); + console.log('💡 这不是首次安装,请检查 MySQL 白名单/安全组/网络后重启服务'); + console.log('='.repeat(50)); +} + +async function startDatabaseUnavailableServer(installStatus) { + serverMode = 'database_unavailable'; + const app = express(); + applyCommonMiddleware(app); + registerDatabaseUnavailableRoutes(app, installStatus); + + const port = parseInt(process.env.PORT || '4000', 10); + await new Promise((resolve) => { + app.listen(port, resolve); + }); +} + +async function createFullModeApp(cloudConfig) { + const dbType = cloudConfig.driver === 'mssql' ? 'mssql' : 'mysql'; + console.log(`📊 数据库类型: ${dbType}`); + + const port = parseInt(process.env.PORT || '4000', 10); + + if (String(process.env.DISABLE_CUBEJS || '').trim().toLowerCase() === 'true') { + console.warn('⏸️ 已通过 DISABLE_CUBEJS=true 跳过 Cube.js,仅启动业务 API 服务'); + const app = express(); + await new Promise((resolve) => { + app.listen(port, resolve); + }); + + return { + app, + cubeMode: 'disabled', + cubeError: 'DISABLE_CUBEJS=true', + }; + } + + try { + const cubejsServer = new CubejsServer({ + schemaPath: 'model', + dbType: dbType, + logger: (msg, params) => { + console.log(`[Cube.js] ${msg}`, params !== undefined ? JSON.stringify(params) : ''); + }, + driverFactory: () => { + // 根据 driver 类型选择驱动 + if (cloudConfig.driver === 'mssql') { + const MSSQLDriver = require('@cubejs-backend/mssql-driver'); + return new MSSQLDriver({ + server: cloudConfig.host, + port: cloudConfig.port, + database: cloudConfig.database, + user: cloudConfig.user, + password: cloudConfig.password, + options: { + encrypt: cloudConfig.ssl, + trustServerCertificate: true, + }, + }); + } + + // 默认使用 MySQL + const MySQLDriver = require('@cubejs-backend/mysql-driver'); + return new MySQLDriver({ + database: cloudConfig.database, + host: cloudConfig.host, + port: cloudConfig.port, + user: cloudConfig.user, + password: cloudConfig.password, + ssl: cloudConfig.ssl, + }); + }, + apiSecret: process.env.CUBEJS_API_SECRET, + scheduledRefreshTimer: CUBE_SCHEDULED_REFRESH_TIMER, + jwt: { + key: process.env.CUBEJS_SECRET, + algorithms: ['HS256'], + }, + }); + + const { app } = await cubejsServer.listen(); + return { + app, + cubeMode: 'cube', + cubeError: null, + }; + } catch (error) { + console.warn('⚠️ Cube.js 启动失败,已降级为业务 API 服务:', error.message); + const app = express(); + await new Promise((resolve) => { + app.listen(port, resolve); + }); + + return { + app, + cubeMode: 'express-fallback', + cubeError: error.message, + }; + } +} + +// ===================== +// 启动服务 +// ===================== +async function startServer(options = {}) { + const envSkipLocalSchema = String(process.env.SKIP_LOCAL_SCHEMA_CHECK || '').trim().toLowerCase() === 'true'; + const { skipLocalSchema = envSkipLocalSchema } = options; + serverMode = 'full'; + if (skipLocalSchema) { + console.warn('⚠️ 系统数据库暂时不可用,已跳过本次本地 schema 检查,继续启动业务 API'); + } else { + await ensureLocalSchema(); + } + + if (shouldRequireImageSearchCredentials()) { + const credentialStatus = await verifyImageSearchCredentials(); + console.log(`✅ 图搜凭据守卫通过:公司=${credentialStatus.company.source}, 1688=${credentialStatus.alibaba1688.source}`); + } + + // 获取云数据库配置 + const cloudConfig = await getCloudDbConfig(); + if (cloudConfig.bootstrap) { + console.log(`📊 初始化模式占位数据源: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); + } else { + console.log(`📊 云数据库: ${cloudConfig.host}:${cloudConfig.port}/${cloudConfig.database}`); + } + + // 启动 Cube.js 服务器;如 Cube Store 不可用,降级为普通业务 API 服务,避免整站不可用。 + const { app: cubejsApp, cubeMode, cubeError } = await createFullModeApp(cloudConfig); + + applyCommonMiddleware(cubejsApp); + + // ===================== + // 健康检查 (多数据源) + // ===================== + cubejsApp.get('/health', async (_req, res) => { + const [systemTest, businessTest, redisTest] = await Promise.all([ + withTimeout(database.local.test(), 5000, { success: false, message: '系统数据库检测超时' }), + withTimeout(cloudDb.test(), 5000, { success: false, message: '业务数据库检测超时' }), + withTimeout(redis.test(), 3000, { success: false, message: 'Redis 检测超时' }), + ]); + + res.json({ + status: 'ok', + timestamp: new Date().toISOString(), + build: getBuildIdentity(), + databases: { + system: systemTest, + local: systemTest, + cloud: businessTest, + }, + services: { + redis: redisTest, + cube: { + success: cubeMode === 'cube', + mode: cubeMode, + message: cubeMode === 'cube' ? 'Cube.js 服务正常' : `Cube.js 已降级: ${cubeError || '未知错误'}`, + }, + }, + }); + }); + + // ===================== + // 认证 API + // ===================== + cubejsApp.get('/api/auth/providers', require('./api/auth').getProviders); + cubejsApp.get('/api/auth/dingtalk/login', require('./api/auth').startDingTalkLogin); + cubejsApp.get('/api/auth/google/login', require('./api/auth').startGoogleLogin); + cubejsApp.post('/api/auth/login', require('./api/auth').login); + cubejsApp.post('/api/auth/local-preview', require('./api/auth').localPreview); + cubejsApp.post('/api/auth/callback', require('./api/auth').callback); + cubejsApp.get('/api/auth/verify', require('./api/auth').verify); + + // ===================== + // 公开访问的 API(无需认证) + // ===================== + cubejsApp.use('/api/install', installRoutes); + cubejsApp.use('/api/sso', thirdPartySsoRoutes); + cubejsApp.use('/api/desktop-client', desktopClientRoutes); + cubejsApp.use('/api/public/taobao-detail-plus', taobaoDetailPlusRoutes); + cubejsApp.use('/api/public/planning-may-product', planningMayProductPublicRoutes); + cubejsApp.use('/api/external/selection-fission/presale', presaleExternalRoutes); + cubejsApp.use('/api/external/selection-fission/delist', delistExternalRoutes); + cubejsApp.use('/api/campaign', campaignCloudRoutes); + cubejsApp.use('/api/external/selection-fission/1688-products', alibaba1688ProductExternalRoutes); + cubejsApp.use('/api/external/selection-fission', planningSelectionFissionExternalRoutes); + cubejsApp.use('/api/external/presale', presaleExternalRoutes); + + // 系统配置的 GET 接口公开访问(登录页需要获取网站名称和 Logo) + registerPublicSystemConfigRoute(cubejsApp); + + // ===================== + // 需要认证的 API + // ===================== + cubejsApp.use('/api/admin/sso-clients', authMiddleware, authMiddleware.requireRole('admin'), thirdPartySsoAdminRoutes); + cubejsApp.use('/api/admin', authMiddleware, adminRoutes); + 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/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/modular-stock-monitoring', authMiddleware, modularStockMonitoringRoutes); + cubejsApp.use('/api/daily-inventory', authMiddleware, dailyInventoryRoutes); + cubejsApp.use('/api/mkh-bom', authMiddleware, mkhBomRoutes); + + // ===================== + // 数据分析 API(公开访问,可根据需要添加认证) + // ===================== + cubejsApp.use('/api/analysis', analysisPageCache, analysisRoutes); + + // ===================== + // 数据刷新 API(需要认证) + // ===================== + cubejsApp.post('/api/admin/refresh', authMiddleware, async (req, res) => { + try { + const { date } = req.body; + console.log(`[API] 手动触发数据刷新: ${date || '昨天'}`); + const result = await manualRefresh(date); + res.json({ success: true, ...result }); + } catch (error) { + res.status(500).json({ success: false, error: error.message }); + } + }); + + // ===================== + // 重新加载云数据库配置 API + // ===================== + cubejsApp.post('/api/admin/database/reload', authMiddleware, async (req, res) => { + try { + console.log('🔄 正在重新加载云数据库配置...'); + res.json({ + success: true, + message: '配置已保存,需要重启服务才能生效。请执行: pm2 restart maixiang' + }); + } catch (error) { + res.status(500).json({ success: false, message: error.message }); + } + }); + + console.log('='.repeat(50)); + console.log('🚀 后端服务已启动'); + console.log(`📡 服务地址: http://localhost:${process.env.PORT || 4000}`); + console.log(cubeMode === 'cube' + ? `📊 Cube.js API: http://localhost:${process.env.PORT || 4000}/cubejs-api` + : '📊 Cube.js API: 已降级不可用,业务 API 继续运行'); + + // 测试数据库连接 + console.log('\n📦 数据库连接测试:'); + const [systemTest, businessTest, redisTest] = await Promise.all([ + withTimeout(database.local.test(), 5000, { message: '系统数据库检测超时' }), + withTimeout(cloudDb.test(), 5000, { message: '业务数据库检测超时' }), + withTimeout(redis.test(), 3000, { message: 'Redis 检测超时' }), + ]); + console.log(` [系统] ${systemTest.message}`); + console.log(` [业务] ${businessTest.message}`); + console.log(` [缓存] ${redisTest.message}`); + console.log('='.repeat(50)); + + if (String(process.env.ENABLE_BACKGROUND_JOBS || 'true').toLowerCase() === 'false') { + console.log('⏸️ 后台定时任务和预热已关闭,业务请求按需处理'); + return; + } + + startScheduler(); + startStockSelectionSchedulers(); + startTaobaoProductEmbeddingSchedulers(); + startPddSkuDimensionSnapshotSchedulers(); + startPlanningMayProductSnapshotSchedulers(); + startPlanningDingWorkflowSyncSchedulers(); + startOpsBIncentiveSnapshotSchedulers(); + startGoodsFirstSeenScheduler(); + startAlibaba1688ImageSearchExpiryScheduler(); + startMkhReplenishAlertScheduler(); + + // Keep the first selection-pool page request from paying the remote schema + // inspection round-trip over the planning database tunnel. + setTimeout(() => { + warmSelectionFissionReadSchema() + .then(() => console.log('[SelectionFission] 列表读取结构预热完成')) + .catch((error) => console.warn('[SelectionFission] 列表读取结构预热失败:', error.message)); + }, 1000); + + if (isStartupPrewarmEnabled()) { + setTimeout(() => { + if (typeof prewarmChannelHotData === 'function') { + runGuardedPrewarm('channel-hot-data', () => prewarmChannelHotData({ skipCoverage: true })).catch((error) => { + console.warn('⚠️ 渠道默认查询预热失败:', error.message); + }); + } + }, 8000); + + setTimeout(() => { + if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { + runGuardedPrewarm('analysis-hot-pages', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { + console.warn('⚠️ 分析页默认查询预热失败:', error.message); + }); + } + }, 10000); + + + + if (typeof analysisRoutes.prewarmHotAnalysisPages === 'function') { + const analysisPrewarmTimer = setInterval(() => { + runGuardedPrewarm('analysis-hot-pages:interval', () => analysisRoutes.prewarmHotAnalysisPages()).catch((error) => { + console.warn('⚠️ 分析页默认查询定时预热失败:', error.message); + }); + }, 10 * 60 * 1000); + + if (typeof analysisPrewarmTimer.unref === 'function') { + analysisPrewarmTimer.unref(); + } + } + + + } else { + console.log('⏸️ 启动预热已关闭,可通过 ENABLE_STARTUP_PREWARM=true 开启'); + } + + if (!isStartupPrewarmEnabled() && typeof prewarmChannelStockMonitorDashboardDefaultSnapshot === 'function') { + if (typeof prewarmChannelIdDefaultSnapshots === 'function') { + setTimeout(() => { + runGuardedPrewarm( + 'channel-id-analysis-resilience', + () => prewarmChannelIdDefaultSnapshots({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ ID数据分析韧性快照预热失败:', error.message); + }); + }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS); + } + + if (typeof prewarmChannelIdDashboardDefaultSnapshot === 'function') { + setTimeout(() => { + runGuardedPrewarm( + 'channel-id-dashboard-resilience', + () => prewarmChannelIdDashboardDefaultSnapshot({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ ID数据分析仪表盘韧性快照预热失败:', error.message); + }); + }, CHANNEL_ID_RESILIENCE_PREWARM_DELAY_MS + 15000); + } + + setTimeout(() => { + runGuardedPrewarm( + 'channel-stock-monitor-resilience', + () => prewarmChannelStockMonitorDashboardDefaultSnapshot({ skipCoverage: true }) + ).catch((error) => { + console.warn('⚠️ 库存销售影响仪表盘韧性快照预热失败:', error.message); + }); + }, CHANNEL_STOCK_MONITOR_RESILIENCE_PREWARM_DELAY_MS); + } + + if (String(process.env.ENABLE_BRAND_ANALYSIS_PREWARM || 'true').toLowerCase() !== 'false' + && typeof prewarmBrandAnalysisDefault === 'function') { + setTimeout(() => { + runGuardedPrewarm('brand-analysis-default', () => prewarmBrandAnalysisDefault()) + .then((result) => { + console.log( + `[BrandAnalysis] default prewarm completed total=${result.total || 0} elapsedMs=${result.elapsedMs || 0}` + ); + }) + .catch((error) => { + console.warn('⚠️ 品牌维度默认真实数据预热失败:', error.message); + }); + }, BRAND_ANALYSIS_PREWARM_DELAY_MS); + } + + const cron = require('node-cron'); + + if (typeof prewarmChannelHotData === 'function') { + cron.schedule('0 1 * * *', async () => { + console.log('🧊 凌晨 1 点执行渠道 23 小时缓存预热...'); + await runGuardedPrewarm( + 'channel-hot-data:daily', + () => prewarmChannelHotData({ forceRefresh: true, skipCoverage: true }) + ); + }, { + timezone: 'Asia/Shanghai', + }); + console.log('[Scheduler] 渠道 23 小时缓存预热任务已注册: 每天01:00执行'); + } + + if (process.env.NODE_ENV === 'production') { + const alertChecker = require('./services/alertChecker'); + + cron.schedule('*/5 * * * *', async () => { + console.log('🔔 执行预警检查...'); + await alertChecker.checkAllRules(); + }); + } +} + +async function bootstrap() { + const installStatus = await getInstallStatus(); + if (installStatus.required) { + await startInstallServer(installStatus); + return; + } + + if (installStatus.databaseUnavailable) { + console.warn(`⚠️ 系统数据库暂时不可用,不进入降级拦截模式: ${installStatus.systemDatabase?.reason || 'unknown'}`); + await startServer({ skipLocalSchema: true }); + return; + } + + await startServer(); +} + +bootstrap().catch((error) => { + console.error('❌ 服务启动失败:', error); + process.exit(1); +}); + +async function shutdownGracefully() { + await Promise.allSettled([ + cloudDb.close(), + jianSunDb.close(), + database.close(), + redis.close(), + ]); +} + +// 优雅关闭 +process.on('SIGTERM', async () => { + console.log('正在关闭服务...'); + await shutdownGracefully(); + process.exit(0); +}); + +process.on('SIGINT', async () => { + console.log('正在关闭服务...'); + await shutdownGracefully(); + process.exit(0); +}); diff --git a/backend/src/jobs/mkhReplenishAlertJob.js b/backend/src/jobs/mkhReplenishAlertJob.js new file mode 100644 index 0000000..f0f2231 --- /dev/null +++ b/backend/src/jobs/mkhReplenishAlertJob.js @@ -0,0 +1,396 @@ +'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, +}; diff --git a/backend/src/services/mkhBomService.js b/backend/src/services/mkhBomService.js new file mode 100644 index 0000000..f585725 --- /dev/null +++ b/backend/src/services/mkhBomService.js @@ -0,0 +1,2671 @@ +'use strict'; + +// 模块化库存监控 —— MKH-BOM 资料 +// +// 页面结构(与业务确认的模板):MKH商品编码 | 配件明细 | 数量 | 配件仓位 +// +// ── 数据口径 ────────────────────────────────────────────────────── +// +// ① MKH 商品编码(列表主数据)—— 业务库 JSReportReplica(MSSQL / schema=rpt) +// rpt.jst_sku WHERE sku_id LIKE N'MKH%' AND item_type = N'成品' +// AND other_3 = N'简尚-崔桥工厂品牌仓' +// AND 该编码存在正式 BOM 配件明细 (当前 4223 条) +// · 商品档案里的 item_type/labels 不能单独作为成品依据:上游也会把部分配件 +// 标成“成品/模块化成品”。只有 jst_skuBomItem 的父项才进入本页面。 +// · 形态 MKH + 款号 + 序列,例 MKHDNZ0409260600198 +// +// ② 配件明细(BOM)—— 聚水潭商品 BOM(主来源) +// rpt.jst_skuBomInfo / rpt.jst_skuBomItem +// · jst_skuBomInfo.sku_id = MKH 成品编码 +// · jst_skuBomItem.map_outer_sku_id = 配件编码,rm_qty = 配件数量 +// · 两表当前分别约 4,257 / 49,012 行;远端副本暂未提供 sku_id 索引, +// 因此只读取必要列并在进程内短时缓存,避免分页和下钻重复扫描。 +// +// ③ 业务中台组合商品表(降级来源) +// JSDataMiddlePlatform.dbo.jst_comboProductItem +// · combine_sku_id = MKH 成品编码,src_sku_id = 配件编码,qty = 配件数量 +// · 当前实测 1,406 个 MKH 成品、3,022 条配件关系、159 个不同配件编码 +// +// ④ 产品资料系统数据库(PostgreSQL / furniture3d_prod)作为补充来源 +// public."IntakeNonIntegratedProduct".payload -> 'skuItems' -> 'componentDetails' +// · 每一项形如 { code, name, role/assetType, barcode, quantity } +// · 例:MKH-DNZ0384 → [{ barcode: DNZ038501N1437V01, quantity: 1, role: 板材 }, +// { barcode: DNZ038401N1440V01, quantity: 1, role: 组件 }] +// · 匹配:编码去横线/空格转大写后比对;精确 SKU 匹配不到时退化到 SPU 前缀匹配 +// (业务库 MKHDNZ0384260600002 ↔ 资料库 MKH-DNZ0384260600001) +// · BOM 只用于补充指定仓库商品的配件明细,不能把无 jst_sku 仓库档案的编码追加为商品。 +// +// ⚠️ 该库另有 external.product_bom_v1 标准视图,但它依赖 Product / ProductSKU / +// Panel 三张整合表,这三张表当前均为 0 行,因此视图恒为空、不要用。 +// payload 里的 bomDetails / factoryRows / pricingRows 属成本与工厂行,结构不同 +// (无 SKU 条码),也不能直接当配件 BOM。 +// +// ⑤ 配件仓位 —— **唯一数据源** 业务库 rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」) +// 2026-09-17 业务口径:仓位列**只认这张表**,其他来源一律不用(原来那套 +// 「库存表正数量仓(未分箱)→ 商品档案仓(暂无箱号)」的兜底链已删除 —— +// 那不是仓位,标出来会让人以为料就在那儿)。 +// sku_id 可能带“-装箱序号”后缀(DNZ0019110N08V01-27),按基础配件编码匹配。 +// 取值只有两层: +// 1) 真货位 bin_group / bin → 「仓名 · bin_group / bin」 +// 2) 箱号(净数量 > 0 的 pack_id) → 「仓名 · 箱号 pack_id」 +// 该表查不到 → 仓位为空,前端显示「未维护」。**不做任何回退**。 +// · 列表展开(/detail)、单品明细、导出配件明细共用 loadComponentLocations(), +// 只打一条查询,按编码缓存 10 分钟。 +// ⚠️ 关联列别踩(2026-09-17 实测):仓名必须用 `jst_warehouseInfo.Id = p.wh_id`; +// 该表 `wms_co_id` **全表 NULL**,用 `w.wms_co_id = p.wms_co_id` 关联 85,152 行全部取不到仓名。 +// ⚠️ 2026-09-17 全量复核(别再重复排查):该表全表 85,152 行,但只有 **3 个箱** +// (914138566000001000 / …1003 / …4000)、2 个仓库(wh_id=1 常州简尚家居 85,145 行、 +// wh_id=4 简尚-新苏北仓 7 行);`bin` / `bin_group` **全表为空**。 +// 且表内容与 MKH 配件**零交集**:正式 BOM 的 715 个配件编码在该表一个都查不到 +// (精确 + `code-%` 前缀都是 0;箱表里是 DNZ0135xx/0199xx/0203xx 老型号半成品)。 +// → MKH 配件仓位列当前**必然全部显示「未维护」**,这是数据源现状,不是代码问题; +// 需业务在聚水潭给配件做装箱/维护货位后才会自动出现(代码不用再动)。 +// +// ⑥ 配件库存 —— 业务库 rpt.jst_inventoryInfo 按 sku_id 汇总(**商品**库存,不是配件仓位)。 +// +// ── ⑦ 补货预警(2026-09-17 业务确认口径)────────────────────────── +// 规则:近 15 天日销 > 0 且 现有库存 < 3 天日销 → 预警补货 +// · 近15天日销 rpt.pm_stockMonitor.fifteenAvgSale +// (warehouse = '简尚-崔桥工厂品牌仓',按 date 取最新一条快照) +// · 现有库存 rpt.jst_inventoryInfo(wms_co_id = 15870495 品牌仓)实时库存; +// 该仓对 MKH 成品只覆盖约 256 / 4408 条(2026-09-16 快照;成品总数口径见 ①), +// 缺失时回退监控快照 jsStock +// · 结余库存 = max(0, 现有库存 − 待发货订单) +// ⚠️ 2026-09-18 起:已下单未发货的部分已被占用,**所有口径一律先扣待发订单** +// (见 ⑫)。待发货订单 = 0 时结余库存 ≡ 现有库存,与旧口径完全等价。 +// · 可用天数 = 结余库存 / 近15天日销 +// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存),不足 0 取 0 +// ⚠️ 刻意**不用**源系统的 jsUseDays 列:实测它与 jsStock / fifteenAvgSale 不同源 +// (同一 SKU 可差 20 天以上,如 MKHDNZ0409260700488 库存 44 / 日销 0.67, +// 除法 65.7 天而 jsUseDays 为 44),本页口径以业务给定的「近15天日销」为准。 +// +// ── 为什么是 15 天而不是业务最初要求的 30 天 ── +// 2026-09-17 业务要求「近7日日销」改「近30天日销」,但实测全库**没有 30 天日销**: +// · pm_stockMonitor 只有 threeAvgSale(3天) / sevenAvgSale(7天) / fifteenAvgSale(15天), +// 且整表**只保留 1 天快照**(2026-09-16),攒不出 30 天历史 +// · rpt.dws_sn_sales_full_info_di(stat_date+skuid+goods_count,数据新鲜到 09-15) +// 里 MKH 商品 **0 个**;目标仓 794 个 SKU 命中也是 0 +// · rpt.dws_order_sales_cost_detail_di 停在 2025-04(历史归档) +// · rpt.jst_salesOutboundInfo 只有出库**单头**,无 SKU 明细;业务库无出库明细表 +// · rpt.op_BarcodeSalePrice30dDaily 名字带 30d,但只有 salePrice **没有销量** +// → 经与业务确认,改用十五天口径 fifteenAvgSale(已实现的最长周期)。 +// ⚠️ 若日后上游补了 30 天字段,只需改 loadReplenishIndex 里的列名 + DAILY_SALE_DAYS。 +// +// 实测(2026-09-16 品牌仓快照,当时成品口径尚未收紧为「正式 BOM 父项」): +// 4408 个 MKH 成品中,7 天有销 114 个、15 天有销 170 个 +// 实测(2026-09-17 切换当天,同一份 09-16 快照,7 天 vs 15 天): +// 有销商品 114 → 170 ← 周期拉长,更多商品进得了「有销」集合 +// 补货预警 25 → 19 ← 反而变少:15 天日销普遍低于 7 天(近期销量走低), +// 可用天数 = 库存 ÷ 日销 随之变大,跌破 3 天的变少 +// 缺货 9 → 10 +// 已排产 11 → 13 +// 订单驱动 14 → 8 ← 更多商品自己有日销了,不再需要靠订单兜底 +// ⚠️ 「预警变少」不是 bug,是口径变化的正常结果;若业务想要 30 天,需上游先补数。 +// +// ── ⑦-a 日销兜底:订单表实算(2026-09-19 新增)──────────────────── +// 问题:上游快照 pm_stockMonitor.fifteenAvgSale 会**漏算**。实测(快照 09-18, +// 窗口内有发货的 MKH 成品共 178 个编码)**24 个被记成 0**,其中 16 个是 +// 「订单全部来自样品专用店铺」(上游同样不计样品单,属合理口径), +// 剩 **8 个是真实店铺**(天猫 / 京东 / 拼多多),这才是异常。 +// 业务实例:`MKHDNZ0409260601548` 近 15 天实发 2 件(09-09 / 09-19), +// 快照日销却是 0,库存又是 0 → 走「无销量」分支彻底隐身,**连缺货都不报**。 +// ⚠️ 该列是**保留两位小数的日均**,不是取整值:品牌仓 177 个非零值范围 +// 0.07 ~ 4.96,含小数的占 170 个,最小非零 0.07 = 1 件 ÷ 15 天。 +// 所以「发过货却记 0」**无法用四舍五入解释**,就是上游没算进去。 +// 兜底规则:**快照日销 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货**时, +// 日销改用订单表实算值 = 已发货件数 ÷ SALE_WINDOW_DAYS, +// 随后照常走 ⑦/⑫ 的日销分支(结余库存、3 天阈值、建议补货量都不改)。 +// · **只在快照 ≤ 0 时兜底**:快照 > 0 一律以上游为准,不覆盖,避免两套口径打架 +// · **排除样品 / 测试专用店铺**(shop_name 含「样品」或「测试」),与上游口径对齐; +// 不排除的话那 16 个样品单编码会被误拉进预警 +// · **排除异常单**(2026-09-19 补充,见下):订单表里的「已发货」不等于真实销售 +// · 窗口 = 含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径 +// · 数据源与 ⑩ 的待发货订单**同一次查询、同一组表**:一次扫表同时拿到 +// 「待发货」与「近15天已发货」两份数据。实测合并后总耗时 ≈ 单查待发货 +// (均约 1.25s),而拆成两条独立查询要在现有开销之上**再加一次全表扫描**。 +// · 兜底生效的商品带 `dailySaleSource='orders'` 与 `recentShippedQty`, +// 前端据此提示「该值来自订单实算、非上游快照」,避免与源系统数字对不上时误判 +// · 订单源不可用时 shippedBySku 为空 → 兜底整体不生效,退化成旧口径(不会误报) +// +// 🔥 **必须排除「异常单」(2026-09-19 业务反馈后修正)** +// 业务报「MKHDNZ0410260700005 没发过货,为啥有日销」→ 查下来是兜底把 **0.01 元特殊单** +// 当成了销售。该单画像(拼多多-亿家达书房家具官方旗舰店): +// · 实付 **0.01 元**、商品额 908.01、**免额 908(全额免)**、备注 `SSDD`、标签含「特殊单」 +// · 该店在 **09-18 10:35–10:36 一分钟内**批量生成 18 张同样的 0.01 元 MKH 单 +// (实付合计 0.18 元、免额合计 16,344 元),另有 18 张占位码 `1.X00005` 的 0.01 元单 +// · **`rpt.jst_salesOutboundInfo` 里这些 o_id 查不到任何出库记录** +// (同店同时段其他单有 6,467 行)→ 系统标「已发货」,货其实没出 +// · 后果:把 10 个 SKU 的日销从 0 抬到 0.6~1.8(共 162 件),凭空产出缺货预警 +// 规则:已发货分支排除 **`pay_amount ≤ 0.01`** 或 **`labels` 含「特殊单」** 的单。 +// ⚠️ **别用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行), +// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。 +// ⚠️ 只作用于**已发货**分支;待发货(⑩)口径本次未动。 +// +// 实测(2026-09-19,窗口随当天滚动):近 15 天有发货 187 个编码,其中 +// 165 个快照本就 > 0(兜底不生效),**22 个快照 = 0 → 兜底生效**, +// 含本问题的 MKHDNZ0409260601548。这 22 个里:**12 个是真实单驱动**(实付 299~1338 元, +// 兜底正确),**10 个是上述 0.01 元异常单驱动**(`MKHDNZ0410260700001/0002/0004/0005/ +// 0007/0008/0009/0010/0011/0012`)—— 加过滤后这 10 个日销归零,另 12 个不受影响。 +// ⚠️ 这是**补漏**不是新口径:日销来源仍是「近 15 天发货量 ÷ 15」, +// 只是把上游漏掉的发货自己算一遍。上游修好后兜底会自动失效(快照 > 0)。 +// +// ── ⑦-c 出库校验:**必须真出库才算销量**(2026-09-19 业务确认口径)──── +// 业务原话:「这些异常 SKU,系统标记了『已发货』,但没有实际出库的,不需要出现缺货预警」。 +// 即 ⑦-a 的金额/标签判据再往根上退一步——**以出库表为准**: +// 已发货分支要计入销量,必须同时满足 +// ① 不是异常单(`pay_amount ≤ 0.01` 或 `labels` 含「特殊单」,⑦-a) +// ② 在出库表里查得到该 `o_id`(`jst_salesOutboundInfo` / `jst_qmcust_salesoutbound`) +// 实测(2026-09-19,窗口 15 天、1,086 张已发货 MKH 单): +// · 出库表命中 889 张 → 197 张无出库,其中 **40 张是拆分单、157 张是 Sent** +// · 157 张里 **131 张是 ≤0.01 元或「特殊单」**(即 ⑦-a 已排除的那批刷单), +// 剩下唯一 1 张正常金额的是 09-05 的 388 元单(标签「发货后取消 / 发货后售后 / +// 风控订单」)—— ② 这条判据正是为它这类「标了发货其实没出库」的单兜底 +// · 与只按金额/标签判据相比,**当前窗口的兜底生效 SKU 集合完全一致(12 个)**, +// 属"同解但更根本",代价是每次冷缓存多约 0.5s 的出库查询 +// 🔥 **两个必须豁免的情形**(不豁免就会大面积误杀真实单,实测数据如下): +// ① **拆分单**(`status='Split'`):父单被拆成子单后出库挂在**子单**上, +// 父单必然查不到出库 —— 窗口内 40 张 Split 单 **100% 无出库记录**, +// 若不豁免会误杀「京东-HX旗舰店104 09-19 实付 1194.14 元」这类真实单 +// ② **今天发货的单**:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行, +// 而 09-18 有 14,690 行)→ 09-19 的 7 张 Sent 无出库单里特殊单为 0, +// 说明无出库是**同步滞后**而非虚假发货。 +// 注意豁免只到「昨天」为止:09-18 的 18 张无出库 Sent 单 **100% 是特殊单**, +// 出库表 09-18 数据完整,所以 09-18 不能被豁免(否则会放过刷单) +// ⚠️ 只判**有没有出库行**:`jst_salesOutboundInfo.qty` 全表 1,119,606 行恒为 0 +// (`SUM(qty)` 永远得 0,近 15 天 180,433 行同样),别拿数量做判据。 +// ⚠️ 出库数据取不到时**整体跳过出库校验**(退化成只按金额/标签判据)并标 degraded: +// 漏杀只是多报一条预警,错杀会让真实缺货彻底隐身。 +// +// ── ⑦-d 库存源时效兜底:当日入库读箱表(2026-09-19 新增)────────── +// 业务原话:「MKHDNZ0409260700491 这个今日已经入库,现有库存是有…… +// 点击同步最新数据这个按钮,数据没有更新,还是显示库存为 0」。 +// 🔥 根因:**页面唯一的库存源 `jst_inventoryInfo` 是「有变化才写 updateTime」的同步表, +// 当天新入库的成品未必有对应行**(品牌仓尤其稀疏,一天只更新几十行)。 +// 2026-09-19 实测: +// · 该 SKU 在 inventoryInfo 全表**只有主仓 14138566 的一行 qty=0**, +// 品牌仓**连行都没有**;而箱表品牌仓 bin='P-1' / qty=4 / createTime 当日 15:19 +// · 横向对照:inv 当天最后更新 06:41(品牌仓最后 04:42,当天仅 3 行), +// 箱表当天最后更新 17:01(小时级)→ **当日入库的货只有箱表看得见** +// · ⚠️ 且 inv 当天 06:41 后**再没更新过**(09-17 / 09-18 都是全天逐小时更新) +// → 大概率上游同步任务当天异常,需业务侧跟进(见文末遗留) +// → 页面取不到实时值 → 回退 09-18 监控快照 jsStock=0 → 显示 0。 +// ⚠️ 「同步最新数据」按钮只清服务端缓存(clearMkhBomCache),**不会触发上游同步**, +// 所以点上多少次都还是 0 —— 数据源当天就没有这条库存。 +// 兜底规则(2026-09-24 修正): +// ① inventoryInfo 与箱表品牌仓 `bin` 非空行都有值 → 取两者较大值 +// ② 只有其中一个当前库存源有值 → 用该源 +// ③ 两个当前库存源都无记录 → 回退监控快照 jsStock +// 取较大值的原因:inventoryInfo 包含散货,通常比箱表大;但新入库会先进入小时级 +// 箱表、inventoryInfo 仍保留旧正数。若一律让 inventoryInfo 优先,就会把新入库库存压住; +// 取 max 既不会丢散货,也能在日更快照滞后时立即看到箱表新货。 +// ⚠️ 必须过滤 `bin` 为空的行:那是**装箱记录不是库存**(品牌仓 bin 空行合计 21 件), +// 不过滤会虚增。 +// 影响面(2026-09-19 实测):品牌仓 4,408 个成品里,走 ② 兜底的只有 **3 个** +// (MKHDNZ0409260601548=5 / MKHDNZ0409260700059=4 / MKHDNZ0409260700491=4, +// 同一箱 115870495000001201、货位 P-1,均为**当日新建**行); +// 预警列表随之 27 → 24 条(缺货 21 → 18),这 3 条都是「入库当天被误判缺货」。 +// 性能:箱表全量取数 **87ms**(brand 仓 + bin 非空 + GROUP BY),无额外负担。 +// +// ⚠️ 遗留(需业务 / 上游跟进):箱表兜底只解决「**当日入库看不见**」这一种情形。 +// inventoryInfo 若长期停更,**存量库存的时效性仍依赖该表恢复**——届时页面库存 +// 会停留在停更时点(本次即 09-19 06:41)的旧值,本项目无从感知。 +// 若日后业务反馈「库存大面积停在某个时间点」,**先查 inventoryInfo 的 +// MAX(updateTime)**,再看箱表的,两者一对即可判断是上游停了还是本页取数问题。 +// +// ── ⑧ 在途加工单(2026-09-17 业务确认口径)──────────────────────── +// 规则:**只要该 MKH 商品存在未完成的加工单,就不再列入补货预警** +// (工厂已经在做了,不重复提示补货),改标为「已排产」。 +// · 数据源:业务库同实例的链接服务器 +// LS_JS_DMP → JSDataMiddlePlatform.dbo.bi_processing_work_orders +// (聚水潭「组装加工」加工单,一张扁平的 加工单 × 商品 明细表) +// · 过滤:加工仓储方 = '简尚-崔桥工厂半成品包装项目仓' +// AND 状态 <> '作废' ← 作废单据一律不核算 +// AND max(数量 − 已入库数量, 0) > 0 ← 只认真实剩余量 +// · 在途加工量 = Σ max(数量 − 已入库数量, 0),按 商品编码 归集。 +// 不再依赖「收货状态」文本:上游状态字段延迟时,已入库数量已追平的单据必须立即退出在途。 +// ⚠️ 为什么走 getPool 而不是 cloudDatabase.query: +// query() 会经 rewriteReportReplicaSql() 把 `dbo.xxx` 重写成 `rpt.xxx`, +// 跨库四段名 `LS_JS_DMP.JSDataMiddlePlatform.dbo.t` 会被改坏成 +// `LS_JS_DMP.JSDataMiddlePlatform.rpt.t`。getPool() 不经过该重写。 +// 实测(2026-09-16):全表 679 行,其中 完成 498(全部入库)/ 作废 176 / +// 已确认 3 / 待审核 2;属半成品包装仓且未完成且非作废的只有 5 单 40 件。 +// ⚠️ 作废单在半成品包装仓有 79 单 740 件,若不过滤会虚增约 18 倍在途量。 +// +// ── ⑨ 导出(2026-09-17)────────────────────────────────────────── +// 导出与列表**共用同一套筛选/排序口径**(filterRows + sortRows),区别只有两点: +// · 不分页 —— 返回当前筛选条件下的全部行,而不是当前页 +// · 两种形态 —— products(商品清单,列同页面表格)/ components(配件明细摊平) +// 官方 BOM 明细按 SKU 分批读取(单批上限 MAX_PAGE_SIZE=200,见 loadOfficialBomForSkuBatches), +// 只对筛选结果取数,因此「仅预警」这类小范围导出只需 1 批查询; +// 每批结果都进 officialBomDetailCache,重复导出直接命中缓存。 +// ⚠️ products 形态**不返回 components** —— 全量商品(4000+ 行)若都挂上配件明细, +// 响应会膨胀到几十 MB,而商品清单本身用不到明细。 +// +// ── ⑩ 待发货订单(2026-09-17 业务确认口径)──────────────────────── +// 规则:**近 15 天日销 = 0、但当前存在未发货订单的 MKH 成品,同样列入补货预警**。 +// 背景:日销取自监控快照(日更)的滚动均值,新上的 MKH 成品「昨天没卖、今天来了订单」 +// 时快照仍是 0,会被判成「无销量」而完全排除在预警之外,导致漏备货。 +// · 数据源:业务库**两张订单主表,必须都读**(2026-09-19 修正,见下 ⑩-a) +// · rpt.jst_Orders —— 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会 +// · rpt.jst_qmcust_orderlist —— 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单 +// 两张表用 `skus` 文本列承载商品:`<总件数>.<条码>[*数量][,<条码>[*数量]]` +// (如 `3.GBGJ-ZWJ024903AN1407V01*1,MKHDNZ0409260600022*1`)。 +// ⚠️ 不要用明细表(jst_OrderItem / jst_qmcust_orderlist_item):主表直读免关联, +// skus 列一次性带出全部商品;实测 MKH 成品订单 100% 可解析,故只解析 +// MKH 成品码(`/^MKH/i` 且非 `MKH-` 打头的组合装)。 +// +// ── ⑩-a 为什么是两张表(2026-09-19 修正,重要)──────────────────── +// 现象:`MKHDNZ0409260700059` / `MKHDNZ0409260601228` 明明有待发货订单, +// 系统却显示 0 单、不给预警,业务反馈到系统。 +// 根因:**业务库的订单按渠道拆成了互不重叠的两张表**,本服务原先只读了 +// `rpt.jst_Orders` —— 天猫 / 淘宝 / 1688 / 阿里巴巴渠道的待发货需求**全被漏掉**。 +// 实测(2026-09-19,近 30 天窗口): +// · 两表店铺名**交集 = 0**(jst_Orders 398 店 vs qmcust 78 店),so_id 交集仅 5 条, +// o_id 交集 **0 条**(两表的 o_id 完全独立,合并安全); +// · 同一品牌在不同平台是**不同店铺名**(例「欧意朗」在 jst_Orders 是拼多多/得物/唯品会, +// 在天猫/淘宝则是另一张表里的店)→ **按品牌名反查会误判「表里有」,必须按完整店铺名核对**; +// · `jst_Orders` 里 `shop_name LIKE '%天猫%'` = **0 行**(不是命名差异,是真的没有)。 +// 影响面(MKH 成品待发货):仅 jst_Orders = 36 编码 / 50 件; +// 合并后 = **40 编码 / 56 件**,补进来的新编码 4 个: +// MKHDNZ0409260700059 / MKHDNZ0409260601228 / MKHDNZ0409260601530 / MKHDNZ0409260700498; +// 实时单量:jst_Orders 未发货含 MKH 52 单 + qmcust 10 单。 +// 性能:4.91M 行的 qmcust 上该查询约 0.17s(LIKE '%MKH%' 全扫),可忽略。 +// 口径范围:**渠道全并**(2026-09-19 业务确认),含 1688 / 阿里巴巴 / 分销导单专用店铺。 +// · 未发货判定:`send_date IS NULL AND status NOT IN ('Cancelled','Merged')` +// (实测对应聚水潭 status=Delivering 配货中 33 单 / Question 问题件 29 单 / Split 4 单; +// 已发货的 Sent 恒有 send_date;Cancelled/Merged 是取消与被合并单,不能算需求) +// · 待发货订单量 = Σ 该成品在未发货订单中的件数(同一单多次出现会累加) +// · 预警判定(2026-09-17 业务补充口径):日销为 0,但仍用「可用天数」衡量风险 —— +// 可用天数 = 现有库存 ÷ 代发(待发货)订单件数 +// · 可用天数 = 0(无库存) → 缺货 +// · 0 < 可用天数 < 3 天 → 预警 +// · 可用天数 ≥ 3 天 → 库存充足,不预警(阈值语义与日销分支一致) +// ⚠️ 代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0),不会出现除零; +// 可用天数统一如实记录(保留 2 位小数),不再像以前那样缺货记 0 / 够发记 null。 +// · 建议补货量: +// 缺货 → max(0, 代发订单件数 − 现有库存)(先补到能覆盖整笔订单) +// 预警(0<天数<3) → max(0, 3 × 代发订单件数 − 现有库存)(补到 3 天缓冲) +// 正常(天数≥3) → 0 +// ⚠️ 与「在途加工单」的优先级:**先判已排产**(见 ⑧)—— +// 无日销但有订单、且工厂已开加工单的商品,仍标「已排产」不预警,避免重复提示。 +// 实测(2026-09-17,7 天口径下):未发货且含 MKH 成品的订单 75 单 → 48 个成品编码 / 71 件, +// 其中 30 个本来就有日销、**18 个是本次新增**(日销=0,靠订单才进预警)。 +// ⚠️ 改 15 天口径后实测:订单驱动从 14 个降到 **8 个** +// (日销基数 114 → 170,原本靠订单兜底的商品自己有日销了),与预期一致。 +// ⚠️ 同日又叠加「可用天数 ≤ 3 天」阈值(2026-09-17 补充):代发件数大、库存也能撑 3 天 +// 以上的商品不再预警,订单驱动预警数会在此 8 个基础上进一步减少(具体数以接口为准)。 +// +// ── ⑪ 配件不足 / 可组装数(2026-09-17 业务确认口径)──────────────── +// 规则:**缺货 / 预警的 MKH 成品,若其配件现有库存「可组装数 = 0」,则提示 +// 「配件不足无法补货」**,建议补货量显示为「—」(不给出补不到的补货目标)。 +// · 可组装数 = min over 配件 ⌊ 可支配配件数 ÷ 单台用量 ⌋ (木桶短板决定) +// 可支配配件数 = 现有库存 − 未完成加工单占用(不足 0 取 0) +// ⚠️ **必须扣未完成加工单的占用**(业务明确要求):加工单在途的成品已经把配件 +// 预定/领走了,不扣就会高估可组装能力。加工单占用按配件维度归集: +// 占用[配件] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) +// · 配件现有库存 rpt.jst_inventoryInfo,仓 = 简尚-崔桥工厂半成品包装项目仓 +// (wms_co_id = 15718196)—— MKH BOM 的 784 个配件归这个仓,产出成品入品牌仓。 +// 实测:该仓 822 行 / 741 行有正库存;本页涉及的配件**全部只在该仓有值**, +// 跨仓正库存为 0,所以不跨仓汇总。 +// · 单台用量 rm_qty(jst_skuBomItem)实测取值为 1 / 2 / 4,必须参与计算,不能当 1。 +// ⚠️ 不能用 `jst_packInventoryInfo` 当配件库存 —— 它是「箱及仓位库存」, +// 与 MKH 配件零交集(见 ⑤),仓位口径专用。 +// · 采购在途(purchase_qty)**不计入**可用量(业务确认:只认现有库存)。 +// 锁定列(order_lock / pick_lock / lock_qty / virtual_qty)实测几乎全为 0 +// (本页 80 个配件里仅 2 条非零且值为 1),故直接用 qty。 +// · 判定范围:**缺货 + 预警都判**(业务确认),不改变原有预警级别与排序, +// 只额外打标记;「已排产」商品本来就不预警,不参与判定。 +// · 无 BOM 配件明细的商品 → 可组装数未知(组件缺失 ≠ 配件不足),不做判定。 +// 实测(2026-09-17,15 天口径):预警 19 个(缺货 10 + 预警 9)中 **12 个可组装数 = 0** +// (缺货 7 + 预警 5);扣加工单占用前后仅 1 个商品变化(185 → 180 台), +// 说明占用规模小但口径上必须扣。 +// ⚠️ 配件库存查询失败时**不做任何判定**(宁可少标也不误标), +// 接口透出 componentStockDegraded=true。 +// +// ── ⑫ 结余库存(2026-09-18 业务口径修正)────────────────────────── +// 背景:`MKHDNZ0409260601376` 现有库存 3、待发货订单 3,业务认为结余库存应为 0、 +// 可用天数 0、应判缺货;但旧代码里「待发货订单」**只在日销 = 0 时参与判定** +// (`orderDriven = dailySale <= 0 && pendingOrderQty > 0`),该品日销 0.27 > 0, +// 于是走日销分支 3 ≥ 0.81(3 天日销)→ 判「正常」,3 件待发订单被完全忽略。 +// 新口径:**待发货订单对所有商品都参与计算,不再区分日销是否为 0** +// · 结余库存 availableStock = max(0, 现有库存 − 待发货订单) +// · 可用天数 = 结余库存 ÷ 近15天日销(日销 > 0 时) +// · 缺货:结余库存 ≤ 0(订单都发不出去);预警:0 < 可用天数 < 3;否则正常 +// · 建议补货量 = ceil(3 × 近15天日销 + 待发货订单 − 现有库存) +// (先补到能把订单发掉,再补 3 天缓冲) +// ⚠️ ⑩ 的订单驱动分支(日销 = 0)**保持不变**:那里本来就是以代发订单件数为分母, +// 语义是「库存能覆盖几倍订单」,与日销无关,改动会误伤已有的订单驱动预警。 +// 实测(2026-09-18,4,223 个成品):既有日销又有待发订单的商品 38 个, +// 其中 **4 个等级变化、全部升级为缺货**: +// MKHDNZ0409260700503(日销1.67 库存1 待发2) 预警0.60天 → 缺货0天 +// MKHDNZ0409260601547(日销0.42 库存1 待发1) 预警2.38天 → 缺货0天 +// MKHDNZ0409260601527(日销0.13 库存1 待发1) 正常7.69天 → 缺货0天 +// MKHDNZ0409260601376(日销0.27 库存3 待发3) 正常11.11天 → 缺货0天 +// 全量分布:缺货 10→14、预警 6→4、正常 165→163(已排产 / 无销量不变)。 +// +const cloudDatabase = require('../config/cloudDatabase'); +const productDb = require('../config/productInformationDatabase'); + +const DEFAULT_PAGE_SIZE = 50; +const MAX_PAGE_SIZE = 200; +const BOM_CACHE_TTL_MS = 2 * 60 * 1000; +const OFFICIAL_BOM_DETAIL_CACHE_TTL_MS = 15 * 60 * 1000; +// 配件仓位(只查「箱及仓位库存」)按编码缓存;单批编码数受 MSSQL 单语句 2100 参数 +// 上限约束(每个编码占 2 个参数:精确匹配 + 带后缀前缀匹配),300 × 2 = 600 个参数,安全。 +const COMPONENT_LOCATION_CACHE_TTL_MS = 10 * 60 * 1000; +const COMPONENT_LOCATION_CHUNK_SIZE = 300; + +let businessBomCache = null; +let officialBomIndexCache = null; +const officialBomDetailCache = new Map(); +const componentLocationCache = new Map(); +let productBomCache = null; + +/** + * 缓存代际(epoch):`clearMkhBomCache()` 每次自增。 + * + * 为什么需要它 ——「同步最新数据」按钮点完反而看到旧数据: + * 所有缓存都是「先挂 pending,await 到结果后无条件写回」的写法。若在某个查询 + * in-flight 期间用户点了同步(清空全部缓存),那个**发起于清缓存之前**的查询 + * 读到的仍是旧快照,它返回时会无条件把旧值写回缓存,把清缓存后新请求填入的 + * 新值覆盖掉 —— 之后 2~10 分钟(各缓存 TTL)内页面持续显示旧数据。 + * 并发越高(多人同时用、或先点刷新再点同步)越容易撞上。 + * + * 修法:查询发起时记下当时的 epoch,写回前比对;epoch 变了说明中途被清过缓存, + * 这次结果已经过期,**只返回给发起它的那个请求,不写缓存**。 + */ +let cacheEpoch = 0; + +// 运营数据(库存 / 箱表 / 加工单 / 订单)使用独立代际。页面强制刷新时只淘汰 +// 这些变化频繁的缓存,不重查 BOM 与商品主数据。这样既能立即看到入库/完工, +// 又避免用户每刷新一次就把所有稳定表全量重扫。 +let operationalCacheEpoch = 0; +let operationalRefreshPending = null; +let operationalRefreshedAt = 0; +const OPERATIONAL_REFRESH_DEDUPE_MS = 15 * 1000; + +// MKH 模块化成品必须同时是指定仓库商品档案和正式 BOM 父项。 +// 上游存在被 item_type/labels 误标为成品的配件,不能仅依赖商品档案标签。 +const DESIGNATED_WAREHOUSE = '简尚-崔桥工厂品牌仓'; +const MKH_WHERE = `s.sku_id LIKE N'MKH%' + AND s.item_type = N'成品' + AND s.other_3 = N'${DESIGNATED_WAREHOUSE}' + AND EXISTS ( + SELECT 1 + FROM rpt.jst_skuBomItem parent_bom WITH (NOLOCK) + WHERE parent_bom.sku_id = s.sku_id + AND NULLIF(LTRIM(RTRIM(parent_bom.map_outer_sku_id)), N'') IS NOT NULL + )`; + +// ── 补货预警参数(见文件头 ⑦)──────────────────────────────────── +// 品牌仓 wms_co_id(rpt.jst_warehouseInfo 查得) +const BRAND_WMS_CO_ID = 15870495; +// 日销统计窗口(天)。见文件头 ⑦:业务最初要 30 天,但全库无 30 天日销数据, +// 实测最长只有 fifteenAvgSale(15 天),经业务确认改用 15 天。 +// ⚠️ SALE_WINDOW_DAYS 决定页面文案与口径展示,DAILY_SALE_COLUMN 决定实际取数列, +// 两者必须成对修改;下方 SQL 已按 DAILY_SALE_COLUMN 插值,改常量即可生效。 +const SALE_WINDOW_DAYS = 15; +// 日销取数列(rpt.pm_stockMonitor)—— 必须与 SALE_WINDOW_DAYS 保持一致 +const DAILY_SALE_COLUMN = 'fifteenAvgSale'; +// 预警阈值:现有库存可用天数低于 3 天即需补货 +const REPLENISH_DAYS_THRESHOLD = 3; + +const WARNING_LEVEL = Object.freeze({ + OUT_OF_STOCK: '缺货', + WARNING: '预警', + SCHEDULED: '已排产', + NORMAL: '正常', + NO_SALE: '无销量', +}); + +// ── 在途加工单参数(见文件头 ⑧)────────────────────────────────── +// 加工仓储方:加工单的加工地,即半成品包装项目仓(产出入品牌仓) +const WORK_ORDER_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓'; +// 作废状态:作废单据不参与在途量核算 +const WORK_ORDER_VOID_STATUS = '作废'; +// 收货状态:该值表示已全部入库,等价于「已完成」 +const WORK_ORDER_RECEIVED_STATUS = '全部入库'; +// 加工单表位于同一实例的链接服务器上,四段名必须走 getPool 查询(原因见文件头 ⑧) +const WORK_ORDER_TABLE = 'LS_JS_DMP.JSDataMiddlePlatform.dbo.bi_processing_work_orders'; +// 链接服务器不可用时的降级缓存时长:短一点,避免长时间带着错误在途量 +const WORK_ORDER_DEGRADED_TTL_MS = 60 * 1000; + +// ── 订单参数(见文件头 ⑩ 待发货 / ⑦-a 近15天已发货)────────────── +// 订单主表本地副本;含 `send_date`(发货日)、`shop_name`(店铺)与 `status`, +// 明细在 `skus` 文本列里。 +// ⚠️ **两张表必须都读**(2026-09-19 修正):业务库的订单按渠道拆成互不重叠的两张表, +// 只读 jst_Orders 会漏掉天猫 / 淘宝 / 1688 等渠道的全部需求。 +// ⚠️ 待发货(⑩)与近15天已发货(⑦-a)**共用这一组表、共用同一次查询**。 +const ORDER_TABLES = Object.freeze([ + 'rpt.jst_Orders', // 京东 / 京喜 / 拼多多 / 抖音 / 得物 / 唯品会 + 'rpt.jst_qmcust_orderlist', // 天猫 / 淘宝 / 1688 / 阿里巴巴 / 分销导单 +]); +// 不计入需求的订单状态:Cancelled 已取消 / Merged 已合并进别的单 +const PENDING_ORDER_EXCLUDED_STATUS = Object.freeze(['Cancelled', 'Merged']); +// 只捞含 MKH 的订单,避免全表扫描(实测 1.18M 行 + 4.91M 行,合并查询约 1.25s) +const PENDING_ORDER_SKU_LIKE = '%MKH%'; +// 订单数据源异常时的降级缓存时长 +const PENDING_ORDER_DEGRADED_TTL_MS = 60 * 1000; +// 日销兜底(⑦-a):这些店铺的单上游不计入日销,兜底也必须排除,否则会误拉进预警 +const SALE_EXCLUDED_SHOP_KEYWORDS = Object.freeze(['样品', '测试']); +// 日销兜底(⑦-a):**异常单**同样不能计入「已发货」—— +// 2026-09-19 实测:店铺在 1 分钟内批量生成 18 张「实付 0.01 元 / 商品额 908 元全额免 / +// 备注 SSDD / 标签含「特殊单」」的单(同批还有占位码 X00005 的单), +// 在 `rpt.jst_salesOutboundInfo` 里**查不到任何出库记录**(同店其他单有 6467 行)—— +// 即系统标了「已发货」但货没真出。它们曾把 10 个 SKU 的日销从 0 抬到 0.6~1.8。 +// 判定:实付 ≤ 0.01 元,或标签含「特殊单」。 +// ⚠️ **不要用 `free_amount > 0` 判**:正常单普遍有折扣免额(实测窗口内 569/1086 行), +// 按它筛会把 356 / 458 / 1338 元的正常单一起误杀(第一版就踩了这个坑)。 +const SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX = 0.01; +const SALE_EXCLUDED_ORDER_LABEL_KEYWORDS = Object.freeze(['特殊单']); + +// ── 出库校验参数(见文件头 ⑦-c)────────────────────────────────── +// 出库表:判定「标了已发货的单到底有没有真出货」。两张表按渠道分开,**必须都查**—— +// 2026-09-19 实测近 15 天窗口内 1,086 张 MKH 已发货单:京东表命中 889、天猫表命中 198, +// jst_qmcust_orderlist 来源的 208 张单里 7 张只有京东表有记录、1 张只有天猫表有记录。 +const OUTBOUND_TABLES = Object.freeze([ + 'rpt.jst_salesOutboundInfo', // 京东 / 拼多多 / 抖音 等 + 'rpt.jst_qmcust_salesoutbound', // 天猫 / 淘宝 / 1688 等 +]); +// 单个查询里塞多少个 o_id:1000 个参数远低于 MSSQL 单语句 2100 上限, +// 实测 1086 个订单号分 2 块并行查询合计 ≈ 515ms(整窗口拉全量 DISTINCT 要 8s,别那么干) +const OUTBOUND_LOOKUP_CHUNK_SIZE = 1000; +// 「拆分单」状态:父单被拆成子单后,出库记录挂在**子单**上,父单必然查不到出库。 +// 2026-09-19 实测窗口内 40 张 Split 单 **100% 无出库记录** → 不能拿「无出库」判它造假。 +const SPLIT_ORDER_STATUSES = Object.freeze(['split']); + +// ── 配件不足 / 可组装数参数(见文件头 ⑪)──────────────────────── +// 配件库存仓:MKH BOM 配件归属仓「简尚-崔桥工厂半成品包装项目仓」的 wms_co_id +const COMPONENT_STOCK_WMS_CO_ID = 15718196; +const COMPONENT_STOCK_WAREHOUSE = '简尚-崔桥工厂半成品包装项目仓'; +// 单批编码数:每个编码占 1 个参数,300 远低于 MSSQL 单语句 2100 上限 +const COMPONENT_STOCK_CHUNK_SIZE = 300; +const COMPONENT_STOCK_CACHE_TTL_MS = 10 * 60 * 1000; +// 配件库存不可用时的降级缓存时长(短一点,尽快恢复判定) +const COMPONENT_STOCK_DEGRADED_TTL_MS = 60 * 1000; +// 文案 / Tooltip 里最多列出的短板配件数 +const COMPONENT_SHORTAGE_MAX_LIST = 3; + +const toText = (value) => { + if (value === null || value === undefined) return null; + const text = String(value).trim(); + return text === '' ? null : text; +}; + +const toNumber = (value) => { + if (value === null || value === undefined || value === '') return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +// 日期转 YYYY-MM-DD(按本地时区),用于监控日展示 +const toDateText = (value) => { + if (value === null || value === undefined || 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())}`; +}; + +// 编码归一化:去横线/空格 + 大写,用于跨库匹配 +const normalizeCode = (value) => { + const text = toText(value); + return text ? text.toUpperCase().replace(/[-\s]/g, '') : null; +}; + +// ── 补货预警:进程内索引(见文件头 ⑦)──────────────────────────── +// ⚠️ 为什么不在 SQL 里关联: +// 实测(2026-09-17)在 ORDER BY / CASE 中对 OUTER APPLY 的列做 DECIMAL 算术, +// 会让 SQL Server 选到灾难性执行计划 —— +// · 按「可用天数」排序:128.8 秒 +// · 统计预警条数(CASE 内引用 apply 列):97.1 秒 +// · 同结构但只 COUNT:0.098 秒 +// 而「一次全量取数 + 应用层计算」只要约 0.2 秒。 +// 故 MKH 商品(4,408 条)与监控快照(4,411 行)一次性读入进程内索引, +// 筛选 / 排序 / 分页 / 统计全部在内存完成,SQL 侧只负责取数。 +const REPLENISH_PARAMS = Object.freeze({ + brandWarehouse: DESIGNATED_WAREHOUSE, + brandWmsCoId: BRAND_WMS_CO_ID, +}); +// 商品集合是主数据、监控快照为日更,10 分钟缓存足够 +const MKH_CACHE_TTL_MS = 10 * 60 * 1000; +let mkhProductCache = null; +let replenishIndexCache = null; +// 加工单在途量索引(见文件头 ⑧) +let workOrderIndexCache = null; +// 待发货订单索引(见文件头 ⑩) +let orderIndexCache = null; +// 配件库存(按编码缓存,见文件头 ⑪) +const componentStockCache = new Map(); + +const TRUTHY_FLAGS = new Set(['1', 'true', 'yes', 'on']); + +const parseFlag = (value) => TRUTHY_FLAGS.has(String(value ?? '').trim().toLowerCase()); + +// 列表展示范围(内存过滤) +const SCOPES = Object.freeze({ + WARNING: 'warning', SOLD: 'sold', WORK_ORDER: 'work-order', ALL: 'all', +}); + +/** + * 解析展示范围: + * warning —— 仅需补货(缺货 + 3 天内预警) + * sold —— 有近15天日销的商品 + * work-order —— 有未完成加工单(在途加工量 > 0)的商品 + * all —— 全部 MKH 成品 + * 兼容旧参数 warningOnly=1(等价 scope=warning)。 + */ +function resolveScope(query = {}) { + const raw = String(query.scope ?? '').trim().toLowerCase(); + if (raw === SCOPES.WARNING || raw === SCOPES.SOLD || raw === SCOPES.WORK_ORDER || raw === SCOPES.ALL) { + return raw; + } + if (parseFlag(query.warningOnly)) return SCOPES.WARNING; + return SCOPES.ALL; +} + +const matchesKeyword = (row, keyword) => { + if (!keyword) return true; + const needle = keyword.toLowerCase(); + return String(row.skuId || '').toLowerCase().includes(needle) + || String(row.productName || '').toLowerCase().includes(needle); +}; + +function filterRows(rows, { keyword, scope }) { + return rows.filter((row) => { + if (scope === SCOPES.WARNING && !row.hasWarning) return false; + if (scope === SCOPES.SOLD && !(toNumber(row.dailySale) > 0)) return false; + if (scope === SCOPES.WORK_ORDER && !row.hasPendingWorkOrder) return false; + return matchesKeyword(row, keyword); + }); +} + +/** + * 排序:stockDays(可用天数)/ skuId。 + * ⚠️ 无销量的商品 stockDays 为 null,无论升降序都恒定排在最后, + * 否则它们会占据「最紧急」的首屏位置。 + */ +function sortRows(rows, field, order) { + const direction = order === 'DESC' ? -1 : 1; + const bySku = (a, b) => String(a.skuId || '').localeCompare(String(b.skuId || '')); + const list = [...rows]; + + if (field === 'stockDays') { + list.sort((a, b) => { + const av = toNumber(a.stockDays); + const bv = toNumber(b.stockDays); + if (av === null && bv === null) return bySku(a, b); + if (av === null) return 1; + if (bv === null) return -1; + if (av !== bv) return (av - bv) * direction; + return bySku(a, b); + }); + return list; + } + + list.sort((a, b) => bySku(a, b) * direction); + return list; +} + +/** + * 把 componentDetails 原始 JSON 解析成统一结构。 + */ +function parseComponents(raw) { + if (!Array.isArray(raw)) return []; + return raw + .map((item) => { + if (!item || typeof item !== 'object') return null; + const barcode = toText(item.barcode); + const code = toText(item.code); + const name = toText(item.name) || barcode || code; + return { + barcode: barcode || code, + code, + name, + role: toText(item.role) || toText(item.assetType), + qty: toNumber(item.quantity) ?? 1, + }; + }) + .filter(Boolean); +} + +/** + * 将业务中台组合商品明细整理成按 MKH 成品编码查找的 BOM。 + * 同一成品、同一配件出现多行时合并数量,避免下钻重复展示。 + */ +function groupBusinessBomRows(rows = []) { + const grouped = new Map(); + + rows.forEach((row) => { + const skuCode = toText(row.comboSkuId); + const skuKey = normalizeCode(skuCode); + const componentCode = toText(row.componentSkuId); + const componentKey = normalizeCode(componentCode); + if (!skuCode || !skuKey || !componentCode || !componentKey) return; + + if (!grouped.has(skuKey)) { + grouped.set(skuKey, { + skuCode, + components: new Map(), + }); + } + + const entry = grouped.get(skuKey); + const previous = entry.components.get(componentKey); + const qty = toNumber(row.qty) ?? 1; + if (previous) { + previous.qty += qty; + if (!previous.name) previous.name = toText(row.componentName) || componentCode; + return; + } + + entry.components.set(componentKey, { + barcode: componentCode, + code: componentCode, + name: toText(row.componentName) || componentCode, + role: null, + qty, + }); + }); + + const bySku = new Map(); + const entries = []; + grouped.forEach((entry, skuKey) => { + const components = Array.from(entry.components.values()); + bySku.set(skuKey, components); + entries.push({ + skuCode: entry.skuCode, + spu: null, + productName: entry.skuCode, + components, + }); + }); + + return { bySku, bySpuPrefix: [], entries }; +} + +/** + * 将聚水潭正式商品 BOM 明细整理成按成品编码查找的结构。 + * 同一 BOM 中重复的配件编码会合并数量;外部配件编码为空时忽略该行, + * 避免把聚水潭内部数字 ID 当成可展示、可查库存的配件编码。 + */ +function groupOfficialBomRows(rows = []) { + const grouped = new Map(); + + rows.forEach((row) => { + const skuCode = toText(row.skuId); + const skuKey = normalizeCode(skuCode); + const componentCode = toText(row.componentSkuId); + const componentKey = normalizeCode(componentCode); + if (!skuCode || !skuKey || !componentCode || !componentKey) return; + + if (!grouped.has(skuKey)) { + grouped.set(skuKey, { + skuCode, + components: new Map(), + }); + } + + const entry = grouped.get(skuKey); + const previous = entry.components.get(componentKey); + const qty = toNumber(row.qty) ?? 1; + if (previous) { + previous.qty += qty; + if (!previous.name) previous.name = toText(row.componentName) || componentCode; + return; + } + + entry.components.set(componentKey, { + barcode: componentCode, + code: componentCode, + name: toText(row.componentName) || componentCode, + role: null, + qty, + }); + }); + + const bySku = new Map(); + const entries = []; + grouped.forEach((entry, skuKey) => { + const components = Array.from(entry.components.values()); + bySku.set(skuKey, components); + entries.push({ + skuCode: entry.skuCode, + spu: null, + productName: entry.skuCode, + components, + }); + }); + + return { bySku, bySpuPrefix: [], entries }; +} + +/** + * 读取聚水潭正式 BOM 主表中的成品编码索引。 + * 主表只有约四千行,只传输 sku_id;用于全局统计与来源优先级判断。 + */ +async function loadOfficialBomIndex() { + const now = Date.now(); + if (officialBomIndexCache?.value && officialBomIndexCache.expiresAt > now) { + return officialBomIndexCache.value; + } + if (officialBomIndexCache?.pending) return officialBomIndexCache.pending; + + const pending = cloudDatabase.query( + `SELECT h.sku_id AS skuId + FROM rpt.jst_skuBomInfo h WITH (NOLOCK) + WHERE h.sku_id LIKE N'MKH%'`, + {}, + { requestTimeout: 180000 } + ).then((rows) => { + const bySku = new Map(); + rows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key) bySku.set(key, []); + }); + return { bySku, bySpuPrefix: [], entries: [] }; + }); + + const epoch = cacheEpoch; + officialBomIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + // epoch 变了 ⇒ 期间用户点过「同步最新数据」,本次结果来自清缓存前的旧快照, + // 只还给发起它的请求,不写回缓存(否则会把新值覆盖成旧值) + if (epoch === cacheEpoch) { + officialBomIndexCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } + return value; + } catch (error) { + if (epoch === cacheEpoch) officialBomIndexCache = null; + throw error; + } +} + +/** + * 按当前页面或单品所需 SKU 批量读取正式 BOM 明细。 + * 单次最多接收 MAX_PAGE_SIZE 个 SKU,并缓存每个 SKU 的结果(包括空结果), + * 避免把远端约 4.9 万条明细在每次缓存刷新时全部传回应用服务器。 + */ +async function loadOfficialBomForSkus(skuIds = []) { + const requested = Array.from(new Map( + skuIds + .map((skuId) => [normalizeCode(skuId), toText(skuId)]) + .filter(([key, value]) => key && value) + ).entries()).slice(0, MAX_PAGE_SIZE); + const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; + const missing = requested.filter(([key]) => { + const cached = officialBomDetailCache.get(key); + return !cached || cached.expiresAt <= now; + }); + + if (missing.length > 0) { + const params = {}; + const placeholders = missing.map(([, skuId], index) => { + params[`officialSku${index}`] = skuId; + return `@officialSku${index}`; + }); + const rows = await cloudDatabase.query( + `SELECT + i.sku_id AS skuId, + i.map_outer_sku_id AS componentSkuId, + i.map_name AS componentName, + i.rm_qty AS qty + FROM rpt.jst_skuBomItem i WITH (NOLOCK) + WHERE i.sku_id IN (${placeholders.join(', ')}) + AND NULLIF(LTRIM(RTRIM(i.map_outer_sku_id)), N'') IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM rpt.jst_skuBomInfo h WITH (NOLOCK) + WHERE h.sku_id = i.sku_id + )`, + params, + { requestTimeout: 180000 } + ); + const grouped = groupOfficialBomRows(rows); + const expiresAt = Date.now() + OFFICIAL_BOM_DETAIL_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; + missing.forEach(([key]) => { + if (writable) { + officialBomDetailCache.set(key, { + components: grouped.bySku.get(key) || [], + expiresAt, + }); + } + }); + } + + const bySku = new Map(); + requested.forEach(([key]) => { + const cached = officialBomDetailCache.get(key); + if (cached?.components?.length) bySku.set(key, cached.components); + }); + return { bySku, bySpuPrefix: [], entries: [] }; +} + +/** + * 从业务中台读取 MKH 组合商品 BOM,作为正式商品 BOM 缺失时的降级来源。 + */ +async function loadBusinessBom() { + const now = Date.now(); + if (businessBomCache?.value && businessBomCache.expiresAt > now) { + return businessBomCache.value; + } + if (businessBomCache?.pending) return businessBomCache.pending; + + const pending = Promise.all([ + cloudDatabase.query( + `SELECT + c.combine_sku_id AS comboSkuId, + c.src_sku_id AS componentSkuId, + c.qty AS qty, + s.name AS componentName + FROM JSDataMiddlePlatform.dbo.jst_comboProductItem c + LEFT JOIN dbo.jst_sku s ON s.sku_id = c.src_sku_id + WHERE c.combine_sku_id LIKE N'MKH%' + ORDER BY c.combine_sku_id, c.Id`, + {}, + { requestTimeout: 180000 } + ), + cloudDatabase.query( + `SELECT s.sku_id AS skuId, s.name AS productName + FROM dbo.jst_sku s + WHERE ${MKH_WHERE}`, + {}, + { requestTimeout: 180000 } + ), + ]).then(([rows, productRows]) => { + const bom = groupBusinessBomRows(rows); + const productNames = new Map(); + productRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key) productNames.set(key, toText(row.productName)); + }); + bom.existingSkuKeys = new Set(productNames.keys()); + bom.entries.forEach((entry) => { + entry.productName = productNames.get(normalizeCode(entry.skuCode)) || entry.productName; + }); + return bom; + }); + + const epoch = cacheEpoch; + businessBomCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + if (epoch === cacheEpoch) { + businessBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } + return value; + } catch (error) { + if (epoch === cacheEpoch) businessBomCache = null; + throw error; + } +} + +/** + * 从产品资料系统数据库读取全部「有配件明细」的模块化商品。 + * 返回 { bySku: Map, bySpuPrefix: Array, entries: Array } + * 失败时返回空结构并降级(页面照常展示列表,只是配件列为 0)。 + */ +async function loadPgBom() { + const now = Date.now(); + if (productBomCache?.value && productBomCache.expiresAt > now) { + return productBomCache.value; + } + if (productBomCache?.pending) return productBomCache.pending; + + const empty = { bySku: new Map(), bySpuPrefix: [], entries: [] }; + const sql = ` + SELECT + p.id AS product_id, + p.spu AS spu, + COALESCE(s->>'skuCode', s->>'barcode') AS sku_code, + s->'componentDetails' AS components + FROM public."IntakeNonIntegratedProduct" p + CROSS JOIN LATERAL jsonb_array_elements(COALESCE(p.payload->'skuItems', '[]'::jsonb)) s + WHERE jsonb_array_length(COALESCE(s->'componentDetails', '[]'::jsonb)) > 0`; + + const pending = (async () => { + let rows = []; + try { + rows = await productDb.query(sql); + } catch (error) { + console.warn('[MkhBom] 读取产品资料系统数据库失败,配件 BOM 降级:', error.message); + return empty; + } + + // 商品名称(资料库档案表,取不到不影响主流程) + const nameMap = new Map(); + try { + const nameRows = await productDb.query( + `SELECT id, "productName" FROM public."IntakeNonIntegratedProductProfile" WHERE "productName" IS NOT NULL`); + nameRows.forEach((row) => nameMap.set(toText(row.id), toText(row.productName))); + } catch (error) { + console.warn('[MkhBom] 读取资料库商品名称失败(忽略):', error.message); + } + + const bySku = new Map(); + const bySpuPrefix = []; + const entries = []; + + rows.forEach((row) => { + const components = parseComponents(row.components); + if (components.length === 0) return; + + const skuCode = toText(row.sku_code); + const spu = toText(row.spu); + const skuKey = normalizeCode(skuCode); + if (skuKey) bySku.set(skuKey, components); + + const spuKey = normalizeCode(spu); + if (spuKey) bySpuPrefix.push({ prefix: spuKey, components, spu }); + + entries.push({ + skuCode, + spu, + productName: nameMap.get(toText(row.product_id)) || spu || skuCode, + components, + }); + }); + + // 前缀长优先,避免短前缀抢先命中 + bySpuPrefix.sort((a, b) => b.prefix.length - a.prefix.length); + + return { bySku, bySpuPrefix, entries }; + })(); + + const epoch = cacheEpoch; + productBomCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + if (epoch === cacheEpoch) { + productBomCache = { pending: null, value, expiresAt: Date.now() + BOM_CACHE_TTL_MS }; + } + return value; + } catch (error) { + if (epoch === cacheEpoch) productBomCache = null; + throw error; + } +} + +/** + * 按编码匹配配件:先精确 SKU,再退化到 SPU 前缀。 + */ +function matchComponents(bom, skuId) { + const key = normalizeCode(skuId); + if (!key) return null; + + const exact = bom.bySku.get(key); + if (exact) return { components: exact, matchType: 'sku' }; + + const hit = bom.bySpuPrefix.find((entry) => key.startsWith(entry.prefix)); + if (hit) return { components: hit.components, matchType: 'spu' }; + + return null; +} + +function matchBomSources(officialBom, businessBom, productBom, skuId) { + const officialMatch = matchComponents(officialBom, skuId); + if (officialMatch) return { ...officialMatch, dataSource: 'official_bom' }; + + const businessMatch = matchComponents(businessBom, skuId); + if (businessMatch) return { ...businessMatch, dataSource: 'business_bom' }; + + const productMatch = matchComponents(productBom, skuId); + if (productMatch) return { ...productMatch, dataSource: 'product_db' }; + + return null; +} + +function summarizeConfiguredBoms(officialBom, businessBom, productBom) { + const summary = { official: 0, business: 0, product: 0, total: 0 }; + (businessBom?.existingSkuKeys || new Set()).forEach((skuKey) => { + if (matchComponents(officialBom, skuKey)) { + summary.official += 1; + summary.total += 1; + return; + } + if (matchComponents(businessBom, skuKey)) { + summary.business += 1; + summary.total += 1; + return; + } + if (matchComponents(productBom, skuKey)) { + summary.product += 1; + summary.total += 1; + } + }); + return summary; +} + +const sumQty = (components) => components.reduce((sum, item) => sum + (toNumber(item.qty) || 0), 0); + +/** + * 计算单个 MKH 成品的补货预警指标(纯函数,便于回归测试)。 + * + * 口径(见文件头 ⑦ + ⑦-a + ⑩ + ⑫): + * ⓪ 日销取值:上游快照(⑦);**快照 ≤ 0 且近15天有真实发货时**改用订单实算值(⑦-a), + * 结果带 `dailySaleSource`('snapshot' / 'orders')与 `snapshotDailySale` 便于对照。 + * 下面 ① / ② 用的 `dailySale` 即此处的最终取值。 + * ① 近15天日销 > 0 且 结余库存 < 3 天日销 → 预警 + * 结余库存 = max(0, 现有库存 − 待发货订单),待发订单为 0 时即现有库存(见 ⑫) + * 结余库存 = 0 → 缺货(订单都发不出去) + * ② 近15天日销 = 0 且有代发(待发货)订单 → 订单驱动预警(见文件头 ⑩): + * 可用天数 = 现有库存 ÷ 代发订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警 + * 两者都不满足 → 无销量,不参与预警。 + * 已有未完成加工单的商品一律标「已排产」,两种口径都不预警。 + * + * @param {object} row + * row.avgSale 上游近15天日均发货量(rpt.pm_stockMonitor.fifteenAvgSale) + * row.recentShippedQty 近15天已发货件数(订单表实算,⑦-a;已排除样品/测试店) + * → 快照 ≤ 0 且该值 > 0 时,日销改用 recentShippedQty ÷ 15 + * row.realtimeStock 品牌仓实时库存(可为 null,表示该仓无记录) + * row.monitorStock 监控快照库存(fallback) + * row.safetyDays 源系统安全天数(仅展示) + * row.suggestStock 源系统建议备货量(仅展示对照) + * row.monitorDate 监控快照日期 + * row.pendingWorkOrderQty 在途加工量(未完成且非作废) + * row.pendingOrderQty 待发货订单量(未发货订单件数) + */ +function buildReplenishMetrics(row = {}) { + // 日销 = 上游快照(⑦) + 订单兜底(⑦-a): + // 上游 fifteenAvgSale 会漏算,**只在快照 ≤ 0 且近 SALE_WINDOW_DAYS 天有真实发货时** + // 才改用订单实算值(已发货件数 ÷ 天数);快照 > 0 一律以上游为准,不覆盖。 + const snapshotDailySale = Math.max(0, toNumber(row.avgSale) ?? 0); + const recentShippedQty = Math.max(0, toNumber(row.recentShippedQty) ?? 0); + const orderDailySale = recentShippedQty > 0 + ? Number((recentShippedQty / SALE_WINDOW_DAYS).toFixed(4)) + : 0; + const preferLiveData = row.preferLiveData === true; + const useOrderDailySale = preferLiveData || (snapshotDailySale <= 0 && orderDailySale > 0); + const dailySale = useOrderDailySale ? orderDailySale : snapshotDailySale; + + // 现有库存来源优先级(见文件头 ⑦-d): + // inventoryInfo 品牌仓有货 → 用它(旧口径不变) + // 否则箱表(小时级)有货 → 用它(补「当日入库、日更库存表还没同步」的盲区) + // 否则 inventoryInfo 有行(qty≤0) → 用 0(该仓确实没货,别再退回更旧的快照) + // 否则回退监控快照 + const realtimeStock = toNumber(row.realtimeStock); + const packStock = toNumber(row.packStock); + const monitorStock = toNumber(row.monitorStock) ?? 0; + const hasRealtime = realtimeStock !== null; + let stock; + let stockSource; + if ((hasRealtime && realtimeStock > 0) || (packStock !== null && packStock > 0)) { + const realtimeValue = hasRealtime && realtimeStock > 0 ? realtimeStock : 0; + const packValue = packStock !== null && packStock > 0 ? packStock : 0; + stock = Math.max(realtimeValue, packValue); + stockSource = packValue > realtimeValue ? 'pack' : 'realtime'; + } else if (hasRealtime) { + stock = realtimeStock; + stockSource = 'realtime'; + } else if (!preferLiveData) { + stock = monitorStock; + stockSource = 'monitor'; + } else { + // 手动实时刷新不能再退回延迟快照;两个当前库存源均无记录时按 0 展示并明确标记来源。 + stock = 0; + stockSource = 'live-missing'; + } + + // 在途加工单(见文件头 ⑧):未完成且非作废的加工单待入库量 + const pendingWorkOrderQty = Math.max(0, toNumber(row.pendingWorkOrderQty) ?? 0); + const pendingWorkOrderCount = Math.max(0, toNumber(row.pendingWorkOrderCount) ?? 0); + const hasPendingWorkOrder = pendingWorkOrderQty > 0; + + // 待发货订单(见文件头 ⑩):当前未发货订单里的件数 + const pendingOrderQty = Math.max(0, toNumber(row.pendingOrderQty) ?? 0); + const pendingOrderCount = Math.max(0, toNumber(row.pendingOrderCount) ?? 0); + // 快照日销还是 0、但已经有订单 → 由订单驱动预警(⑩ 专属分支,分母用代发订单件数) + const orderDriven = dailySale <= 0 && pendingOrderQty > 0; + // 结余库存(见文件头 ⑫):已下单未发货的部分已被占用,先扣掉才是可自由支配的库存。 + // ⚠️ 不分日销是否为 0,所有口径统一按结余库存计算(2026-09-18 口径修正)。 + const availableStock = Math.max(0, stock - pendingOrderQty); + + const base = { + dailySale, + // 日销来源(⑦-a):'snapshot' 上游快照 / 'orders' 订单表实算兜底 + dailySaleSource: preferLiveData ? 'orders-live' : (useOrderDailySale ? 'orders' : 'snapshot'), + // 上游快照原值(保留下来做对照,业务对不上源系统数字时能看出是兜底生效) + snapshotDailySale, + // 近 SALE_WINDOW_DAYS 天已发货件数(⑦-a,已排除样品/测试店) + recentShippedQty, + stock, + availableStock, + pendingWorkOrderQty, + pendingWorkOrderCount, + hasPendingWorkOrder, + pendingWorkOrderEta: toDateText(row.pendingWorkOrderEta), + pendingWorkOrderNo: toText(row.pendingWorkOrderNo), + pendingOrderQty, + pendingOrderCount, + pendingOrderEarliest: toDateText(row.pendingOrderEarliest), + orderDriven, + thresholdQty: Number((dailySale * REPLENISH_DAYS_THRESHOLD).toFixed(4)), + stockSource, + safetyDays: toNumber(row.safetyDays), + suggestStock: toNumber(row.suggestStock) ?? 0, + monitorDate: toDateText(row.monitorDate), + }; + + // 既无近15天日销、也无未发货订单 → 不参与预警 + if (dailySale <= 0 && !orderDriven) { + return { + ...base, + thresholdQty: 0, + stockDays: null, + replenishQty: 0, + warningLevel: WARNING_LEVEL.NO_SALE, + warningOrder: 5, + hasWarning: false, + }; + } + + // 已有未完成加工单 → 不再列入补货预警(业务口径见文件头 ⑧;优先级高于订单驱动) + if (hasPendingWorkOrder) { + return { + ...base, + stockDays: dailySale > 0 ? Number((availableStock / dailySale).toFixed(2)) : null, + replenishQty: 0, + warningLevel: WARNING_LEVEL.SCHEDULED, + warningOrder: 4, + hasWarning: false, + }; + } + + // 订单驱动(见文件头 ⑩):日销为 0 但有代发(待发货)订单、有库存 + // → 用「现有库存 ÷ 代发订单件数」估算可用天数,≤ 3 天即补货预警 + // ⚠️ 阈值语义与日销分支一致:可用天数 < 3 天为「预警」、= 0(无库存)为「缺货」、 + // ≥ 3 天视为库存充足不预警;代发订单件数恒 > 0(orderDriven 已保证 pendingOrderQty > 0) + if (orderDriven) { + const orderStockDays = stock / pendingOrderQty; + let level; + let replenishQty; + if (orderStockDays <= 0) { + // 完全没货(库存 ≤ 0):缺货,建议补到能覆盖整笔代发订单 + level = WARNING_LEVEL.OUT_OF_STOCK; + replenishQty = Math.max(0, pendingOrderQty - stock); + } else if (orderStockDays < REPLENISH_DAYS_THRESHOLD) { + // 有货但可用天数不足 3 天:预警,建议补到 3 天缓冲(3 × 代发件数 − 现有库存) + level = WARNING_LEVEL.WARNING; + replenishQty = Math.max(0, Math.ceil(REPLENISH_DAYS_THRESHOLD * pendingOrderQty - stock)); + } else { + // 可用天数 ≥ 3 天:库存充足,不预警 + level = WARNING_LEVEL.NORMAL; + replenishQty = 0; + } + return { + ...base, + // 可用天数统一记录(保留 2 位小数,用于排序与展示) + stockDays: Number(orderStockDays.toFixed(2)), + replenishQty, + warningLevel: level, + // 排序权重:缺货 1 < 预警 2 < 正常 3(与日销分支一致) + warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 + : (level === WARNING_LEVEL.WARNING ? 2 : 3), + hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING, + }; + } + + // 日销驱动(见文件头 ⑦ + ⑫):一律用**结余库存**衡量,待发货订单先从库存里扣掉 + const stockDays = availableStock / dailySale; + // 建议补货量 = 3 天日销缓冲 + 待发货订单缺口(先补到能把订单发掉,再补 3 天缓冲) + const replenishQty = Math.max(0, Math.ceil(base.thresholdQty + pendingOrderQty - stock)); + const level = availableStock <= 0 + ? WARNING_LEVEL.OUT_OF_STOCK + : (stockDays < REPLENISH_DAYS_THRESHOLD ? WARNING_LEVEL.WARNING : WARNING_LEVEL.NORMAL); + + return { + ...base, + stockDays: Number(stockDays.toFixed(2)), + replenishQty, + warningLevel: level, + // 排序权重:缺货 1 < 预警 2 < 正常 3 < 已排产 4 < 无销量 5 + warningOrder: level === WARNING_LEVEL.OUT_OF_STOCK ? 1 : (level === WARNING_LEVEL.WARNING ? 2 : 3), + hasWarning: level === WARNING_LEVEL.OUT_OF_STOCK || level === WARNING_LEVEL.WARNING, + }; +} + +/** + * 未完成加工单对配件的占用(纯函数,见文件头 ⑪)。 + * 占用[配件编码] = Σ over 未完成加工单 (该成品在途台数 × 该成品对该配件的单台用量) + * 加工单在途的成品已经把配件预定/领走,计算可组装数时必须先扣掉,否则会高估补货能力。 + * @param {Array<{skuId: string, pendingWorkOrderQty: number}>} workOrders 未完成加工单(成品级) + * @param {Map} bomBySku 归一化成品编码 → 配件明细 + * @returns {Map} 配件编码 → 被占用量 + */ +function buildComponentOccupation(workOrders = [], bomBySku) { + const occupation = new Map(); + if (!bomBySku || typeof bomBySku.get !== 'function') return occupation; + + workOrders.forEach((workOrder) => { + const pending = Math.max(0, toNumber(workOrder?.pendingWorkOrderQty) ?? 0); + if (pending <= 0) return; + const key = normalizeCode(workOrder.skuId); + const components = key ? bomBySku.get(key) : null; + if (!Array.isArray(components) || components.length === 0) return; + components.forEach((component) => { + const code = toText(component?.barcode) || toText(component?.code); + if (!code) return; + const perQty = Math.max(1, toNumber(component.qty) ?? 1); + occupation.set(code, (occupation.get(code) || 0) + pending * perQty); + }); + }); + + return occupation; +} + +/** + * 配件可组装数 / 配件不足判定(纯函数,见文件头 ⑪)。 + * 可组装数 = min over 配件 ⌊ max(0, 库存 − 加工单占用) ÷ 单台用量 ⌋ + * 「木桶短板」决定能组装几台;可组装数 = 0 即配件不足、无法补货。 + * @param {Array} components 配件明细 [{ barcode, code, name, qty }] + * @param {Map} stockByCode 配件现有库存 + * @param {Map} occupiedByCode 加工单占用 + * @returns {null | {assemblableQty:number, shortage:boolean, bottlenecks:Array}} 无配件明细时返回 null(未知 ≠ 不足) + */ +function buildComponentReadiness(components, stockByCode, occupiedByCode) { + if (!Array.isArray(components) || components.length === 0) return null; + + let assemblable = Infinity; + const bottlenecks = []; + components.forEach((component) => { + const code = toText(component?.barcode) || toText(component?.code); + if (!code) return; + const perQty = Math.max(1, toNumber(component.qty) ?? 1); + const stock = Math.max(0, toNumber(stockByCode?.get(code)) ?? 0); + const occupied = Math.max(0, toNumber(occupiedByCode?.get(code)) ?? 0); + const available = Math.max(0, stock - occupied); + const can = Math.floor(available / perQty); + if (can < assemblable) assemblable = can; + bottlenecks.push({ + code, + name: toText(component.name) || code, + perQty, + stock, + occupied, + available, + can, + }); + }); + + if (!Number.isFinite(assemblable)) return null; + // 短板优先(可组装数最少),同分按编码稳定排序 + bottlenecks.sort((a, b) => a.can - b.can || a.code.localeCompare(b.code)); + + return { + assemblableQty: assemblable, + shortage: assemblable <= 0, + bottlenecks: bottlenecks.slice(0, COMPONENT_SHORTAGE_MAX_LIST), + }; +} + +/** + * MKH 商品集合(指定仓库的模块化成品)—— 进程内缓存。 + * 返回 [{ skuId, productName, category, material }],按编码升序。 + */ +async function loadMkhProductIndex() { + const now = Date.now(); + if (mkhProductCache?.value && mkhProductCache.expiresAt > now) { + return mkhProductCache.value; + } + if (mkhProductCache?.pending) return mkhProductCache.pending; + + const pending = cloudDatabase.query( + `SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category, s.other_10 AS material + FROM dbo.jst_sku s + WHERE ${MKH_WHERE} + ORDER BY s.sku_id ASC`, + {}, + { requestTimeout: 180000 } + ).then((rows) => rows.map((row) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + category: toText(row.category), + material: toText(row.material), + })).filter((row) => row.skuId)); + + const epoch = cacheEpoch; + mkhProductCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + if (epoch === cacheEpoch) { + mkhProductCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + } + return value; + } catch (error) { + if (epoch === cacheEpoch) mkhProductCache = null; + throw error; + } +} + +/** + * 未完成加工单(在途量)索引 —— 进程内缓存 { bySku: Map<归一化编码, 在途指标>, degraded }。 + * 只保留「半成品包装项目仓加工、未完成、非作废」的加工单,口径见文件头 ⑧。 + * ⚠️ 该数据源是链接服务器,取不到时**降级为空索引**(补货预警照常跑), + * 并缩短缓存避免长时间带着错误在途量。degraded=true 会被透出到接口。 + */ +async function loadPendingWorkOrderIndex() { + const now = Date.now(); + if (workOrderIndexCache?.value && workOrderIndexCache.expiresAt > now) { + return workOrderIndexCache.value; + } + if (workOrderIndexCache?.pending) return workOrderIndexCache.pending; + + const pending = (async () => { + // ⚠️ 必须走 getPool:cloudDatabase.query() 会把 `dbo.xxx` 重写成 `rpt.xxx`, + // 跨库四段名会被改坏(见文件头 ⑧)。 + const pool = await cloudDatabase.getPool(); + const request = pool.request(); + request.input('workOrderWarehouse', WORK_ORDER_WAREHOUSE); + request.input('voidStatus', WORK_ORDER_VOID_STATUS); + const result = await request.query(` + SELECT [商品编码] AS skuId, + SUM(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4)) + ELSE 0 + END) AS pendingQty, + COUNT(DISTINCT CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN [加工单号] + END) AS orderCount, + MIN(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN TRY_CONVERT(DATETIME, [预计加工完成时间], 111) + END) AS eta, + MIN(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([加工单号] AS NVARCHAR(40)) + END) AS firstOrderNo + FROM ${WORK_ORDER_TABLE} WITH (NOLOCK) + WHERE [加工仓储方] = @workOrderWarehouse + AND [状态] <> @voidStatus + GROUP BY [商品编码] + HAVING SUM(CASE + WHEN CAST([数量] AS DECIMAL(18, 4)) > CAST([已入库数量] AS DECIMAL(18, 4)) + THEN CAST([数量] AS DECIMAL(18, 4)) - CAST([已入库数量] AS DECIMAL(18, 4)) + ELSE 0 + END) > 0`); + + const bySku = new Map(); + (result.recordset || []).forEach((row) => { + const key = normalizeCode(row.skuId); + if (!key) return; + const pendingQty = Math.max(0, toNumber(row.pendingQty) ?? 0); + const orderCount = Math.max(0, toNumber(row.orderCount) ?? 0); + if (pendingQty <= 0) return; + bySku.set(key, { + // 原始商品编码:配件占用展开(见文件头 ⑪)需要拿它回查 BOM,故一并保留 + skuId: toText(row.skuId), + pendingWorkOrderQty: pendingQty, + pendingWorkOrderCount: orderCount, + pendingWorkOrderEta: row.eta instanceof Date ? row.eta : null, + pendingWorkOrderNo: toText(row.firstOrderNo), + }); + }); + return { bySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; + })().catch((error) => { + console.warn('[MKH-BOM] 加工单在途量读取失败,已按「无在途加工单」降级:', error.message); + return { bySku: new Map(), degraded: true, error: error.message, ttl: WORK_ORDER_DEGRADED_TTL_MS }; + }); + + const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; + workOrderIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { + workOrderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + } + return value; + } catch (error) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) workOrderIndexCache = null; + throw error; + } +} + +/** + * 解析 jst_Orders.skus 文本列 → [{ code, qty }](纯函数,便于回归测试)。 + * 格式:`<总件数>.<条码>[*数量][,<条码>[*数量]]…` + * 例:`1.MKHDNZ0409260700426` → 1 个成品 1 件 + * `2.A*1,B*1` → A、B 各 1 件 + * `3.C*1,D*1,E*1` → 总件数前缀只作校验,不参与计算 + * · 省略 `*数量` 时按 1 件;解析不了的片段直接丢弃(可用率实测见文件头 ⑩) + */ +function parseOrderSkus(skus) { + const text = toText(skus); + if (!text) return []; + const body = text.replace(/^\d+\./, ''); + const items = []; + body.split(',').forEach((part) => { + const segment = part.trim(); + if (!segment) return; + const matched = segment.match(/^(.*?)(?:\*(\d+))?$/); + if (!matched) return; + const code = toText(matched[1]); + if (!code) return; + const parsedQty = matched[2] ? Number(matched[2]) : 1; + items.push({ code, qty: Number.isFinite(parsedQty) && parsedQty > 0 ? parsedQty : 1 }); + }); + return items; +} + +/** 是否为 MKH **成品**编码(`MKH-` 打头的是组合装,见文件头 ⑩,不计入成品需求) */ +const isMkhProductCode = (code) => { + const text = toText(code); + return Boolean(text) && /^MKH/i.test(text) && !/^MKH-/i.test(text); +}; + +/** 是否为「样品 / 测试专用店铺」—— 上游日销不计这些单,兜底也必须排除(见文件头 ⑦-a) */ +const isExcludedSaleShop = (shopName) => { + const text = toText(shopName); + if (!text) return false; + return SALE_EXCLUDED_SHOP_KEYWORDS.some((keyword) => text.includes(keyword)); +}; + +/** + * 是否为「异常单」—— 标了已发货但货没真出的单,不能计入兜底日销(见文件头 ⑦-a)。 + * ① 标签含「特殊单」; + * ② 实付金额 ≤ 0.01 元(0.01 元全免单)。 + * ⚠️ 金额未知(null / 非数字)时**不排除** —— 宁可用数据也别误杀,取不到就退化成旧口径。 + */ +const isExcludedSaleOrder = (row = {}) => { + const labels = toText(row.labels); + if (labels && SALE_EXCLUDED_ORDER_LABEL_KEYWORDS.some((keyword) => labels.includes(keyword))) { + return true; + } + const paid = toNumber(row.payAmount); + return paid !== null && paid <= SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX; +}; + +/** 是否为「拆分单」——父单的出库记录挂在子单上,父单查不到出库属正常(见 ⑦-c) */ +const isSplitOrder = (status) => { + const text = toText(status); + return Boolean(text) && SPLIT_ORDER_STATUSES.includes(text.toLowerCase()); +}; + +/** 发货日是否为**今天** —— 当天出库尚未同步完成,不能用出库判据(见 ⑦-c) */ +const isShippedToday = (sendDate) => { + const date = sendDate instanceof Date ? sendDate : new Date(sendDate); + if (Number.isNaN(date.getTime())) return false; + const today = new Date(); + return date.getFullYear() === today.getFullYear() + && date.getMonth() === today.getMonth() + && date.getDate() === today.getDate(); +}; + +/** + * 该已发货单能否用「出库记录」来判定真伪(见 ⑦-c)。 + * 不能判定的两种情形,退回只按 ⑦-a 的金额/标签判据,**不做无出库排除**,避免误杀: + * · 拆分单:出库挂在子单上,父单必然查不到 + * · 今天发货:当天出库数据尚未同步完(实测 09-19 全天出库表只有 1 行) + * 取不到订单号时同样豁免——宁可漏杀,不可错杀。 + */ +const isOutboundCheckSkipped = (row = {}) => ( + isSplitOrder(row.status) + || isShippedToday(row.sendDate) + || !toText(row.oId) +); + +/** + * 批量查「这些订单号在出库表里有没有实际出库记录」(见 ⑦-c)。 + * 只取 o_id 一列、分块并行查,失败时返回空集合 + 错误信息让调用方降级。 + * ⚠️ 只判**有没有行**:`qty` 列全表 1,119,606 行恒为 0,`SUM(qty)` 永远得 0,别用它。 + */ +async function loadOutboundOrderIds(orderIds) { + const ids = [...new Set((orderIds || []).map((id) => toText(id)).filter(Boolean))]; + if (!ids.length) return { ids: new Set(), error: null }; + + const chunks = []; + for (let index = 0; index < ids.length; index += OUTBOUND_LOOKUP_CHUNK_SIZE) { + chunks.push(ids.slice(index, index + OUTBOUND_LOOKUP_CHUNK_SIZE)); + } + + const settled = await Promise.allSettled(chunks.map((chunk) => { + const params = {}; + const placeholders = chunk + .map((value, index) => { params[`outboundOid${index}`] = value; return `@outboundOid${index}`; }) + .join(', '); + const union = OUTBOUND_TABLES + .map((table) => `SELECT o_id AS oId FROM ${table} WITH (NOLOCK) WHERE o_id IN (${placeholders})`) + .join('\n UNION\n '); + return cloudDatabase.query( + `SELECT DISTINCT outbound_rows.oId AS oId + FROM ( + ${union} + ) outbound_rows`, + params, + { requestTimeout: 180000 } + ); + })); + + const hits = new Set(); + const failed = []; + settled.forEach((result, index) => { + if (result.status !== 'fulfilled') { + failed.push(`出库表分块#${index + 1}: ${result.reason?.message || result.reason}`); + return; + } + result.value.forEach((row) => { + const text = toText(row.oId); + if (text) hits.add(text); + }); + }); + return { ids: hits, error: failed.length ? failed.join(' | ') : null }; +} + +/** + * 订单索引 —— 进程内缓存,**一次扫表同时得到两份数据**(合并的理由见文件头 ⑦-a): + * pendingBySku : Map<归一化编码, { pendingOrderQty, pendingOrderCount, pendingOrderEarliest }> (⑩) + * shippedBySku : Map<归一化编码, 近 SALE_WINDOW_DAYS 天已发货件数>(⑦-a,已排除样品/测试店) + * 口径: + * · 待发货 = `send_date IS NULL` 且状态非取消/合并(⑩) + * · 已发货 = `send_date` 落在近 SALE_WINDOW_DAYS 天窗口内(⑦-a),且 + * ①非样品/测试店铺、②非异常单(金额/标签)、③**有实际出库记录**(⑦-c) + * · 两者商品都取自主表 `skus` 文本列,`MKH-` 打头的组合装不计 + * · 三条过滤只作用于**已发货**分支:待发货(⑩)本来就是没出货的订单,不做出库校验 + * ⚠️ 不降级为「抛错」:取不到时按空索引处理(补货预警退化成旧口径),并缩短缓存以便重试。 + */ +async function loadOrderIndex() { + const now = Date.now(); + if (orderIndexCache?.value && orderIndexCache.expiresAt > now) { + return orderIndexCache.value; + } + if (orderIndexCache?.pending) return orderIndexCache.pending; + + const statusParams = {}; + const statusPlaceholders = PENDING_ORDER_EXCLUDED_STATUS.map((status, index) => { + statusParams[`pendingOrderStatus${index}`] = status; + return `@pendingOrderStatus${index}`; + }).join(', '); + + // 两张订单表**并行**读取后合并(见 ⑩:渠道互不重叠,只读一张会漏需求) + // ⚠️ 一条查询同时覆盖「待发货」(send_date IS NULL)与「近 N 天已发货」(send_date 在窗口内): + // 两者共用同一次表扫描,拆成两条查询会在现有开销之上再加一次全表扫描(实测约 +1.25s)。 + // 窗口取含今天在内的 SALE_WINDOW_DAYS 个自然日(今天往前 14 天),与分母同口径。 + const pending = Promise.allSettled(ORDER_TABLES.map((table) => cloudDatabase.query( + `SELECT o.o_id AS oId, + o.order_date AS orderDate, + o.send_date AS sendDate, + o.shop_name AS shopName, + o.pay_amount AS payAmount, + o.labels AS labels, + o.status AS status, + o.skus AS skus + FROM ${table} o WITH (NOLOCK) + WHERE o.skus LIKE @pendingOrderSkuLike + AND o.status NOT IN (${statusPlaceholders}) + AND ( + o.send_date IS NULL + OR ( + o.send_date >= DATEADD(DAY, -${SALE_WINDOW_DAYS - 1}, CAST(GETDATE() AS date)) + AND o.send_date < DATEADD(DAY, 1, CAST(GETDATE() AS date)) + ) + )`, + { pendingOrderSkuLike: PENDING_ORDER_SKU_LIKE, ...statusParams }, + { requestTimeout: 180000 } + ))).then(async (results) => { + const pendingBySku = new Map(); + const shippedBySku = new Map(); + const seenOrderIds = new Set(); + const failed = []; + const shippedRows = []; + const pendingRows = []; + const outboundCheckIds = []; + + results.forEach((result, index) => { + if (result.status !== 'fulfilled') { + failed.push(`${ORDER_TABLES[index]}: ${result.reason?.message || result.reason}`); + return; + } + result.value.forEach((row) => { + // 两表 o_id 实测零交集(2026-09-19 校验),去重只是防御: + // 万一上游把同一张单补录进另一张表,也不会重复计件。 + const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId); + if (orderId !== null) { + if (seenOrderIds.has(orderId)) return; + seenOrderIds.add(orderId); + } + // 已发货(⑦-a):样品/测试专用店铺的单上游不计入日销,这里也必须排除 + const shipped = row.sendDate !== null && row.sendDate !== undefined; + if (shipped && isExcludedSaleShop(row.shopName)) return; + // 已发货(⑦-a):0.01 元特殊单「标了发货但没真出库」,同样不计入日销 + if (shipped && isExcludedSaleOrder(row)) return; + + if (shipped) { + shippedRows.push(row); + // ⑦-c:可用于出库校验的单先记下订单号,第二遍统一过滤 + if (!isOutboundCheckSkipped(row)) outboundCheckIds.push(orderId); + return; + } + pendingRows.push(row); + }); + }); + + // ⑦-c:已发货单再过一道「有没有真出库」。 + // 出库数据取不到(或只取到一部分)时**整体不做出库排除**,退化成只按金额/标签判据—— + // 宁可漏杀不可错杀:漏杀只是多一条预警,错杀会让真实缺货彻底隐身。 + let outboundReady = false; + let outboundIds = new Set(); + if (outboundCheckIds.length) { + const outbound = await loadOutboundOrderIds(outboundCheckIds); + if (outbound.error) { + console.warn('[MKH-BOM] 出库数据源读取失败,本次跳过出库校验:', outbound.error); + failed.push(outbound.error); + } else { + outboundReady = true; + outboundIds = outbound.ids; + } + } + + shippedRows.forEach((row) => { + const orderId = row.oId === null || row.oId === undefined ? null : String(row.oId); + // ⑦-c:能判定、却没查到出库 → 系统标了「已发货」但货没真出,不计入销量 + if (outboundReady && !isOutboundCheckSkipped(row) && !outboundIds.has(orderId)) return; + parseOrderSkus(row.skus).forEach(({ code, qty }) => { + if (!isMkhProductCode(code)) return; + const key = normalizeCode(code); + if (!key) return; + shippedBySku.set(key, (shippedBySku.get(key) || 0) + qty); + }); + }); + + pendingRows.forEach((row) => { + const orderDate = row.orderDate instanceof Date ? row.orderDate : null; + parseOrderSkus(row.skus).forEach(({ code, qty }) => { + if (!isMkhProductCode(code)) return; + const key = normalizeCode(code); + if (!key) return; + const entry = pendingBySku.get(key) || { + pendingOrderQty: 0, + pendingOrderCount: 0, + pendingOrderEarliest: null, + }; + entry.pendingOrderQty += qty; + entry.pendingOrderCount += 1; + if (orderDate && (!entry.pendingOrderEarliest || orderDate < entry.pendingOrderEarliest)) { + entry.pendingOrderEarliest = orderDate; + } + pendingBySku.set(key, entry); + }); + }); + + if (failed.length) { + // 部分源失败:仍用成功源的数据(比全空更接近真实),但标 degraded 让接口透出, + // 并用短 TTL 尽快重试补齐。 + console.warn('[MKH-BOM] 订单数据源部分读取失败,需求与日销口径可能不完整:', failed.join(' | ')); + return { + pendingBySku, + shippedBySku, + degraded: true, + error: failed.join(' | '), + ttl: PENDING_ORDER_DEGRADED_TTL_MS, + }; + } + return { pendingBySku, shippedBySku, degraded: false, error: null, ttl: MKH_CACHE_TTL_MS }; + }); + + const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; + orderIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { + orderIndexCache = { pending: null, value, expiresAt: Date.now() + value.ttl }; + } + return value; + } catch (error) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) orderIndexCache = null; + throw error; + } +} + +/** + * 补货预警索引 —— 进程内缓存 { bySku: Map<归一化编码, 指标>, monitorDate }。 + * 一次读入品牌仓监控快照与实时库存,指标计算全部在应用层完成(原因见文件头 ⑦)。 + */ +async function loadReplenishIndex() { + const now = Date.now(); + if (replenishIndexCache?.value && replenishIndexCache.expiresAt > now) { + return replenishIndexCache.value; + } + if (replenishIndexCache?.pending) return replenishIndexCache.pending; + + const pending = Promise.all([ + // 监控快照:同一天只有一行,按 date DESC 取首条即可 + cloudDatabase.query( + `SELECT m.sku_id AS skuId, m.${DAILY_SALE_COLUMN} AS fifteenAvgSale, m.jsStock AS monitorStock, + m.safetyDays AS safetyDays, m.suggestStock AS suggestStock, m.date AS monitorDate + FROM dbo.pm_stockMonitor m + WHERE m.warehouse = @brandWarehouse AND m.sku_id LIKE N'MKH%' + ORDER BY m.sku_id ASC, m.date DESC`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), + // 实时库存:同一 SKU 可能多行,按 updateTime DESC 取首条 + cloudDatabase.query( + `SELECT i.sku_id AS skuId, i.qty AS qty + FROM dbo.jst_inventoryInfo i + WHERE i.wms_co_id = @brandWmsCoId AND i.sku_id LIKE N'MKH%' + ORDER BY i.sku_id ASC, i.updateTime DESC`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), + // 箱/货位库存(见文件头 ⑦-d):inventoryInfo 是日更,当日入库当天看不到, + // 用小时级的箱表补 0。只取 bin 非空行 —— bin 为空的是装箱记录,不是库存。 + cloudDatabase.query( + `SELECT p.sku_id AS skuId, SUM(p.qty) AS qty + FROM dbo.jst_packInventoryInfo p + WHERE p.wms_co_id = @brandWmsCoId AND p.sku_id LIKE N'MKH%' + AND NULLIF(LTRIM(RTRIM(p.bin)), N'') IS NOT NULL + GROUP BY p.sku_id`, + REPLENISH_PARAMS, + { requestTimeout: 180000 } + ), + // 在途加工单(见文件头 ⑧) + loadPendingWorkOrderIndex(), + // 订单索引:待发货(⑩)+ 近15天已发货(⑦-a),共用同一次查询 + loadOrderIndex(), + ]).then(([monitorRows, stockRows, packStockRows, workOrderIndex, orderIndex]) => { + const workOrderBySku = workOrderIndex.bySku; + const pendingOrderBySku = orderIndex.pendingBySku; + const shippedBySku = orderIndex.shippedBySku; + const latestStock = new Map(); + stockRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key && !latestStock.has(key)) latestStock.set(key, toNumber(row.qty)); + }); + const packStock = new Map(); + packStockRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (key) packStock.set(key, toNumber(row.qty)); + }); + + const seenMonitor = new Set(); + const bySku = new Map(); + const liveBySku = new Map(); + let monitorDate = null; + monitorRows.forEach((row) => { + const key = normalizeCode(row.skuId); + if (!key || seenMonitor.has(key)) return; + seenMonitor.add(key); + if (!monitorDate) monitorDate = toDateText(row.monitorDate); + const metricInput = { + // 日销取数见文件头 ⑦:十五天口径(业务要 30 天但全库无此数据) + avgSale: row.fifteenAvgSale, + // 日销兜底见文件头 ⑦-a:快照漏算时用订单表实算的近15天发货量 + recentShippedQty: shippedBySku.get(key) || 0, + // 实时库存缺失(而非为 0)时才回退监控快照 + realtimeStock: latestStock.has(key) ? latestStock.get(key) : null, + // 箱/货位库存(⑦-d):inventoryInfo 日更,当日入库的货只有这张小时级表有 + packStock: packStock.has(key) ? packStock.get(key) : null, + monitorStock: row.monitorStock, + safetyDays: row.safetyDays, + suggestStock: row.suggestStock, + monitorDate: row.monitorDate, + ...(workOrderBySku.get(key) || null), + ...(pendingOrderBySku.get(key) || null), + }; + bySku.set(key, buildReplenishMetrics(metricInput)); + liveBySku.set(key, buildReplenishMetrics({ ...metricInput, preferLiveData: true })); + }); + + return { + bySku, + liveBySku, + latestStock, + packStock, + workOrderBySku, + pendingOrderBySku, + shippedBySku, + workOrderDegraded: workOrderIndex.degraded === true, + pendingOrderDegraded: orderIndex.degraded === true, + monitorDate, + }; + }); + + const epoch = cacheEpoch; + const operationalEpoch = operationalCacheEpoch; + replenishIndexCache = { pending, value: null, expiresAt: 0 }; + try { + const value = await pending; + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) { + replenishIndexCache = { pending: null, value, expiresAt: Date.now() + MKH_CACHE_TTL_MS }; + } + return value; + } catch (error) { + if (epoch === cacheEpoch && operationalEpoch === operationalCacheEpoch) replenishIndexCache = null; + throw error; + } +} + +/** + * 配件现有库存批量查询(见文件头 ⑪)—— 只查配件归属仓「简尚-崔桥工厂半成品包装项目仓」。 + * · 按基础配件编码精确匹配,SUM(qty) 汇总 + * · 结果按编码缓存(TTL 10 分钟),**查不到也缓存 0**(该仓无记录 = 无库存) + * · 任一分片查询失败 → 立即返回 degraded=true,调用方**放弃判定**(宁可少标不误标), + * 且失败的分片不写缓存,下次调用重试 + * @returns {Promise<{stockMap: Map, degraded: boolean}>} + */ +async function loadComponentStock(codes = []) { + const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; + const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); + const stockMap = new Map(); + const missing = []; + unique.forEach((code) => { + const hit = componentStockCache.get(code); + if (hit && hit.expiresAt > now) stockMap.set(code, hit.qty); + else missing.push(code); + }); + if (missing.length === 0) return { stockMap, degraded: false }; + + for (let i = 0; i < missing.length; i += COMPONENT_STOCK_CHUNK_SIZE) { + const chunk = missing.slice(i, i + COMPONENT_STOCK_CHUNK_SIZE); + try { + const params = { componentStockWmsCoId: COMPONENT_STOCK_WMS_CO_ID }; + const placeholders = chunk.map((code, index) => { + params[`cs${index}`] = code; + return `@cs${index}`; + }); + // eslint-disable-next-line no-await-in-loop -- 有意分片串行,避免一次打满连接池 + const rows = await cloudDatabase.query( + `SELECT i.sku_id AS code, SUM(i.qty) AS qty + FROM dbo.jst_inventoryInfo i WITH (NOLOCK) + WHERE i.wms_co_id = @componentStockWmsCoId + AND i.sku_id IN (${placeholders.join(', ')}) + GROUP BY i.sku_id`, + params, + { requestTimeout: 120000 } + ); + const found = new Map(); + rows.forEach((row) => { + const code = toText(row.code); + if (code) found.set(code, toNumber(row.qty) ?? 0); + }); + const expiresAt = Date.now() + COMPONENT_STOCK_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; + chunk.forEach((code) => { + const qty = found.get(code) ?? 0; + // 本批结果仍要返回给调用方(stockMap),只是不再写缓存 + if (writable) componentStockCache.set(code, { qty, expiresAt }); + stockMap.set(code, qty); + }); + } catch (error) { + console.warn('[MKH-BOM] 配件库存查询失败,本次不做配件不足判定:', error.message); + return { stockMap, degraded: true }; + } + } + + return { stockMap, degraded: false }; +} + +/** + * 批量计算商品的可组装数(见文件头 ⑪):列表 / 推送共用,防止口径漂移。 + * 依赖三份已缓存的索引,本身不再单独缓存: + * · 成品 BOM 明细 —— loadOfficialBomForSkuBatches(按 SKU 缓存 15 分钟) + * · 配件现有库存 —— loadComponentStock(按编码缓存 10 分钟) + * · 加工单占用 —— loadPendingWorkOrderIndex(缓存 10 分钟)按 BOM 展开 + * @param {string[]} skuIds 成品编码(只传缺货 / 预警的商品,避免全量扫描) + * @returns {Promise<{bySku: Map, degraded: boolean}>} key = 归一化成品编码 + */ +async function loadMkhComponentReadiness(skuIds = []) { + const requested = Array.from(new Set(skuIds.map((skuId) => toText(skuId)).filter(Boolean))); + if (requested.length === 0) return { bySku: new Map(), degraded: false }; + + const [alertBom, workOrderIndex] = await Promise.all([ + loadOfficialBomForSkuBatches(requested), + loadPendingWorkOrderIndex(), + ]); + + // 配件全集:预警商品自己的配件 + 加工单商品的配件(后者用于算占用) + const workOrders = [...workOrderIndex.bySku.values()]; + const workOrderSkuIds = workOrders.map((item) => item.skuId).filter(Boolean); + const workOrderBom = workOrderSkuIds.length > 0 + ? await loadOfficialBomForSkuBatches(workOrderSkuIds) + : { bySku: new Map() }; + + const occupation = buildComponentOccupation(workOrders, workOrderBom.bySku); + + const codes = new Set(occupation.keys()); + alertBom.bySku.forEach((components) => { + components.forEach((component) => { + const code = toText(component?.barcode) || toText(component?.code); + if (code) codes.add(code); + }); + }); + + const { stockMap, degraded } = await loadComponentStock([...codes]); + // 配件库存不可用 → 整批放弃判定(组件缺失 ≠ 配件不足,误标会误导业务) + if (degraded) return { bySku: new Map(), degraded: true }; + + const bySku = new Map(); + alertBom.bySku.forEach((components, key) => { + const readiness = buildComponentReadiness(components, stockMap, occupation); + if (readiness) bySku.set(key, readiness); + }); + + return { bySku, degraded: false }; +} + +/** + * MKH 商品 × 库存/日销指标 × 在途加工单(内存合并)。 + * 列表接口与补货预警推送共用同一份合并结果,避免两处口径漂移。 + */ +async function buildMergedReplenishRows(options = {}) { + const preferLiveData = options.preferLiveData === true; + const [products, replenishIndex] = await Promise.all([ + loadMkhProductIndex(), + loadReplenishIndex(), + ]); + + const rows = products.map((product) => { + const key = normalizeCode(product.skuId); + const metrics = (preferLiveData ? replenishIndex.liveBySku : replenishIndex.bySku).get(key); + if (metrics) return { ...product, ...metrics }; + // 监控快照里没有该商品时,仍要带上在途加工单(⑧)、待发货订单(⑩)与 + // 近15天已发货(⑦-a)信息 —— 后者正是「快照里根本没这个编码」时唯一的日销来源 + return { + ...product, + ...buildReplenishMetrics({ + preferLiveData, + realtimeStock: replenishIndex.latestStock?.has(key) + ? replenishIndex.latestStock.get(key) + : null, + packStock: replenishIndex.packStock?.has(key) + ? replenishIndex.packStock.get(key) + : null, + ...(replenishIndex.workOrderBySku?.get(key) || null), + ...(replenishIndex.pendingOrderBySku?.get(key) || null), + recentShippedQty: replenishIndex.shippedBySku?.get(key) || 0, + }), + }; + }); + + // 配件可组装数 / 配件不足(见文件头 ⑪):只对「缺货 / 预警」的商品计算, + // 「已排产」「无销量」等本来不需要补货的商品不参与,避免无谓查询。 + const alertSkuIds = rows.filter((row) => row.hasWarning).map((row) => row.skuId); + const readiness = alertSkuIds.length > 0 + ? await loadMkhComponentReadiness(alertSkuIds) + : { bySku: new Map(), degraded: false }; + + rows.forEach((row) => { + const hit = row.hasWarning ? readiness.bySku.get(normalizeCode(row.skuId)) : null; + // 未判定(非预警 / 无 BOM 明细 / 配件库存降级)一律保持 false + null,前端据此显示「—」 + row.componentShortage = hit?.shortage === true; + row.assemblableQty = hit ? hit.assemblableQty : null; + row.bottleneckComponents = hit ? hit.bottlenecks : []; + }); + + return { + rows, + replenishIndex, + componentStockDegraded: readiness.degraded === true, + }; +} + +/** + * 补货预警清单(全量、不分页)—— 供定时推送任务使用。 + * scope = 'warning'(默认)→ 缺货 + 3 天内预警 + * scope = 'outOfStock' → 仅缺货 + * ⚠️ 返回的是「需要补货」的商品,不包含已被未完成加工单豁免的「已排产」商品。 + */ +async function listReplenishAlertRows(options = {}) { + const scope = toText(options.scope); + const levels = scope === 'outOfStock' + ? [WARNING_LEVEL.OUT_OF_STOCK] + : [WARNING_LEVEL.OUT_OF_STOCK, WARNING_LEVEL.WARNING]; + + const { rows, replenishIndex, componentStockDegraded } = await buildMergedReplenishRows(); + const alerts = rows.filter((row) => levels.includes(row.warningLevel)); + + // 最紧急的排前面:缺货 → 可用天数最少 → 编码升序 + alerts.sort((a, b) => ( + (a.warningOrder || 9) - (b.warningOrder || 9) + || ((toNumber(a.stockDays) ?? 0) - (toNumber(b.stockDays) ?? 0)) + || String(a.skuId).localeCompare(String(b.skuId)) + )); + + return { + rows: alerts, + totalCount: alerts.length, + outOfStockCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length, + warningOnlyCount: alerts.filter((row) => row.warningLevel === WARNING_LEVEL.WARNING).length, + replenishQtyTotal: Number( + alerts.reduce((sum, row) => sum + (toNumber(row.replenishQty) || 0), 0).toFixed(2) + ), + // 订单驱动预警(日销=0 但有未发货订单)的条数,见文件头 ⑩ + orderDrivenCount: alerts.filter((row) => row.orderDriven).length, + pendingOrderQtyTotal: Number( + alerts.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2) + ), + monitorDate: replenishIndex.monitorDate, + workOrderDegraded: replenishIndex.workOrderDegraded === true, + pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true, + // 配件不足(见文件头 ⑪):可组装数 = 0、补不出货的商品数 + componentShortageCount: alerts.filter((row) => row.componentShortage).length, + componentStockDegraded: componentStockDegraded === true, + replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, + saleWindowDays: SALE_WINDOW_DAYS, + scope: scope === 'outOfStock' ? 'outOfStock' : 'warning', + }; +} + +function matchesPackSku(componentCode, packSkuCode) { + const component = toText(componentCode)?.toUpperCase(); + const packSku = toText(packSkuCode)?.toUpperCase(); + if (!component || !packSku) return false; + return packSku === component || packSku.startsWith(`${component}-`); +} + +function formatPackLocation(row) { + const warehouseName = toText(row.warehouseName); + const warehouseId = toText(row.warehouseId); + // 仓库优先显示名称(wms_co_id → jst_warehouseInfo.wms_co_id → name),取不到时退回 id。 + // ⚠️ 关联列是 wms_co_id 不是 wh_id,见文件头 ⑤(09-19 修正) + const warehouse = warehouseName || (warehouseId ? `仓库 ${warehouseId}` : ''); + const binGroup = toText(row.binGroup); + const bin = toText(row.bin); + const packId = toText(row.packId); + // 有真货位(bin_group / bin)才叫「仓位」;否则退回箱号 + const position = [binGroup, bin].filter(Boolean).join(' / ') + || (packId ? `箱号 ${packId}` : ''); + return [warehouse, position].filter(Boolean).join(' · '); +} + +function buildPackLocationMap(rows, componentCodes) { + const locations = new Map(componentCodes.map((code) => [code, new Set()])); + + rows.forEach((row) => { + const componentCode = componentCodes.find((code) => matchesPackSku(code, row.skuId)); + if (!componentCode) return; + const location = formatPackLocation(row); + if (location) locations.get(componentCode).add(location); + }); + + const result = new Map(); + locations.forEach((values, code) => { + const all = [...values]; + if (!all.length) return; + const visible = all.slice(0, 10); + result.set(code, all.length > visible.length + ? `${visible.join('、')} 等 ${all.length} 个位置` + : visible.join('、')); + }); + return result; +} + +/** + * 配件仓位批量查询(列表展开 / 单品明细 / 导出配件明细共用)。 + * 数据源**只有一个**:rpt.jst_packInventoryInfo(聚水潭「箱及仓位库存」), + * 仓名左连 rpt.jst_warehouseInfo(`Id = wh_id`,见文件头 ⑤ 的关联列警告)。 + * · 按基础配件编码匹配,兼容库内 sku_id 带「-装箱序号」后缀的形态 + * · 取值只有两层:真货位 bin_group / bin → 箱号 pack_id; + * 该表查不到就是空,**不回退**库存表仓 / 商品档案仓(那不是仓位) + * · 单个配件跨多个箱时最多列出 10 个位置,超出补「等 N 个位置」 + * · 结果按编码缓存(TTL 10 分钟);查询失败**不写缓存**,下次调用重试 + * @returns {Promise>} 配件编码 → 仓位文本(查不到的编码不含 key) + */ +async function loadComponentLocations(codes = []) { + const now = Date.now(); + // 见 cacheEpoch 注释:清缓存后才返回的旧查询结果不得写回缓存 + const epoch = cacheEpoch; + const unique = Array.from(new Set(codes.map((code) => toText(code)).filter(Boolean))); + const locationMap = new Map(); + const missing = []; + unique.forEach((code) => { + const hit = componentLocationCache.get(code); + if (hit && hit.expiresAt > now) { + if (hit.location) locationMap.set(code, hit.location); + } else { + missing.push(code); + } + }); + if (missing.length === 0) return locationMap; + + // 单元格超限由 COMPONENT_LOCATION_CHUNK_SIZE 分片,见常量处说明 + const chunks = []; + for (let i = 0; i < missing.length; i += COMPONENT_LOCATION_CHUNK_SIZE) { + chunks.push(missing.slice(i, i + COMPONENT_LOCATION_CHUNK_SIZE)); + } + + // 组间串行(本函数只打一条查询,避免一次塞满连接池) + for (const chunk of chunks) { + try { + const params = {}; + const conditions = chunk.map((code, index) => { + params[`lc${index}`] = code; + params[`lp${index}`] = `${code}-%`; + return `(p.sku_id = @lc${index} OR p.sku_id LIKE @lp${index})`; + }); + const rows = await cloudDatabase.query( + `SELECT p.sku_id AS skuId, + p.pack_id AS packId, + p.wms_co_id AS warehouseId, + p.bin AS bin, + p.bin_group AS binGroup, + w.name AS warehouseName, + SUM(p.qty) AS qty + FROM rpt.jst_packInventoryInfo p WITH (NOLOCK) + LEFT JOIN rpt.jst_warehouseInfo w WITH (NOLOCK) ON w.wms_co_id = p.wms_co_id + WHERE ${conditions.join(' OR ')} + GROUP BY p.sku_id, p.pack_id, p.wms_co_id, p.bin, p.bin_group, w.name + HAVING SUM(p.qty) > 0`, + params, + { requestTimeout: 120000 } + ); + const byCode = buildPackLocationMap(rows, chunk); + const expiresAt = Date.now() + COMPONENT_LOCATION_CACHE_TTL_MS; + const writable = epoch === cacheEpoch; + chunk.forEach((code) => { + const location = byCode.get(code) || null; + // 本批结果仍要返回给调用方(locationMap),只是不再写缓存 + if (writable) componentLocationCache.set(code, { location, expiresAt }); + if (location) locationMap.set(code, location); + }); + } catch (error) { + // 失败不写缓存,下次调用重试 + console.warn('[MkhBom] 配件仓位查询降级:', error.message); + } + } + + return locationMap; +} + +/** + * 列表:指定仓库的 MKH 模块化成品 + BOM 配件种类/数量。 + * BOM 数据只做明细匹配,不向商品列表追加无仓库档案的编码。 + */ +async function getMkhBomList(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 scope = resolveScope(query); + // 默认按可用天数升序 —— 最紧急的排最前 + const sortField = String(query.sortField || 'stockDays'); + const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const forceFresh = parseFlag(query.fresh); + const preferLiveData = parseFlag(query.live) || forceFresh; + + // fresh=1 是「真的重读运营数据」,不只是改用实时口径。同时到达的多个用户 + // 共用一次刷新,15 秒内的重复请求直接复用刚刚查到的结果,保护业务云库。 + if (forceFresh) await refreshMkhOperationalData(); + + const [mergedResult, officialBomIndex, businessBom, productBom] = await Promise.all([ + buildMergedReplenishRows({ preferLiveData }), + loadOfficialBomIndex(), + loadBusinessBom(), + loadPgBom(), + ]); + // 商品 × 库存/日销指标 × 在途加工单(内存合并,与补货预警推送共用) + const { rows: merged, replenishIndex, componentStockDegraded } = mergedResult; + + // 全量统计(不受关键词 / 范围筛选影响) + const warningRows = merged.filter((row) => row.hasWarning); + const stats = { + mkhTotalCount: merged.length, + soldCount: merged.filter((row) => toNumber(row.dailySale) > 0).length, + warningCount: warningRows.length, + outOfStockCount: warningRows.filter((row) => row.warningLevel === WARNING_LEVEL.OUT_OF_STOCK).length, + // 已排产:有未完成加工单、按业务口径不再预警的商品数(见文件头 ⑧) + scheduledCount: merged.filter((row) => row.warningLevel === WARNING_LEVEL.SCHEDULED).length, + pendingWorkOrderSkuCount: merged.filter((row) => row.hasPendingWorkOrder).length, + pendingWorkOrderQtyTotal: Number( + merged.reduce((sum, row) => sum + (toNumber(row.pendingWorkOrderQty) || 0), 0).toFixed(2) + ), + // 待发货订单(见文件头 ⑩):有未发货订单的成品数 / 件数,以及其中「靠订单才预警」的数量 + pendingOrderSkuCount: merged.filter((row) => (toNumber(row.pendingOrderQty) || 0) > 0).length, + pendingOrderQtyTotal: Number( + merged.reduce((sum, row) => sum + (toNumber(row.pendingOrderQty) || 0), 0).toFixed(2) + ), + orderDrivenCount: warningRows.filter((row) => row.orderDriven).length, + // 配件不足(见文件头 ⑪):可组装数 = 0,即现有配件凑不出一台、补不出来的商品数 + componentShortageCount: warningRows.filter((row) => row.componentShortage).length, + monitorDate: replenishIndex.monitorDate, + }; + + // 筛选 → 排序 → 分页(全部在内存完成,见文件头 ⑦) + const filtered = filterRows(merged, { keyword, scope }); + const sorted = sortRows(filtered, sortField, sortOrder); + const offset = (page - 1) * pageSize; + const pageRows = sorted.slice(offset, offset + pageSize); + + // 只对当前页补 BOM 明细,避免全量扫描 + const officialBom = await loadOfficialBomForSkus(pageRows.map((row) => row.skuId)); + const bomSummary = summarizeConfiguredBoms(officialBomIndex, businessBom, productBom); + + const rows = pageRows.map((row) => { + const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId); + const components = matched?.components || []; + return { + ...row, + accessoryCount: components.length, + accessoryQtySum: sumQty(components), + bomConfigured: components.length > 0 ? '已配置' : '未配置', + bomMatchType: matched?.matchType || null, + bomDataSource: matched?.dataSource || null, + source: 'business_db', + }; + }); + + return { + rows, + total: sorted.length, + bomRowCount: bomSummary.total, + bomOfficialRowCount: bomSummary.official, + bomBusinessRowCount: bomSummary.business, + bomProductDbRowCount: bomSummary.product, + bomExtraCount: 0, + bomReady: bomSummary.total > 0, + page, + pageSize, + scope, + configuredInPage: rows.filter((row) => row.accessoryCount > 0).length, + ...stats, + replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, + saleWindowDays: SALE_WINDOW_DAYS, + dataMode: preferLiveData ? 'live' : 'snapshot', + refreshedAt: new Date().toISOString(), + // 前后端实时刷新协议。本地前端如果代理到未重启的旧后端, + // 将拒绝展示其返回的旧库存,而不是把缺失协议字段当成刷新成功。 + freshDataContractVersion: 1, + freshDataApplied: forceFresh && operationalRefreshedAt > 0, + operationalRefreshedAt: operationalRefreshedAt + ? new Date(operationalRefreshedAt).toISOString() + : null, + // 加工单数据源(链接服务器)不可用时为 true,此时在途量按 0 处理 + workOrderDegraded: replenishIndex.workOrderDegraded === true, + workOrderWarehouse: WORK_ORDER_WAREHOUSE, + // 待发货订单数据源不可用时为 true,此时待发货量按 0 处理(退化为旧口径) + pendingOrderDegraded: replenishIndex.pendingOrderDegraded === true, + // 配件库存数据源不可用时为 true,此时**不做**配件不足判定(见文件头 ⑪) + componentStockDegraded: componentStockDegraded === true, + componentStockWarehouse: COMPONENT_STOCK_WAREHOUSE, + warningInPage: rows.filter((row) => row.hasWarning).length, + }; +} + +/** + * 明细:单个 MKH 成品的配件清单 + * 结构按业务模板:配件明细 | 数量 | 配件仓位 + */ +async function getMkhBomDetail(mkhSkuId) { + const skuId = toText(mkhSkuId); + if (!skuId) { + const error = new Error('缺少 MKH 商品编码'); + error.statusCode = 400; + throw error; + } + + const comboRows = await cloudDatabase.query( + `SELECT s.sku_id AS skuId, s.name AS productName, s.category AS category + FROM dbo.jst_sku s + WHERE s.sku_id = @mkhSkuId AND ${MKH_WHERE}`, + { mkhSkuId: skuId }, + { requestTimeout: 120000 } + ); + + const combo = comboRows[0] ? { + skuId: toText(comboRows[0].skuId), + productName: toText(comboRows[0].productName), + category: toText(comboRows[0].category), + } : null; + + if (!combo) { + const error = new Error('未找到指定仓库的该 MKH 商品'); + error.statusCode = 404; + throw error; + } + + const [officialBom, businessBom, productBom] = await Promise.all([ + loadOfficialBomForSkus([skuId]), + loadBusinessBom(), + loadPgBom(), + ]); + const matched = matchBomSources(officialBom, businessBom, productBom, skuId); + const components = matched?.components || []; + + const codes = components.map((item) => item.barcode).filter(Boolean); + // 与列表展开 / 导出配件明细走同一个加载器:只查「箱及仓位库存」,见文件头 ⑤ + const locationMap = await loadComponentLocations(codes); + + const items = components.map((component, index) => ({ + index: index + 1, + skuId: component.barcode, + barcode: component.barcode, + skuName: component.name, + role: component.role, + qty: toNumber(component.qty) ?? 0, + location: locationMap.get(component.barcode) || null, + })); + + return { + skuId: toText(combo.skuId), + productName: toText(combo.productName), + category: toText(combo.category), + items, + bomReady: items.length > 0, + bomMatchType: matched?.matchType || null, + bomDataSource: matched?.dataSource || null, + }; +} + +// ── ⑨ 导出 ──────────────────────────────────────────────────────── +const EXPORT_MODES = Object.freeze({ + PRODUCTS: 'products', + COMPONENTS: 'components', +}); + +/** + * 分批读取正式 BOM 明细。 + * loadOfficialBomForSkus() 单次只处理 MAX_PAGE_SIZE 个 SKU,导出全量时必须自行分批; + * 组内并发、组间串行,避免一次打满 mssql 连接池。 + * 每批结果都会写入 officialBomDetailCache,同一天重复导出直接命中缓存、不再查库。 + */ +async function loadOfficialBomForSkuBatches(skuIds = [], concurrency = 4) { + const unique = Array.from(new Map( + skuIds + .map((skuId) => [normalizeCode(skuId), toText(skuId)]) + .filter(([key, value]) => key && value) + ).values()); + const bySku = new Map(); + if (!unique.length) return { bySku, bySpuPrefix: [], entries: [] }; + + const batches = []; + for (let i = 0; i < unique.length; i += MAX_PAGE_SIZE) { + batches.push(unique.slice(i, i + MAX_PAGE_SIZE)); + } + + for (let i = 0; i < batches.length; i += concurrency) { + const group = batches.slice(i, i + concurrency); + // eslint-disable-next-line no-await-in-loop -- 有意分组串行:组内并发 4 路,避免打满连接池 + const parts = await Promise.all(group.map((batch) => loadOfficialBomForSkus(batch))); + parts.forEach((part) => { + part.bySku.forEach((components, key) => bySku.set(key, components)); + }); + } + + return { bySku, bySpuPrefix: [], entries: [] }; +} + +/** + * 商品清单导出行(纯函数,便于回归测试)。 + * 列口径与页面表格保持一致,只是去掉「操作」列、把 null 统一成可读值。 + */ +function buildProductExportRows(matchedRows = []) { + return matchedRows.map(({ row, components = [] }) => ({ + skuId: toText(row.skuId), + productName: toText(row.productName), + warningLevel: toText(row.warningLevel), + dailySale: toNumber(row.dailySale) ?? 0, + stock: toNumber(row.stock) ?? 0, + pendingWorkOrderQty: toNumber(row.pendingWorkOrderQty) ?? 0, + pendingOrderQty: toNumber(row.pendingOrderQty) ?? 0, + stockDays: toNumber(row.stockDays), + replenishQty: toNumber(row.replenishQty) ?? 0, + // 配件不足(见文件头 ⑪):可组装数 = 0 → 现有配件凑不出一台,标注出来避免业务照着补 + componentShortage: row.componentShortage === true ? '配件不足' : '', + assemblableQty: toNumber(row.assemblableQty), + bomConfigured: components.length > 0 ? '已配置' : '未配置', + accessoryCount: components.length, + accessoryQtySum: sumQty(components), + })); +} + +/** + * 配件明细导出行(纯函数)—— 按打印模板摊平:一行一个配件。 + * ⚠️ 同一商品的 MKH 编码逐行重复、不做纵向合并:合并单元格会让 Excel 筛选/透视失效, + * 业务拿这份数据多半是继续加工,扁平结构更合用。 + */ +function buildComponentExportRows(matchedRows = []) { + const rows = []; + matchedRows.forEach(({ row, components = [] }) => { + components.forEach((component, index) => { + rows.push({ + skuId: toText(row.skuId), + productName: toText(row.productName), + index: index + 1, + componentBarcode: toText(component.barcode || component.code), + componentName: toText(component.name), + qty: toNumber(component.qty) ?? 0, + location: toText(component.location), + }); + }); + }); + return rows; +} + +/** + * 导出:当前筛选条件下的全部行(不分页),见文件头 ⑨。 + * mode = products(默认)→ 商品清单;mode = components → 配件明细摊平 + */ +async function listMkhBomExportRows(query = {}) { + const requestedMode = String(query.mode || '').trim().toLowerCase(); + const mode = requestedMode === EXPORT_MODES.COMPONENTS + ? EXPORT_MODES.COMPONENTS + : EXPORT_MODES.PRODUCTS; + const keyword = toText(query.keyword); + const scope = resolveScope(query); + const sortField = String(query.sortField || 'stockDays'); + const sortOrder = String(query.sortOrder || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; + const preferLiveData = parseFlag(query.live) || parseFlag(query.fresh); + + // 与列表接口完全相同的筛选 / 排序口径:先全量合并,再内存筛选排序(见文件头 ⑦) + const { rows: merged, replenishIndex } = await buildMergedReplenishRows({ preferLiveData }); + const sorted = sortRows(filterRows(merged, { keyword, scope }), sortField, sortOrder); + + const [officialBom, businessBom, productBom] = await Promise.all([ + loadOfficialBomForSkuBatches(sorted.map((row) => row.skuId)), + loadBusinessBom(), + loadPgBom(), + ]); + + const matchedRows = sorted.map((row) => { + const matched = matchBomSources(officialBom, businessBom, productBom, row.skuId); + return { + row, + components: matched?.components || [], + matchType: matched?.matchType || null, + dataSource: matched?.dataSource || null, + }; + }); + + // ⚠️ 配件明细导出必须单独补仓位:BOM 明细本身不带仓位(见文件头 ⑤), + // 需要拿配件编码去「箱及仓位库」批量取,再按编码合并回明细行。 + // 页面展开走 /detail(同样会补),但导出走的是这条路径,漏掉会整列出空。 + if (mode === EXPORT_MODES.COMPONENTS) { + const componentCodes = matchedRows + .flatMap((item) => item.components.map((component) => component.barcode || component.code)); + const locationMap = await loadComponentLocations(componentCodes); + matchedRows.forEach((item) => { + item.components = item.components.map((component) => ({ + ...component, + location: locationMap.get(toText(component.barcode || component.code)) + || component.location + || null, + })); + }); + } + + const rows = mode === EXPORT_MODES.COMPONENTS + ? buildComponentExportRows(matchedRows) + : buildProductExportRows(matchedRows); + + return { + mode, + rows, + total: rows.length, + skuCount: sorted.length, + bomConfiguredCount: matchedRows.filter((item) => item.components.length > 0).length, + scope, + keyword, + sortField, + sortOrder, + monitorDate: replenishIndex.monitorDate, + replenishDaysThreshold: REPLENISH_DAYS_THRESHOLD, + saleWindowDays: SALE_WINDOW_DAYS, + dataMode: preferLiveData ? 'live' : 'snapshot', + refreshedAt: new Date().toISOString(), + workOrderDegraded: replenishIndex.workOrderDegraded === true, + }; +} + +// 手动同步:清空本页全部服务端缓存(BOM / 补货索引 / 加工单 / 待发货 / 配件库存 / 配件仓位)。 +// 页面「同步」按钮调用——清掉后下一次查询直连副本库,拿到的是上游最新快照, +// 而不是 2~15 分钟前的缓存结果。 +// 注意:副本库本身由聚水潭侧同步,应用层无法触发上游拉取,本函数只保证「不读旧缓存」。 +function clearMkhOperationalCache() { + operationalCacheEpoch += 1; + operationalRefreshedAt = 0; + replenishIndexCache = null; + workOrderIndexCache = null; + orderIndexCache = null; + return { + operationalCacheEpoch, + clearedAt: new Date().toISOString(), + }; +} + +/** + * 强制重读会变的运营数据。 + * - single-flight:并发用户共用同一次云库查询; + * - 15 秒去重:页面初始加载、切回标签和用户连点刷新不会重复压库; + * - 只刷库存/箱表/加工单/订单,不清 BOM 与商品主数据。 + */ +async function refreshMkhOperationalData() { + const now = Date.now(); + if (operationalRefreshPending) { + const result = await operationalRefreshPending; + return { ...result, joined: true }; + } + if (operationalRefreshedAt && now - operationalRefreshedAt < OPERATIONAL_REFRESH_DEDUPE_MS) { + return { reused: true, refreshedAt: new Date(operationalRefreshedAt).toISOString() }; + } + + clearMkhOperationalCache(); + const refreshEpoch = operationalCacheEpoch; + const pending = (async () => { + await loadReplenishIndex(); + if (refreshEpoch === operationalCacheEpoch) operationalRefreshedAt = Date.now(); + return { + refreshed: true, + refreshedAt: operationalRefreshedAt ? new Date(operationalRefreshedAt).toISOString() : null, + }; + })(); + operationalRefreshPending = pending; + try { + return await pending; + } finally { + if (operationalRefreshPending === pending) operationalRefreshPending = null; + } +} + +function clearMkhBomCache() { + // 🔥 先自增代际,再清各缓存:这样「本次清缓存之前已经发起、还没返回」的查询, + // 回来时 epoch 对不上,就不会把旧快照写回缓存(否则会覆盖掉清缓存后新查到的值, + // 导致按钮点完反而 2~10 分钟一直显示旧数据)。 + cacheEpoch += 1; + operationalCacheEpoch += 1; + operationalRefreshPending = null; + operationalRefreshedAt = 0; + + businessBomCache = null; + officialBomIndexCache = null; + productBomCache = null; + mkhProductCache = null; + replenishIndexCache = null; + workOrderIndexCache = null; + orderIndexCache = null; + + const cleared = [ + ['officialBomDetailCache', officialBomDetailCache.size], + ['componentLocationCache', componentLocationCache.size], + ['componentStockCache', componentStockCache.size], + ]; + officialBomDetailCache.clear(); + componentLocationCache.clear(); + componentStockCache.clear(); + + return { + syncedAt: new Date().toISOString(), + cacheEpoch, + operationalCacheEpoch, + cleared: cleared.map(([name, entries]) => ({ name, entries })), + }; +} + +module.exports = { + getMkhBomList, + clearMkhBomCache, + refreshMkhOperationalData, + getMkhBomDetail, + listReplenishAlertRows, + listMkhBomExportRows, + _private: { + normalizeCode, + groupOfficialBomRows, + groupBusinessBomRows, + matchComponents, + matchBomSources, + summarizeConfiguredBoms, + matchesPackSku, + formatPackLocation, + buildPackLocationMap, + loadComponentLocations, + COMPONENT_LOCATION_CHUNK_SIZE, + COMPONENT_LOCATION_CACHE_TTL_MS, + buildReplenishMetrics, + loadPendingWorkOrderIndex, + loadOrderIndex, + loadReplenishIndex, + parseOrderSkus, + isMkhProductCode, + isExcludedSaleShop, + isExcludedSaleOrder, + SALE_EXCLUDED_SHOP_KEYWORDS, + SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, + SALE_EXCLUDED_ORDER_LABEL_KEYWORDS, + // ⑦-c 出库校验 + isSplitOrder, + isShippedToday, + isOutboundCheckSkipped, + loadOutboundOrderIds, + OUTBOUND_TABLES, + OUTBOUND_LOOKUP_CHUNK_SIZE, + SPLIT_ORDER_STATUSES, + PENDING_ORDER_EXCLUDED_STATUS, + ORDER_TABLES, + // ⑪ 配件不足 / 可组装数 + buildComponentOccupation, + buildComponentReadiness, + loadComponentStock, + loadMkhComponentReadiness, + COMPONENT_STOCK_WMS_CO_ID, + COMPONENT_STOCK_WAREHOUSE, + COMPONENT_STOCK_CHUNK_SIZE, + COMPONENT_SHORTAGE_MAX_LIST, + parseFlag, + resolveScope, + filterRows, + sortRows, + matchesKeyword, + REPLENISH_DAYS_THRESHOLD, + SALE_WINDOW_DAYS, + SCOPES, + WARNING_LEVEL, + WORK_ORDER_WAREHOUSE, + WORK_ORDER_VOID_STATUS, + WORK_ORDER_RECEIVED_STATUS, + EXPORT_MODES, + buildProductExportRows, + buildComponentExportRows, + clearMkhOperationalCache, + refreshMkhOperationalData, + OPERATIONAL_REFRESH_DEDUPE_MS, + }, +}; diff --git a/backend/src/services/mkhReplenishAlertSettings.js b/backend/src/services/mkhReplenishAlertSettings.js new file mode 100644 index 0000000..ff91799 --- /dev/null +++ b/backend/src/services/mkhReplenishAlertSettings.js @@ -0,0 +1,227 @@ +'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, +}; diff --git a/backend/src/services/modularStockMonitorService.js b/backend/src/services/modularStockMonitorService.js new file mode 100644 index 0000000..affb51f --- /dev/null +++ b/backend/src/services/modularStockMonitorService.js @@ -0,0 +1,635 @@ +'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, +}; diff --git a/backend/test/cloudDatabaseConfigCache.test.js b/backend/test/cloudDatabaseConfigCache.test.js new file mode 100644 index 0000000..260b577 --- /dev/null +++ b/backend/test/cloudDatabaseConfigCache.test.js @@ -0,0 +1,126 @@ +/** + * 配置负缓存 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 反复淘汰(否则每个请求都要多打一次系统库)' + ); +}); diff --git a/backend/test/mkhBomApi.test.js b/backend/test/mkhBomApi.test.js new file mode 100644 index 0000000..8d333f7 --- /dev/null +++ b/backend/test/mkhBomApi.test.js @@ -0,0 +1,13 @@ +'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' } + ); +}); diff --git a/backend/test/mkhBomService.test.js b/backend/test/mkhBomService.test.js new file mode 100644 index 0000000..4f380e5 --- /dev/null +++ b/backend/test/mkhBomService.test.js @@ -0,0 +1,1337 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { _private } = require('../src/services/mkhBomService'); + +const emptyBom = () => ({ bySku: new Map(), bySpuPrefix: [], entries: [] }); + +test('official Jushuitan BOM rows use external component codes and consolidate duplicates', () => { + const bom = _private.groupOfficialBomRows([ + { + skuId: 'MKHDNZ0409260600022', + componentSkuId: 'DNZ039804N1410V01', + componentName: '桌面', + qty: '1.000000', + }, + { + skuId: 'MKHDNZ0409260600022', + componentSkuId: 'DNZ040928N1440V01', + componentName: 'K脚架', + qty: '1.000000', + }, + { + skuId: 'MKHDNZ0409260600022', + componentSkuId: 'DNZ040928N1440V01', + componentName: 'K脚架', + qty: '1.000000', + }, + { + skuId: 'MKHDNZ0409260600022', + componentSkuId: ' ', + componentName: '无外部编码的内部物料', + qty: 1, + }, + ]); + + const matched = _private.matchComponents(bom, 'MKHDNZ0409260600022'); + assert.deepEqual( + matched.components.map((item) => [item.barcode, item.name, item.qty]), + [ + ['DNZ039804N1410V01', '桌面', 1], + ['DNZ040928N1440V01', 'K脚架', 2], + ] + ); +}); + +test('business MKH BOM rows are grouped for drill-down and match codes without separators', () => { + const bom = _private.groupBusinessBomRows([ + { + comboSkuId: 'MKH-DNY004624N1015V01-1-DNZ038569N1280V01-1', + componentSkuId: 'DNY004624N1015V01', + componentName: '配件 A', + qty: 1, + }, + { + comboSkuId: 'MKH-DNY004624N1015V01-1-DNZ038569N1280V01-1', + componentSkuId: 'DNZ038569N1280V01', + componentName: '配件 B', + qty: 2, + }, + ]); + + const matched = _private.matchComponents( + bom, + 'MKHDNY004624N1015V011DNZ038569N1280V011' + ); + + assert.equal(bom.entries.length, 1); + assert.equal(matched.matchType, 'sku'); + assert.deepEqual( + matched.components.map((item) => [item.barcode, item.name, item.qty]), + [ + ['DNY004624N1015V01', '配件 A', 1], + ['DNZ038569N1280V01', '配件 B', 2], + ] + ); +}); + +test('duplicate business BOM component rows are consolidated without losing quantity', () => { + const bom = _private.groupBusinessBomRows([ + { comboSkuId: 'MKH-A-1', componentSkuId: 'PART-1', qty: 1 }, + { comboSkuId: 'MKH-A-1', componentSkuId: 'PART-1', qty: 2 }, + ]); + + const matched = _private.matchComponents(bom, 'MKHA1'); + assert.equal(matched.components.length, 1); + assert.equal(matched.components[0].qty, 3); +}); + +test('business BOM takes precedence while product database BOM remains a fallback', () => { + const businessBom = _private.groupBusinessBomRows([ + { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 }, + ]); + const productBom = { + bySku: new Map([['MKHA1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]), + bySpuPrefix: [], + entries: [], + }; + + const matched = _private.matchBomSources(emptyBom(), businessBom, productBom, 'MKHA1'); + assert.equal(matched.dataSource, 'business_bom'); + assert.equal(matched.components[0].barcode, 'BUSINESS-PART'); +}); + +test('official Jushuitan BOM takes precedence over all fallback sources', () => { + const officialBom = _private.groupOfficialBomRows([ + { skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 2 }, + ]); + const businessBom = _private.groupBusinessBomRows([ + { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 }, + ]); + const productBom = { + bySku: new Map([['MKHA1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]), + bySpuPrefix: [], + entries: [], + }; + + const matched = _private.matchBomSources(officialBom, businessBom, productBom, 'MKHA1'); + assert.equal(matched.dataSource, 'official_bom'); + assert.equal(matched.components[0].barcode, 'OFFICIAL-PART'); + assert.equal(matched.components[0].qty, 2); +}); + +test('BOM summary excludes combinations outside the designated warehouse SKU set', () => { + const businessBom = _private.groupBusinessBomRows([ + { comboSkuId: 'MKH-A-1', componentSkuId: 'BUSINESS-PART', qty: 1 }, + { comboSkuId: 'MKH-OUTSIDE-1', componentSkuId: 'OUTSIDE-PART', qty: 1 }, + ]); + businessBom.existingSkuKeys = new Set(['MKHA1', 'MKHB1']); + const productBom = { + bySku: new Map([['MKHB1', [{ barcode: 'PRODUCT-PART', qty: 1 }]]]), + bySpuPrefix: [], + entries: [], + }; + + const officialBom = _private.groupOfficialBomRows([ + { skuId: 'MKH-A-1', componentSkuId: 'OFFICIAL-PART', qty: 1 }, + ]); + + assert.deepEqual(_private.summarizeConfiguredBoms(officialBom, businessBom, productBom), { + official: 1, + business: 0, + product: 1, + total: 2, + }); +}); + +test('pack inventory locations match a BOM component and its package suffixes', () => { + assert.equal(_private.matchesPackSku('PART-1', 'PART-1'), true); + assert.equal(_private.matchesPackSku('PART-1', 'PART-1-10'), true); + assert.equal(_private.matchesPackSku('PART-1', 'PART-10'), false); +}); + +test('pack inventory location prefers bins and falls back to positive-stock box ids', () => { + const locations = _private.buildPackLocationMap([ + { + skuId: 'PART-1-1', warehouseId: 1, binGroup: '成品区', bin: 'A-01', packId: 'BOX-1', qty: 2, + }, + { + skuId: 'PART-1-2', warehouseId: 1, binGroup: null, bin: '', packId: 'BOX-2', qty: 1, + }, + ], ['PART-1']); + + assert.equal(locations.get('PART-1'), '仓库 1 · 成品区 / A-01、仓库 1 · 箱号 BOX-2'); +}); + +test('components without pack inventory rows do not receive a fallback warehouse location', () => { + const locations = _private.buildPackLocationMap([], ['PART-1']); + assert.equal(locations.has('PART-1'), false); +}); + +// ── 配件仓位:只认「箱及仓位库存」,不做库存表/商品档案仓回退(2026-09-17 业务口径)── +test('component location is sourced only from the pack inventory table', () => { + assert.equal(typeof _private.loadComponentLocations, 'function'); + // 兜底链已删除:这两个符号不应再存在(存在即有代码走回退) + assert.equal(_private.loadComponentLocationData, undefined); + assert.equal(_private.buildInventoryWarehouseMap, undefined); +}); + +test('pack inventory without bin/bin_group falls back to the box id, never to a warehouse', () => { + const locations = _private.buildPackLocationMap([ + { + skuId: 'PART-1-1', warehouseId: 1, warehouseName: '半成品仓', binGroup: null, bin: null, packId: 'BOX-9', qty: 3, + }, + ], ['PART-1', 'PART-2']); + + assert.equal(locations.get('PART-1'), '半成品仓 · 箱号 BOX-9'); + // 该表没有记录 → 无仓位,交给前端显示「未维护」,不回退任何仓库 + assert.equal(locations.has('PART-2'), false); +}); + +// ── 补货预警:近 15 天日销 > 0 且 现有库存 < 3 天日销 ─────────────── +const metrics = (row) => _private.buildReplenishMetrics(row); + +test('replenish: stock below 3 days of sales is flagged as stockout or warning', () => { + const stockout = metrics({ avgSale: 2.55, realtimeStock: 0, monitorStock: 0 }); + assert.equal(stockout.stockDays, 0); + assert.equal(stockout.warningLevel, '缺货'); + assert.equal(stockout.hasWarning, true); + assert.equal(stockout.replenishQty, 8); // ceil(3 * 2.55 - 0) = ceil(7.65) + + const warning = metrics({ avgSale: 2, realtimeStock: 1, monitorStock: 1 }); + assert.equal(warning.stockDays, 0.5); + assert.equal(warning.warningLevel, '预警'); + assert.equal(warning.hasWarning, true); + assert.equal(warning.replenishQty, 5); // ceil(6 - 1) +}); + +test('replenish: the threshold is exactly 3 days of sales', () => { + const atThreshold = metrics({ avgSale: 2, realtimeStock: 6, monitorStock: 6 }); + assert.equal(atThreshold.stockDays, 3); + assert.equal(atThreshold.warningLevel, '正常'); + assert.equal(atThreshold.hasWarning, false); + assert.equal(atThreshold.replenishQty, 0); + + const below = metrics({ avgSale: 2, realtimeStock: 5.9, monitorStock: 5.9 }); + assert.equal(below.hasWarning, true); +}); + +test('replenish: stock prefers the brand-warehouse realtime value, snapshot only as fallback', () => { + const realtime = metrics({ avgSale: 1, realtimeStock: 4, monitorStock: 9 }); + assert.equal(realtime.stock, 4); + assert.equal(realtime.stockSource, 'realtime'); + + // 实时库存「无记录」(null)才回退监控快照 + const fallback = metrics({ avgSale: 1, realtimeStock: null, monitorStock: 9 }); + assert.equal(fallback.stock, 9); + assert.equal(fallback.stockSource, 'monitor'); + + // 实时库存为 0 是有效值,不得被快照覆盖 + const zero = metrics({ avgSale: 1, realtimeStock: 0, monitorStock: 9 }); + assert.equal(zero.stock, 0); + assert.equal(zero.warningLevel, '缺货'); +}); + +test('replenish: live refresh ignores delayed sales and stock snapshots', () => { + const live = metrics({ + preferLiveData: true, + avgSale: 9, + recentShippedQty: 30, + realtimeStock: null, + packStock: 0, + monitorStock: 99, + }); + + assert.equal(live.dailySale, 2, '实时日销必须按 30 件 ÷ 15 天计算'); + assert.equal(live.dailySaleSource, 'orders-live'); + assert.equal(live.stock, 0, '实时库存源无记录时不得回退延迟快照库存 99'); + assert.equal(live.stockSource, 'live-missing'); +}); + +test('replenish: the larger current-stock source wins when the daily inventory table is stale (⑦-d)', () => { + // 当日入库:日更的库存表还没同步(qty=0 或无记录),小时级的箱表已有货 + const fillFromZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 4, monitorStock: 0 }); + assert.equal(fillFromZero.stock, 4); + assert.equal(fillFromZero.stockSource, 'pack'); + assert.notEqual(fillFromZero.warningLevel, '缺货'); + + const fillFromMissing = metrics({ avgSale: 1, realtimeStock: null, packStock: 5, monitorStock: 0 }); + assert.equal(fillFromMissing.stock, 5); + assert.equal(fillFromMissing.stockSource, 'pack'); + + // 新入库先到箱表:库存快照仍为旧的正数 4 时,不能把箱表的 47 压住 + const packWins = metrics({ avgSale: 1, realtimeStock: 4, packStock: 47, monitorStock: 0 }); + assert.equal(packWins.stock, 47); + assert.equal(packWins.stockSource, 'pack'); + + // 散货只在 inventoryInfo 中:该表较大时仍保留完整库存 + const realtimeWins = metrics({ avgSale: 1, realtimeStock: 100, packStock: 47, monitorStock: 0 }); + assert.equal(realtimeWins.stock, 100); + assert.equal(realtimeWins.stockSource, 'realtime'); + + // 两个实时源都没货 → 库存表说 0 就认 0,不再退回更旧的快照 + const bothZero = metrics({ avgSale: 1, realtimeStock: 0, packStock: 0, monitorStock: 9 }); + assert.equal(bothZero.stock, 0); + assert.equal(bothZero.stockSource, 'realtime'); + + // 库存表整个品牌仓无记录、箱表也没货 → 才回退监控快照 + const snapshot = metrics({ avgSale: 1, realtimeStock: null, packStock: 0, monitorStock: 9 }); + assert.equal(snapshot.stock, 9); + assert.equal(snapshot.stockSource, 'monitor'); +}); + +test('replenish: products without 7-day sales never warn', () => { + const noSale = metrics({ avgSale: 0, realtimeStock: 0, monitorStock: 0 }); + assert.equal(noSale.hasWarning, false); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.stockDays, null); + assert.equal(noSale.replenishQty, 0); + assert.equal(noSale.thresholdQty, 0); +}); + +test('replenish scope resolves from scope or the legacy warningOnly flag, then filters rows', () => { + const rows = [ + { skuId: 'A', hasWarning: true, dailySale: 2 }, + { skuId: 'B', hasWarning: false, dailySale: 2 }, + { skuId: 'C', hasWarning: false, dailySale: 0 }, + ]; + + assert.equal(_private.resolveScope({}), 'all'); + assert.equal(_private.resolveScope({ warningOnly: '1' }), 'warning'); + assert.equal(_private.resolveScope({ scope: 'sold' }), 'sold'); + assert.equal(_private.resolveScope({ scope: 'bogus', warningOnly: 'true' }), 'warning'); + + assert.deepEqual(_private.filterRows(rows, { scope: 'warning' }).map((r) => r.skuId), ['A']); + assert.deepEqual(_private.filterRows(rows, { scope: 'sold' }).map((r) => r.skuId), ['A', 'B']); + assert.deepEqual(_private.filterRows(rows, { keyword: 'b' }).map((r) => r.skuId), ['B']); +}); + +test('replenish sort puts the most urgent first and no-sale products last', () => { + const rows = [ + { skuId: 'NO-SALE', stockDays: null }, + { skuId: 'B', stockDays: 2.8 }, + { skuId: 'A', stockDays: 0 }, + { skuId: 'C', stockDays: 12 }, + ]; + + assert.deepEqual( + _private.sortRows(rows, 'stockDays', 'ASC').map((r) => r.skuId), + ['A', 'B', 'C', 'NO-SALE'] + ); + assert.deepEqual( + _private.sortRows(rows, 'stockDays', 'DESC').map((r) => r.skuId), + ['C', 'B', 'A', 'NO-SALE'] + ); + assert.deepEqual( + _private.sortRows(rows, 'skuId', 'ASC').map((r) => r.skuId), + ['A', 'B', 'C', 'NO-SALE'] + ); +}); + +// ── 在途加工单:半成品包装仓有未完成加工单就不再预警(2026-09-17 口径)── +test('replenish: a pending work order suppresses the replenish warning', () => { + // 库存为 0、本该判「缺货」,但半成品包装项目仓已有未完成加工单 + const scheduled = metrics({ + avgSale: 2, + realtimeStock: 0, + monitorStock: 0, + pendingWorkOrderQty: 20, + pendingWorkOrderCount: 1, + // 用本地时间构造,避免测试机时区不同导致日期串变化 + pendingWorkOrderEta: new Date(2026, 9, 1), + pendingWorkOrderNo: '893495', + }); + assert.equal(scheduled.hasWarning, false); + assert.equal(scheduled.warningLevel, '已排产'); + assert.equal(scheduled.replenishQty, 0); + assert.equal(scheduled.hasPendingWorkOrder, true); + assert.equal(scheduled.pendingWorkOrderQty, 20); + assert.equal(scheduled.pendingWorkOrderCount, 1); + assert.equal(scheduled.pendingWorkOrderEta, '2026-10-01'); + assert.equal(scheduled.pendingWorkOrderNo, '893495'); + // 可用天数仍如实反映现有库存,便于业务复核 + assert.equal(scheduled.stockDays, 0); + + // 同样库存但无加工单时仍然预警 —— 确认抑制效果确实来自加工单 + const bare = metrics({ avgSale: 2, realtimeStock: 0, monitorStock: 0 }); + assert.equal(bare.hasWarning, true); + assert.equal(bare.warningLevel, '缺货'); +}); + +test('replenish: pending work order fields default to empty without any order', () => { + const none = metrics({ avgSale: 1, realtimeStock: 0 }); + assert.equal(none.hasPendingWorkOrder, false); + assert.equal(none.pendingWorkOrderQty, 0); + assert.equal(none.pendingWorkOrderCount, 0); + assert.equal(none.pendingWorkOrderEta, null); + assert.equal(none.pendingWorkOrderNo, null); + + // 已入库数量追平后,即使上游状态文本或单据计数延迟,也不再按「已排产」处理 + const zeroQty = metrics({ + avgSale: 1, + realtimeStock: 0, + pendingWorkOrderQty: 0, + pendingWorkOrderCount: 2, + }); + assert.equal(zeroQty.hasPendingWorkOrder, false); + assert.equal(zeroQty.warningLevel, '缺货'); + assert.equal(zeroQty.hasWarning, true); +}); + +test('replenish: products without sales stay "no sale" even with a pending work order', () => { + const noSale = metrics({ + avgSale: 0, + realtimeStock: 0, + pendingWorkOrderQty: 5, + pendingWorkOrderCount: 1, + }); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.hasWarning, false); + // 在途量仍要透出,页面上应能看到 + assert.equal(noSale.pendingWorkOrderQty, 5); +}); + +test('pending work order filters keep the business-confirmed source and exclusions', () => { + assert.equal(_private.WORK_ORDER_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓'); + assert.equal(_private.WORK_ORDER_VOID_STATUS, '作废'); + assert.equal(_private.WORK_ORDER_RECEIVED_STATUS, '全部入库'); + assert.equal(_private.WARNING_LEVEL.SCHEDULED, '已排产'); +}); + +// ── 待发货订单(见服务文件头 ⑩)────────────────────────────────── +test('pending order: jst_Orders.skus text column is parsed into code/quantity pairs', () => { + // 单商品、省略 *数量 + assert.deepEqual(_private.parseOrderSkus('1.MKHDNZ0409260700426'), [ + { code: 'MKHDNZ0409260700426', qty: 1 }, + ]); + // 多商品、每个带 *数量(总件数前缀 2. 不参与计算) + assert.deepEqual(_private.parseOrderSkus('2.A*1,B*1'), [ + { code: 'A', qty: 1 }, + { code: 'B', qty: 1 }, + ]); + // 同一单里成品与组合装混排:文本照常解析,是否成品由 isMkhProductCode 判定 + assert.deepEqual( + _private.parseOrderSkus('3.GBGJ-ZWJ024903AN1407V01*1,GBGJ-ZWJ024903BN1407V01*1,MKHDNZ0409260600022*1'), + [ + { code: 'GBGJ-ZWJ024903AN1407V01', qty: 1 }, + { code: 'GBGJ-ZWJ024903BN1407V01', qty: 1 }, + { code: 'MKHDNZ0409260600022', qty: 1 }, + ] + ); + // 空值 / 空汇总(实测 23% 的普通订单是 `0.`,必须安全返回空数组) + assert.deepEqual(_private.parseOrderSkus('0.'), []); + assert.deepEqual(_private.parseOrderSkus(''), []); + assert.deepEqual(_private.parseOrderSkus(null), []); + assert.deepEqual(_private.parseOrderSkus('2.MKHDNZ0409260601388*2'), [ + { code: 'MKHDNZ0409260601388', qty: 2 }, + ]); +}); + +test('pending order: only MKH finished products count, combination codes are excluded', () => { + assert.equal(_private.isMkhProductCode('MKHDNZ0409260700426'), true); + assert.equal(_private.isMkhProductCode('MKHSJ0425080100098'), true); + // `MKH-` 打头的是组合装(拼接码),不是成品,不计入成品需求 + assert.equal(_private.isMkhProductCode('MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1'), false); + assert.equal(_private.isMkhProductCode('GBGJ-ZWJ024903AN1407V01'), false); + assert.equal(_private.isMkhProductCode(''), false); + assert.deepEqual([..._private.PENDING_ORDER_EXCLUDED_STATUS], ['Cancelled', 'Merged']); + // 订单按渠道拆成互不重叠的两张表:只读 jst_Orders 会漏掉天猫/淘宝/1688 的需求, + // 这两项是口径回归保护(2026-09-19,`MKHDNZ0409260700059` 漏报的根因)。 + // ⚠️ 待发货(⑩)与近15天已发货(⑦-a)共用这一组表。 + assert.deepEqual( + [..._private.ORDER_TABLES], + ['rpt.jst_Orders', 'rpt.jst_qmcust_orderlist'] + ); + // 日销兜底(⑦-a)排除样品/测试专用店铺:上游同样不计这些单,不排除会误拉进预警 + assert.deepEqual([..._private.SALE_EXCLUDED_SHOP_KEYWORDS], ['样品', '测试']); + assert.equal(_private.isExcludedSaleShop('样品专用店铺'), true); + assert.equal(_private.isExcludedSaleShop('某某测试专用店铺'), true); + assert.equal(_private.isExcludedSaleShop('天猫-HX旗舰店101'), false); + assert.equal(_private.isExcludedSaleShop(null), false); + // 日销兜底(⑦-a)排除异常单:0.01 元特殊单「标了发货但没真出库」,不是销售 + assert.equal(_private.SALE_EXCLUDED_ORDER_PAY_AMOUNT_MAX, 0.01); + assert.deepEqual([..._private.SALE_EXCLUDED_ORDER_LABEL_KEYWORDS], ['特殊单']); + assert.equal(_private.isExcludedSaleOrder({ payAmount: 0.01 }), true, '实付 0.01 元 → 异常单'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: 0 }), true, '实付 0 元 → 异常单'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: '0.01' }), true, '字符串金额同样识别'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: 908.01, labels: '模块化成品,特殊单,禁止拆分' }), true, + '标签含「特殊单」→ 异常单(即使实付正常)'); + // ⚠️ 反例:正常单普遍有折扣免额,**不能**按 free_amount 判异常,否则会误杀 + assert.equal(_private.isExcludedSaleOrder({ payAmount: 356, freeAmount: 50 }), false, '有免额的正常单不得排除'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: 1338.82, freeAmount: 236.26 }), false, '有免额的正常单不得排除'); + // 金额取不到时不排除(宁可用数据也别误杀) + assert.equal(_private.isExcludedSaleOrder({}), false, '金额缺省 → 不排除'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: null }), false, '金额为 null → 不排除'); + assert.equal(_private.isExcludedSaleOrder({ payAmount: '未知' }), false, '金额非数字 → 不排除'); +}); + +test('replenish index: hourly box/pallet stock surfaces same-day inbound (⑦-d)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const originalGetPool = cloudDatabase.getPool; + const queried = []; + + // 复现 MKHDNZ0409260700491:当日 15:19 入库,日更的 inventoryInfo 还没有这条, + // 只有小时级的箱表有;监控快照 jsStock = 0。 + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_packInventoryInfo')) { + queried.push('pack'); + return [{ skuId: 'MKHDNZ0409260700491', qty: 4 }]; + } + if (sql.includes('jst_inventoryInfo')) { + queried.push('inventory'); + return []; + } + if (sql.includes('pm_stockMonitor')) { + queried.push('monitor'); + return [{ + skuId: 'MKHDNZ0409260700491', + fifteenAvgSale: 0, + monitorStock: 0, + safetyDays: 3, + suggestStock: 0, + monitorDate: new Date('2026-09-18T00:00:00Z'), + }]; + } + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { + queried.push('outbound'); + return []; + } + if (sql.includes('jst_qmcust_orderlist')) { + queried.push('qmcust'); + return []; + } + queried.push('orders'); + return []; + }; + cloudDatabase.getPool = async () => ({ + request: () => ({ input() {}, query: async () => ({ recordset: [] }) }), + }); + + try { + const index = await svc._private.loadReplenishIndex(); + assert.ok(queried.includes('pack'), '箱表必须被读到'); + assert.ok(queried.includes('inventory'), '实时库存表仍要读'); + const row = index.bySku.get('MKHDNZ0409260700491'); + assert.equal(row.stock, 4, '当日入库的货必须显示出来,不能显示 0'); + assert.equal(row.stockSource, 'pack'); + } finally { + cloudDatabase.query = originalQuery; + cloudDatabase.getPool = originalGetPool; + svc.clearMkhBomCache(); + } +}); + +test('order index: reads both channel tables, merges and dedupes by o_id', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const queried = []; + + // 出库表命中集合(⑦-c):9005 / 9007 / 9011 有真实出库记录; + // 9012(金额正常但无出库)、9009 / 9010 / 9015(异常单)刻意不在此列 + const outboundHits = ['9005', '9007', '9011']; + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { + queried.push('outbound'); + return outboundHits.map((oId) => ({ oId })); + } + if (sql.includes('jst_qmcust_orderlist')) { + queried.push('qmcust'); + return [ + { oId: 9001, orderDate: new Date('2026-09-18T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' }, + { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' }, + // 已发货行(⑦-a):与待发货**同一次查询**返回,靠 send_date 是否有值区分 + { + oId: 9005, + sendDate: new Date('2026-09-09T00:00:00Z'), + shopName: '天猫-HX旗舰店101', + skus: '1.MKHDNZ0409260601548', + }, + // 样品专用店铺的已发货单必须排除(上游同样不计这些单) + { + oId: 9006, + sendDate: new Date('2026-09-10T00:00:00Z'), + shopName: '样品专用店铺', + skus: '2.MKHDNZ0409260609999', + }, + ]; + } + queried.push('orders'); + return [ + // 与 qmcust 同 o_id → 必须只算一次(防御上游把同一单补录进另一张表) + { oId: 9002, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260601228' }, + { oId: 9003, orderDate: new Date('2026-09-17T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059*2' }, + // 组合装(MKH- 打头)不计入成品需求 + { + oId: 9004, + orderDate: new Date('2026-09-16T00:00:00Z'), + sendDate: null, + skus: '1.MKH-DNZ038522N1280V01-1-GBGJ-DNZ038403N1440V01-1', + }, + // 已发货:真实店铺,同编码跨表累加 + { + oId: 9007, + sendDate: new Date('2026-09-11T00:00:00Z'), + shopName: '京东-HX旗舰店102', + skus: '1.MKHDNZ0409260601548*2', + }, + // 已发货:测试专用店铺 → 排除 + { + oId: 9008, + sendDate: new Date('2026-09-12T00:00:00Z'), + shopName: '某某测试专用店铺', + skus: '1.MKHDNZ0409260608888', + }, + // 异常单(⑦-a):0.01 元全免单,标了已发货但没真出库 → 排除 + // (复现 MKHDNZ0410260700005:实付 0.01 / 免额 908 / 标签含「特殊单」) + { + oId: 9009, + sendDate: new Date('2026-09-18T00:00:00Z'), + shopName: '拼多多-亿家达书房家具官方旗舰店', + payAmount: 0.01, + freeAmount: 908, + labels: '大单待确认,模块化成品,特殊单,禁止拆分,线上发货,转x00005', + skus: '9.MKHDNZ0410260700005*9', + }, + // 异常单(⑦-a):实付正常但标签含「特殊单」→ 同样排除 + { + oId: 9010, + sendDate: new Date('2026-09-18T00:00:00Z'), + shopName: '拼多多-亿家达书房家具官方旗舰店', + payAmount: 500, + labels: '模块化成品,特殊单', + skus: '1.MKHDNZ0410260700099', + }, + // 对照:有折扣免额的**正常单**必须照常计入(第一版按 free_amount 判会误杀它) + { + oId: 9011, + sendDate: new Date('2026-09-18T00:00:00Z'), + shopName: '拼多多-HX旗舰店103', + payAmount: 356, + freeAmount: 50, + labels: '店铺-拼多多,模块化成品', + skus: '1.MKHDNZ0410260700023', + }, + // ⑦-c:金额正常、但出库表查不到 → 系统标了「已发货」货没真出 → 排除 + // (业务原话:标了已发货但没有实际出库的不需要出现缺货预警) + { + oId: 9012, + sendDate: new Date('2026-09-10T00:00:00Z'), + shopName: '拼多多-HX旗舰店105', + payAmount: 388, + labels: '发货后取消,发货后售后,风控订单', + skus: '1.MKHDNZ0409260601888', + }, + // ⑦-c 豁免①:拆分单的出库挂在子单上,父单查不到属正常 → 必须保留 + // (实测窗口内 40 张 Split 单 100% 无出库记录) + { + oId: 9013, + status: 'Split', + sendDate: new Date('2026-09-15T00:00:00Z'), + shopName: '京东-HX旗舰店104', + payAmount: 1194.14, + labels: '以旧换新,模块化成品', + skus: '1.MKHDNZ0409260601777', + }, + // ⑦-c 豁免②:今天发货的单,当天出库尚未同步完 → 必须保留 + { + oId: 9014, + sendDate: new Date(), + shopName: '京东-HX旗舰店104', + payAmount: 500, + labels: '模块化成品', + skus: '1.MKHDNZ0409260601666', + }, + // ⑦-c:拆分单/今天单虽然豁免**出库**校验,但金额/标签判据照样生效 + { + oId: 9015, + status: 'Split', + sendDate: new Date('2026-09-15T00:00:00Z'), + shopName: '拼多多-亿家达书房家具官方旗舰店', + payAmount: 0.01, + labels: '特殊单,模块化成品', + skus: '1.MKHDNZ0410260700777', + }, + ]; + }; + + try { + svc.clearMkhBomCache(); + const index = await _private.loadOrderIndex(); + + assert.deepEqual(queried.sort(), ['orders', 'outbound', 'qmcust'], '两张订单表 + 出库表都必须读到'); + assert.equal(index.degraded, false); + + const a = index.pendingBySku.get('MKHDNZ0409260700059'); + assert.equal(a.pendingOrderQty, 3, '两表合并:1 + 2'); + assert.equal(a.pendingOrderCount, 2); + + const b = index.pendingBySku.get('MKHDNZ0409260601228'); + assert.equal(b.pendingOrderQty, 1, '同 o_id 跨表重复只计一次'); + assert.equal(b.pendingOrderCount, 1); + + assert.equal(index.pendingBySku.size, 2, '组合装不进入索引'); + + // 已发货(⑦-a):同一次查询拿到,真实店铺跨表累加、样品/测试店排除 + assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 3, '已发货件数 1 + 2 累加'); + assert.equal(index.shippedBySku.has('MKHDNZ0409260609999'), false, '样品专用店铺要排除'); + assert.equal(index.shippedBySku.has('MKHDNZ0409260608888'), false, '测试专用店铺要排除'); + // 异常单排除(⑦-a):0.01 元全免单、标签含「特殊单」都不计入已发货 + assert.equal(index.shippedBySku.has('MKHDNZ0410260700005'), false, '0.01 元异常单不得计入日销'); + assert.equal(index.shippedBySku.has('MKHDNZ0410260700099'), false, '标签含「特殊单」的不得计入日销'); + // 有免额的正常单必须照常计入 + assert.equal(index.shippedBySku.get('MKHDNZ0410260700023'), 1, '有免额的正常单不得被误排除'); + // ⑦-c 出库校验:标了已发货但出库表查不到 → 不计入日销 + assert.equal(index.shippedBySku.has('MKHDNZ0409260601888'), false, '无实际出库记录的不得计入日销'); + // ⑦-c 豁免:拆分单 / 今天发货的单不能用出库判定,必须保留 + assert.equal(index.shippedBySku.get('MKHDNZ0409260601777'), 1, '拆分单无出库记录属正常,不得误杀'); + assert.equal(index.shippedBySku.get('MKHDNZ0409260601666'), 1, '今天发货的出库尚未同步,不得误杀'); + // ⑦-c 豁免只免「出库校验」,金额/标签判据照样生效 + assert.equal(index.shippedBySku.has('MKHDNZ0410260700777'), false, '拆分单的 0.01 元异常单仍要排除'); + assert.equal(index.shippedBySku.size, 4, '被排除的店铺/异常单/无出库单不进入索引'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('order index: outbound lookup failure skips the check instead of dropping sales', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + // 出库表整体不可用(例如视图未同步)——此时宁可漏杀不可错杀: + // 不做出库排除(否则真实销售会被整批砍掉),但要标 degraded 触发短 TTL 重试。 + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_salesOutboundInfo') || sql.includes('jst_qmcust_salesoutbound')) { + throw new Error('出库视图不可用'); + } + if (sql.includes('jst_qmcust_orderlist')) return []; + return [ + { + oId: 9101, + sendDate: new Date('2026-09-10T00:00:00Z'), + shopName: '京东-HX旗舰店104', + payAmount: 388, + labels: '模块化成品', + skus: '1.MKHDNZ0409260601777', + }, + ]; + }; + + try { + svc.clearMkhBomCache(); + const index = await _private.loadOrderIndex(); + assert.equal(index.degraded, true, '出库源失败要标 degraded,让接口透出并尽快重试'); + assert.equal( + index.shippedBySku.get('MKHDNZ0409260601777'), + 1, + '出库数据取不到时不得据此砍掉销量(退化成只按金额/标签判据)' + ); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('order index: one failing channel degrades but still returns the other table', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + cloudDatabase.query = async (sql) => { + if (sql.includes('jst_qmcust_orderlist')) throw new Error('qmcust unavailable'); + return [ + { oId: 9100, orderDate: new Date('2026-09-19T00:00:00Z'), sendDate: null, skus: '1.MKHDNZ0409260700059' }, + { + oId: 9101, + sendDate: new Date('2026-09-15T00:00:00Z'), + shopName: '京东-HX旗舰店102', + skus: '1.MKHDNZ0409260601548', + }, + ]; + }; + + try { + svc.clearMkhBomCache(); + const index = await _private.loadOrderIndex(); + + assert.equal(index.degraded, true, '部分源失败必须透出 degraded'); + assert.match(index.error, /qmcust unavailable/); + assert.equal(index.pendingBySku.get('MKHDNZ0409260700059').pendingOrderQty, 1, '成功源的数据仍要保留'); + assert.equal(index.shippedBySku.get('MKHDNZ0409260601548'), 1, '成功源的已发货数据同样保留'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('replenish: daily sale falls back to order-derived value when snapshot is 0', () => { + // 上游快照漏算(⑦-a):`MKHDNZ0409260601548` 近15天实发 2 件、快照日销 0、库存 0, + // 旧口径走「无销量」分支彻底隐身,连缺货都不报。 + const fallback = metrics({ avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(fallback.dailySaleSource, 'orders', '快照为 0 且有发货 → 用订单实算兜底'); + assert.equal(fallback.snapshotDailySale, 0); + assert.equal(fallback.recentShippedQty, 2); + assert.equal(fallback.dailySale, Number((2 / 15).toFixed(4)), '日销 = 2 件 ÷ 15 天'); + assert.equal(fallback.warningLevel, '缺货', '兜底后库存 0 必须报缺货'); + assert.equal(fallback.hasWarning, true); + // 建议补货量 = ceil(3 × 0.1333 + 0 − 0) = 1 + assert.equal(fallback.replenishQty, 1); + + // 快照 > 0 → 一律以上游为准,不被订单实算值覆盖 + const snapshotWins = metrics({ avgSale: 0.5, recentShippedQty: 30, realtimeStock: 0 }); + assert.equal(snapshotWins.dailySaleSource, 'snapshot'); + assert.equal(snapshotWins.dailySale, 0.5, '不覆盖上游快照'); + + // 快照 0 且无发货 → 仍是「无销量」,不参与预警(兜底不能凭空造需求) + const noFallback = metrics({ avgSale: 0, recentShippedQty: 0, realtimeStock: 0 }); + assert.equal(noFallback.dailySale, 0); + assert.equal(noFallback.dailySaleSource, 'snapshot'); + assert.equal(noFallback.warningLevel, '无销量'); + assert.equal(noFallback.hasWarning, false); + + // 兜底日销很小但库存充足 → 正常,不制造噪声 + const enough = metrics({ avgSale: 0, recentShippedQty: 1, realtimeStock: 50, pendingOrderQty: 0 }); + assert.equal(enough.dailySaleSource, 'orders'); + assert.equal(enough.warningLevel, '正常'); + assert.equal(enough.hasWarning, false); + + // 兜底后又快又慢:兜底日销 > 0 时走日销驱动分支(①),不再走订单驱动(②)—— + // 因为此时已经有真实需求历史,用「结余库存 ÷ 日销」衡量比「库存 ÷ 代发件数」更准 + const both = metrics({ + avgSale: 0, recentShippedQty: 2, realtimeStock: 0, pendingOrderQty: 5, pendingOrderCount: 1, + }); + assert.equal(both.dailySaleSource, 'orders'); + assert.equal(both.orderDriven, false, '有兜底日销后不再是订单驱动'); + assert.equal(both.warningLevel, '缺货'); + // 建议补货量 = ceil(3 × 0.1333 + 5 − 0) = ceil(5.3999) = 6(先补订单缺口再补 3 天缓冲) + assert.equal(both.replenishQty, 6); + + // 订单源整体不可用 → recentShippedQty 缺省 0 → 退化成旧口径(不会误报) + const degraded = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(degraded.recentShippedQty, 0); + assert.equal(degraded.dailySaleSource, 'snapshot'); + assert.equal(degraded.warningLevel, '无销量'); +}); + +test('replenish: no sales but unshipped orders warn by available days (stock / order qty)', () => { + // 可用天数 = 现有库存 ÷ 代发订单件数;≤ 3 天即预警,= 0(无库存)为缺货。 + // 完全没货:可用天数 0 → 缺货,补货量 = 代发件数 − 现有库存 + const outOfStock = metrics({ + avgSale: 0, realtimeStock: 0, monitorStock: 0, pendingOrderQty: 4, pendingOrderCount: 2, + }); + assert.equal(outOfStock.hasWarning, true); + assert.equal(outOfStock.warningLevel, '缺货'); + assert.equal(outOfStock.orderDriven, true); + assert.equal(outOfStock.stockDays, 0); + assert.equal(outOfStock.replenishQty, 4); + assert.equal(outOfStock.pendingOrderQty, 4); + assert.equal(outOfStock.pendingOrderCount, 2); + + // 有货但只够 0.25 天 → 预警(不再因「库存 < 订单件数」直接判缺货) + const short = metrics({ + avgSale: 0, realtimeStock: 1, monitorStock: 1, pendingOrderQty: 4, + }); + assert.equal(short.warningLevel, '预警'); + assert.equal(short.hasWarning, true); + assert.equal(short.stockDays, 0.25); + // 补到 3 天缓冲:ceil(3 × 4 − 1) = 11 + assert.equal(short.replenishQty, 11); + + // 可用天数正好 1 天 → 仍预警,补到 3 天缓冲:ceil(3 × 4 − 4) = 8 + const oneDay = metrics({ + avgSale: 0, realtimeStock: 4, monitorStock: 4, pendingOrderQty: 4, + }); + assert.equal(oneDay.warningLevel, '预警'); + assert.equal(oneDay.stockDays, 1); + assert.equal(oneDay.replenishQty, 8); + + // 可用天数正好 3 天(库存=12,件数=4)→ 阈值取「< 3」,故不预警 + const atThreshold = metrics({ + avgSale: 0, realtimeStock: 12, monitorStock: 12, pendingOrderQty: 4, + }); + assert.equal(atThreshold.warningLevel, '正常'); + assert.equal(atThreshold.hasWarning, false); + assert.equal(atThreshold.stockDays, 3); + assert.equal(atThreshold.replenishQty, 0); + + // 可用天数超 3 天(库存=13,件数=4 → 3.25 天)→ 库存充足,不预警,但仍标记 orderDriven + const enough = metrics({ + avgSale: 0, realtimeStock: 13, monitorStock: 13, pendingOrderQty: 4, + }); + assert.equal(enough.warningLevel, '正常'); + assert.equal(enough.hasWarning, false); + assert.equal(enough.orderDriven, true); + assert.equal(enough.stockDays, 3.25); +}); + +test('replenish: order-driven warning does not override the pending work order exemption', () => { + const scheduled = metrics({ + avgSale: 0, + realtimeStock: 0, + pendingOrderQty: 5, + pendingOrderCount: 1, + pendingWorkOrderQty: 6, + pendingWorkOrderCount: 1, + }); + assert.equal(scheduled.warningLevel, '已排产'); + assert.equal(scheduled.hasWarning, false); + assert.equal(scheduled.replenishQty, 0); + // 待发货量仍要透出,页面上应能看到 + assert.equal(scheduled.pendingOrderQty, 5); + assert.equal(scheduled.orderDriven, true); +}); + +test('replenish: pending order fields default to empty without any unshipped order', () => { + const none = metrics({ avgSale: 1, realtimeStock: 0 }); + assert.equal(none.pendingOrderQty, 0); + assert.equal(none.pendingOrderCount, 0); + assert.equal(none.pendingOrderEarliest, null); + assert.equal(none.orderDriven, false); + + // 无日销且无订单 → 仍是「无销量」,不参与预警 + const noSale = metrics({ avgSale: 0, realtimeStock: 0, pendingOrderQty: 0 }); + assert.equal(noSale.warningLevel, '无销量'); + assert.equal(noSale.orderDriven, false); + + // 有日销的商品:待发货订单先从库存里扣掉(结余库存口径,见服务文件头 ⑫) + const withSale = metrics({ avgSale: 2, realtimeStock: 0, pendingOrderQty: 3 }); + assert.equal(withSale.warningLevel, '缺货'); + assert.equal(withSale.orderDriven, false); + assert.equal(withSale.pendingOrderQty, 3); + assert.equal(withSale.availableStock, 0); // max(0, 0 − 3) + assert.equal(withSale.stockDays, 0); + assert.equal(withSale.replenishQty, 9); // ceil(3 × 2 + 3 − 0):先补订单缺口 3,再补 3 天缓冲 6 + + // 结余库存 > 0 时按结余算可用天数:库存 10 − 待发 3 = 7,7 ÷ 2 = 3.5 天 → 正常 + const buffered = metrics({ avgSale: 2, realtimeStock: 10, pendingOrderQty: 3 }); + assert.equal(buffered.availableStock, 7); + assert.equal(buffered.stockDays, 3.5); + assert.equal(buffered.warningLevel, '正常'); + assert.equal(buffered.replenishQty, 0); // ceil(6 + 3 − 10) < 0 → 0 + + // 结余库存不足以支撑 3 天日销 → 预警(MKHDNZ0409260601376 同型:库存3 待发3 日销0.27) + const tight = metrics({ avgSale: 1, realtimeStock: 5, pendingOrderQty: 3 }); + assert.equal(tight.availableStock, 2); + assert.equal(tight.stockDays, 2); + assert.equal(tight.warningLevel, '预警'); + assert.equal(tight.replenishQty, 1); // ceil(3 + 3 − 5) +}); + +// 回归:MKHDNZ0409260601376(2026-09-18 业务反馈) +// 现有库存 3、待发货订单 3 → 结余库存 0、可用天数 0,应判「缺货」。 +// 旧代码只在 dailySale <= 0 时才让待发货订单参与判定,本例日销 0.27 > 0 导致漏报。 +test('replenish: pending orders are deducted even when daily sale is positive (⑫)', () => { + const row = metrics({ avgSale: 0.27, realtimeStock: 3, pendingOrderQty: 3, pendingOrderCount: 3 }); + assert.equal(row.orderDriven, false); + assert.equal(row.availableStock, 0); + assert.equal(row.stockDays, 0); + assert.equal(row.warningLevel, '缺货'); + assert.equal(row.hasWarning, true); + assert.equal(row.warningOrder, 1); + assert.equal(row.replenishQty, 1); // ceil(3 × 0.27 + 3 − 3) = ceil(0.81) +}); + +// ── 导出(见服务文件头 ⑨)──────────────────────────────────────── +test('export: product rows follow the page table columns', () => { + const rows = _private.buildProductExportRows([ + { + row: { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转|25刨花板斜切', + warningLevel: '缺货', + dailySale: 2.97, + stock: 0, + pendingWorkOrderQty: 0, + stockDays: 0, + replenishQty: 9, + }, + components: [{ barcode: 'A1', name: '铁搁板销', qty: 4 }, { barcode: 'A2', name: '自攻螺丝', qty: 12 }], + }, + { + row: { + skuId: 'MKHDNZ0409260600117', + productName: '无销量商品', + warningLevel: '无销量', + dailySale: 0, + stock: 10, + pendingWorkOrderQty: null, + stockDays: null, + replenishQty: 0, + }, + components: [], + }, + ]); + + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转|25刨花板斜切', + warningLevel: '缺货', + dailySale: 2.97, + stock: 0, + pendingWorkOrderQty: 0, + pendingOrderQty: 0, + stockDays: 0, + replenishQty: 9, + componentShortage: '', + assemblableQty: null, + bomConfigured: '已配置', + accessoryCount: 2, + accessoryQtySum: 16, + }); + // 无销量 → stockDays 为 null(前端导出时渲染成「无销量」),在途量缺省归 0 + assert.equal(rows[1].stockDays, null); + assert.equal(rows[1].pendingWorkOrderQty, 0); + assert.equal(rows[1].pendingOrderQty, 0); + assert.equal(rows[1].bomConfigured, '未配置'); + assert.equal(rows[1].accessoryQtySum, 0); +}); + +test('export: component rows are flattened one row per component', () => { + const rows = _private.buildComponentExportRows([ + { + row: { skuId: 'MKHDNZ0409260700428', productName: 'Z字翻转' }, + components: [ + { barcode: 'DNZ039864N1410V01', name: '台面', qty: 1, location: 'A-01 成品区' }, + { barcode: 'GBSFY-01', name: '说明书', qty: 2, location: null }, + ], + }, + { row: { skuId: 'MKHDNZ0409260600117', productName: '无配件商品' }, components: [] }, + ]); + + // 无配件的商品不产生任何导出行(不像页面那样占位) + assert.equal(rows.length, 2); + assert.deepEqual(rows[0], { + skuId: 'MKHDNZ0409260700428', + productName: 'Z字翻转', + index: 1, + componentBarcode: 'DNZ039864N1410V01', + componentName: '台面', + qty: 1, + location: 'A-01 成品区', + }); + // 同一商品的编码逐行重复(不做纵向合并,保证 Excel 可筛选) + assert.equal(rows[1].skuId, 'MKHDNZ0409260700428'); + assert.equal(rows[1].index, 2); + assert.equal(rows[1].location, null); + assert.equal(rows[1].qty, 2); +}); + +test('export: component rows fall back to code when barcode is missing', () => { + const rows = _private.buildComponentExportRows([ + { row: { skuId: 'MKH-X' }, components: [{ code: 'ONLY-CODE', name: '仅编码配件', qty: 3 }] }, + ]); + assert.equal(rows[0].componentBarcode, 'ONLY-CODE'); +}); + +test('export: mode constants cover both shapes', () => { + assert.deepEqual(_private.EXPORT_MODES, { PRODUCTS: 'products', COMPONENTS: 'components' }); +}); + +test('pack inventory location prefers the warehouse name and keeps the box fallback', () => { + const locations = _private.buildPackLocationMap([ + { + skuId: 'PART-1-1', + warehouseId: 15718196, + warehouseName: '简尚-崔桥工厂半成品包装项目仓', + binGroup: null, + bin: '', + packId: 'BOX-1', + qty: 3, + }, + { + skuId: 'PART-1-2', + warehouseId: 15718196, + warehouseName: '简尚-崔桥工厂半成品包装项目仓', + binGroup: '成品区', + bin: 'A-01', + packId: 'BOX-2', + qty: 1, + }, + ], ['PART-1']); + + assert.equal( + locations.get('PART-1'), + '简尚-崔桥工厂半成品包装项目仓 · 箱号 BOX-1、简尚-崔桥工厂半成品包装项目仓 · 成品区 / A-01' + ); +}); + +test('pack inventory location falls back to the warehouse id when the name is missing', () => { + const locations = _private.buildPackLocationMap([ + { skuId: 'PART-1-1', warehouseId: 4, warehouseName: null, bin: '', packId: 'BOX-9', qty: 1 }, + ], ['PART-1']); + + assert.equal(locations.get('PART-1'), '仓库 4 · 箱号 BOX-9'); +}); + +test('pack inventory location chunk size stays under the MSSQL parameter limit', () => { + // 每个编码占 2 个参数(精确匹配 @lcN + 带后缀前缀匹配 @lpN),MSSQL 单语句上限 2100 + assert.equal(_private.COMPONENT_LOCATION_CHUNK_SIZE * 2 <= 2000, true); +}); + +// ── 配件不足 / 可组装数(见服务文件头 ⑪)────────────────────────── +test('component readiness: assemblable qty is limited by the tightest component', () => { + const components = [ + { barcode: 'A', name: '配件A', qty: 1 }, + { barcode: 'B', name: '配件B', qty: 2 }, + ]; + // A: floor(10 / 1) = 10,B: floor(7 / 2) = 3 → 木桶短板是 B + const ready = _private.buildComponentReadiness(components, new Map([['A', 10], ['B', 7]]), new Map()); + + assert.equal(ready.assemblableQty, 3); + assert.equal(ready.shortage, false); + assert.equal(ready.bottlenecks[0].code, 'B'); + assert.equal(ready.bottlenecks[0].can, 3); + // Tooltip 要能显示「单台用量」,不能默认当 1 + assert.equal(ready.bottlenecks[0].perQty, 2); +}); + +test('component readiness: zero assemblable qty marks 配件不足', () => { + const components = [ + { barcode: 'A', name: '配件A', qty: 1 }, + { barcode: 'B', name: '配件B', qty: 1 }, + ]; + // B 在该仓没有库存记录 → 视为 0(loadComponentStock 会缓存 0),一台都凑不出来 + const ready = _private.buildComponentReadiness(components, new Map([['A', 500]]), new Map()); + + assert.equal(ready.assemblableQty, 0); + assert.equal(ready.shortage, true); + assert.equal(ready.bottlenecks[0].code, 'B'); + assert.equal(ready.bottlenecks[0].stock, 0); + assert.equal(ready.bottlenecks[0].available, 0); +}); + +test('component readiness: pending work order occupation is deducted from stock', () => { + const components = [{ barcode: 'A', name: '配件A', qty: 2 }]; + // 库存 10、单台用量 2 → 5 台;扣掉加工单占用 6 件 → floor((10 − 6) / 2) = 2 + const ready = _private.buildComponentReadiness( + components, + new Map([['A', 10]]), + new Map([['A', 6]]) + ); + assert.equal(ready.assemblableQty, 2); + assert.equal(ready.bottlenecks[0].occupied, 6); + assert.equal(ready.bottlenecks[0].available, 4); + + // 占用超过库存 → 可支配数归 0,不能算成负数 + const exhausted = _private.buildComponentReadiness( + components, + new Map([['A', 10]]), + new Map([['A', 20]]) + ); + assert.equal(exhausted.assemblableQty, 0); + assert.equal(exhausted.shortage, true); + assert.equal(exhausted.bottlenecks[0].available, 0); +}); + +test('component readiness: missing BOM detail means unknown, not 配件不足', () => { + // 没有配件明细 → 判不了(组件缺失 ≠ 配件不足),返回 null 让调用方跳过标记 + assert.equal(_private.buildComponentReadiness([], new Map(), new Map()), null); + assert.equal(_private.buildComponentReadiness(null, new Map(), new Map()), null); + assert.equal(_private.buildComponentReadiness(undefined, new Map(), new Map()), null); +}); + +test('component occupation: pending work orders expand into component quantities', () => { + const bomBySku = new Map([['MKHA1', [ + { barcode: 'A', qty: 2 }, + { barcode: 'B', qty: 1 }, + ]]]); + const occupation = _private.buildComponentOccupation([ + { skuId: 'MKHA1', pendingWorkOrderQty: 5 }, + // 加工单商品没有 BOM → 不产生占用(不抛错、不写脏数据) + { skuId: 'MKH-NO-BOM', pendingWorkOrderQty: 9 }, + // 在途 0 或非数字 → 忽略 + { skuId: 'MKHA1', pendingWorkOrderQty: 0 }, + { skuId: 'MKHA1', pendingWorkOrderQty: null }, + ], bomBySku); + + assert.equal(occupation.get('A'), 10); + assert.equal(occupation.get('B'), 5); + assert.equal(occupation.size, 2); +}); + +test('component shortage keeps the business-confirmed source and scope', () => { + // 配件库存只认半成品包装仓、只判现有库存(不含采购在途) + assert.equal(_private.COMPONENT_STOCK_WMS_CO_ID, 15718196); + assert.equal(_private.COMPONENT_STOCK_WAREHOUSE, '简尚-崔桥工厂半成品包装项目仓'); + // 每个编码占 1 个参数,MSSQL 单语句上限 2100 + assert.equal(_private.COMPONENT_STOCK_CHUNK_SIZE <= 2000, true); + assert.equal(typeof _private.loadComponentStock, 'function'); + assert.equal(typeof _private.loadMkhComponentReadiness, 'function'); +}); + +test('export: 配件不足 rows are flagged and carry no replenish target', () => { + const rows = _private.buildProductExportRows([ + { + row: { + skuId: 'MKHDNZ0409260600843', + productName: 'K字单板基础', + warningLevel: '缺货', + dailySale: 0, + stock: 0, + pendingOrderQty: 1, + replenishQty: 1, + componentShortage: true, + assemblableQty: 0, + }, + components: [{ barcode: 'DNZ040958N1440V01', name: '白架通用前拉杆', qty: 1 }], + }, + ]); + + assert.equal(rows[0].componentShortage, '配件不足'); + // 可组装数一并导出,便于业务核对短板 + assert.equal(rows[0].assemblableQty, 0); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 缓存代际(cacheEpoch):修复「点同步最新数据,反而显示旧数据」 +// +// 症状:用户点「同步最新数据」后,页面 2~10 分钟内持续显示旧值,像在用缓存。 +// 成因:缓存是「先挂 pending → await 结果 → 无条件写回」。若某个查询在途时用户点了 +// 同步(清空缓存),那个发起于清缓存之前的查询读到的是旧快照,返回时会把旧值 +// 写回缓存,覆盖掉清缓存后新请求填入的新值 —— 谁后完成谁生效。 +// +// 下面第一个用例复现旧行为(修复前失败),第二个用例锁定正确语义。 +// ───────────────────────────────────────────────────────────────────────────── + +test('cache epoch: 清缓存后在途的旧查询结果不得写回缓存(否则同步按钮点完仍是旧数据)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const CODE = 'DNZTESTCACHE01'; + const staleQty = 9; // 清缓存前那一刻库里/旧快照的值 + const freshQty = 1; // 清缓存后重新查到的值 + + let phase = 'old'; + let stockQueries = 0; + let releaseStale; + const staleGate = new Promise((resolve) => { releaseStale = resolve; }); + let markStaleStarted; + const staleStarted = new Promise((resolve) => { markStaleStarted = resolve; }); + + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + stockQueries += 1; + if (phase === 'old') { + markStaleStarted(); + await staleGate; // 卡住旧查询:模拟「发起于此、期间用户点了同步」的慢查询 + return [{ code: CODE, qty: staleQty }]; + } + return [{ code: CODE, qty: freshQty }]; + }; + + try { + // 1) 旧查询发起并卡住 + const inFlight = svc._private.loadComponentStock([CODE]); + await staleStarted; + + // 2) 用户点「同步最新数据」→ 清缓存并推进代际 + const cleared = svc.clearMkhBomCache(); + assert.ok(cleared.cacheEpoch >= 1, '清缓存必须推进代际号'); + phase = 'new'; + + // 3) 清缓存后的新查询先返回,拿到新值并写缓存 + const fresh = await svc._private.loadComponentStock([CODE]); + assert.equal(fresh.stockMap.get(CODE), freshQty); + assert.equal(stockQueries, 2, '清缓存后必须真正重查数据源'); + + // 4) 旧查询这才返回(它读到的仍是旧快照) + releaseStale(); + const staleResult = await inFlight; + assert.equal(staleResult.stockMap.get(CODE), staleQty, + '过期结果仍应原样返回给发起它的那个请求,不能改变它自己的语义'); + + // 5) 🔥 关键断言:缓存里必须还是新值 + const after = await svc._private.loadComponentStock([CODE]); + assert.equal(after.stockMap.get(CODE), freshQty, + '清缓存后返回的过期结果写回了缓存 → 用户会看到「点了同步还是旧数据」'); + assert.equal(stockQueries, 2, + '第 5 步应直接命中缓存,不应再打数据库(说明缓存确实被写入了新值)'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('cache epoch: 未经清缓存时缓存照常命中(回归保护)', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + + const CODE = 'DNZTESTCACHE02'; + let stockQueries = 0; + cloudDatabase.query = async (sql) => { + if (!sql.includes('jst_inventoryInfo')) return []; + stockQueries += 1; + return [{ code: CODE, qty: 3 }]; + }; + + try { + svc.clearMkhBomCache(); + const first = await svc._private.loadComponentStock([CODE]); + const second = await svc._private.loadComponentStock([CODE]); + assert.equal(first.stockMap.get(CODE), 3); + assert.equal(second.stockMap.get(CODE), 3); + assert.equal(stockQueries, 1, '同一次缓存代际内,第二次必须命中缓存不再查库'); + } finally { + cloudDatabase.query = originalQuery; + svc.clearMkhBomCache(); + } +}); + +test('operational refresh: 并发刷新只查一次运营数据,短时重试复用新结果', async () => { + const cloudDatabase = require('../src/config/cloudDatabase'); + const svc = require('../src/services/mkhBomService'); + const originalQuery = cloudDatabase.query; + const originalGetPool = cloudDatabase.getPool; + + let queryCount = 0; + let releaseMonitor; + let markMonitorStarted; + const monitorGate = new Promise((resolve) => { releaseMonitor = resolve; }); + const monitorStarted = new Promise((resolve) => { markMonitorStarted = resolve; }); + + cloudDatabase.query = async (sql) => { + queryCount += 1; + if (sql.includes('pm_stockMonitor')) { + markMonitorStarted(); + await monitorGate; + } + return []; + }; + cloudDatabase.getPool = async () => ({ + request: () => ({ + input() { return this; }, + async query() { queryCount += 1; return { recordset: [] }; }, + }), + }); + + try { + svc.clearMkhBomCache(); + const first = svc.refreshMkhOperationalData(); + await monitorStarted; + const second = svc.refreshMkhOperationalData(); + releaseMonitor(); + const [firstResult, secondResult] = await Promise.all([first, second]); + assert.equal(firstResult.refreshed, true); + assert.equal(secondResult.joined, true); + assert.equal(secondResult.refreshedAt, firstResult.refreshedAt); + + const afterConcurrent = queryCount; + const reused = await svc.refreshMkhOperationalData(); + assert.equal(reused.reused, true); + assert.equal(queryCount, afterConcurrent, + '15 秒保护窗内不应重复访问业务云库'); + } finally { + cloudDatabase.query = originalQuery; + cloudDatabase.getPool = originalGetPool; + svc.clearMkhBomCache(); + } +}); diff --git a/backend/test/mkhReplenishAlertJob.test.js b/backend/test/mkhReplenishAlertJob.test.js new file mode 100644 index 0000000..7e90c92 --- /dev/null +++ b/backend/test/mkhReplenishAlertJob.test.js @@ -0,0 +1,194 @@ +'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('未做')); +}); diff --git a/backend/test/retiredRoutes.test.js b/backend/test/retiredRoutes.test.js index 0a905c3..f3fad52 100644 --- a/backend/test/retiredRoutes.test.js +++ b/backend/test/retiredRoutes.test.js @@ -6,7 +6,7 @@ const express = require('express'); test('retired market endpoints return 404 while retained incentive and calculator endpoints work', async () => { const originalLoad = Module._load; Module._load = function (id, parent, ...rest) { - if (parent?.filename.endsWith('/api/planningUpload.js')) { + if (/[\\/]api[\\/]planningUpload\.js$/.test(parent?.filename || '')) { if (id.endsWith('/pageAccess')) return { getResolvedUserWithPageAccess: async () => ({ role: 'admin' }) }; if (id.endsWith('/opsBIncentiveBoardService')) return { getOpsBIncentiveBoard: async () => ({ rows: [] }), getOpsBIncentiveBatches: async () => [], getOpsBIncentiveDaily: async () => [], diff --git a/deploy/production/build-frontend.sh b/deploy/production/build-frontend.sh new file mode 100755 index 0000000..5a7408c --- /dev/null +++ b/deploy/production/build-frontend.sh @@ -0,0 +1,43 @@ +#!/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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0c1d584..3733cfc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -31,7 +31,7 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "vite": "npm:rolldown-vite@7.2.5" + "vite": "7.2.7" } }, "node_modules/@ant-design/colors": { @@ -447,40 +447,6 @@ "react": ">=16.10.2" } }, - "node_modules/@emnapi/core": { - "version": "1.8.1", - "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.8.1.tgz", - "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.1.0", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.8.1", - "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.8.1.tgz", - "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.8.0.tgz", @@ -493,6 +459,448 @@ "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -752,41 +1160,21 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", "optional": true, - "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxc-project/runtime": { - "version": "0.97.0", - "resolved": "https://registry.npmmirror.com/@oxc-project/runtime/-/runtime-0.97.0.tgz", - "integrity": "sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==", - "dev": true, - "license": "MIT", + "os": [ + "linux" + ], "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.97.0", - "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.97.0.tgz", - "integrity": "sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "node": "^22.20 || ^24.12 || >=25" } }, "node_modules/@rc-component/async-validator": { @@ -1004,10 +1392,31 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==", + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", "cpu": [ "arm64" ], @@ -1016,15 +1425,12 @@ "optional": true, "os": [ "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==", + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", "cpu": [ "arm64" ], @@ -1033,15 +1439,12 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.50.tgz", - "integrity": "sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", "cpu": [ "x64" ], @@ -1050,15 +1453,26 @@ "optional": true, "os": [ "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.50.tgz", - "integrity": "sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==", + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", "cpu": [ "x64" ], @@ -1067,15 +1481,12 @@ "optional": true, "os": [ "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.50.tgz", - "integrity": "sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==", + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", "cpu": [ "arm" ], @@ -1084,15 +1495,26 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.50.tgz", - "integrity": "sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==", + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", "cpu": [ "arm64" ], @@ -1101,15 +1523,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.50.tgz", - "integrity": "sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==", + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", "cpu": [ "arm64" ], @@ -1118,15 +1537,110 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.50.tgz", - "integrity": "sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", "cpu": [ "x64" ], @@ -1135,15 +1649,12 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.50.tgz", - "integrity": "sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==", + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", "cpu": [ "x64" ], @@ -1152,15 +1663,26 @@ "optional": true, "os": [ "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.50.tgz", - "integrity": "sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==", + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", "cpu": [ "arm64" ], @@ -1169,32 +1691,12 @@ "optional": true, "os": [ "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.50.tgz", - "integrity": "sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.0.7" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==", + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", "cpu": [ "arm64" ], @@ -1203,15 +1705,12 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-win32-ia32-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==", + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", "cpu": [ "ia32" ], @@ -1220,15 +1719,12 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.50.tgz", - "integrity": "sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", "cpu": [ "x64" ], @@ -1237,17 +1733,21 @@ "optional": true, "os": [ "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } + ] }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.53", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", - "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -1261,17 +1761,6 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1381,9 +1870,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -2059,6 +2548,8 @@ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -2169,6 +2660,48 @@ "benchmarks" ] }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", @@ -2919,6 +3452,8 @@ "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "dev": true, "license": "MPL-2.0", + "optional": true, + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -2956,6 +3491,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2977,6 +3513,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -2998,6 +3535,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3019,6 +3557,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3040,6 +3579,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3061,6 +3601,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3082,6 +3623,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3103,6 +3645,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3124,6 +3667,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3145,6 +3689,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -3166,6 +3711,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -4279,46 +4825,52 @@ "node": ">=4" } }, - "node_modules/rolldown": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-beta.50.tgz", - "integrity": "sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==", + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.97.0", - "@rolldown/pluginutils": "1.0.0-beta.50" + "@types/estree": "1.0.9" }, "bin": { - "rolldown": "bin/cli.mjs" + "rollup": "dist/bin/rollup" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.0.0", + "npm": ">=8.0.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-beta.50", - "@rolldown/binding-darwin-arm64": "1.0.0-beta.50", - "@rolldown/binding-darwin-x64": "1.0.0-beta.50", - "@rolldown/binding-freebsd-x64": "1.0.0-beta.50", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.50", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.50", - "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.50", - "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.50", - "@rolldown/binding-linux-x64-musl": "1.0.0-beta.50", - "@rolldown/binding-openharmony-arm64": "1.0.0-beta.50", - "@rolldown/binding-wasm32-wasi": "1.0.0-beta.50", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.50", - "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.50", - "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.50" + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" } }, - "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.50", - "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz", - "integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==", - "dev": true, - "license": "MIT" - }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -4609,19 +5161,17 @@ } }, "node_modules/vite": { - "name": "rolldown-vite", - "version": "7.2.5", - "resolved": "https://registry.npmmirror.com/rolldown-vite/-/rolldown-vite-7.2.5.tgz", - "integrity": "sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==", + "version": "7.2.7", + "resolved": "https://registry.npmmirror.com/vite/-/vite-7.2.7.tgz", + "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.97.0", + "esbuild": "^0.25.0", "fdir": "^6.5.0", - "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rolldown": "1.0.0-beta.50", + "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "bin": { @@ -4638,9 +5188,9 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "esbuild": "^0.25.0", "jiti": ">=1.21.0", "less": "^4.0.0", + "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -4653,15 +5203,15 @@ "@types/node": { "optional": true }, - "esbuild": { - "optional": true - }, "jiti": { "optional": true }, "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, diff --git a/frontend/package.json b/frontend/package.json index 519e4ca..6b5c08b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,9 +37,6 @@ "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", - "vite": "npm:rolldown-vite@7.2.5" - }, - "overrides": { - "vite": "npm:rolldown-vite@7.2.5" + "vite": "7.2.7" } } diff --git a/frontend/src/api/dailyInventory.js b/frontend/src/api/dailyInventory.js new file mode 100644 index 0000000..e844361 --- /dev/null +++ b/frontend/src/api/dailyInventory.js @@ -0,0 +1,9 @@ +import request from './request'; + +const LIST_TIMEOUT_MS = 5 * 60 * 1000; + +// 每日进销存:25 仓 × 进销存流水 + 实时期末 + 分级库存量 +export const getDailyInventory = () => request.get( + '/api/daily-inventory', + { timeout: LIST_TIMEOUT_MS } +); diff --git a/frontend/src/api/mkhBom.js b/frontend/src/api/mkhBom.js new file mode 100644 index 0000000..41bc27c --- /dev/null +++ b/frontend/src/api/mkhBom.js @@ -0,0 +1,58 @@ +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 } +); diff --git a/frontend/src/api/modularStockMonitor.js b/frontend/src/api/modularStockMonitor.js new file mode 100644 index 0000000..ebafba9 --- /dev/null +++ b/frontend/src/api/modularStockMonitor.js @@ -0,0 +1,29 @@ +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 } +); diff --git a/frontend/src/config/sidebarNavigation.js b/frontend/src/config/sidebarNavigation.js index 08f1230..6eb282d 100644 --- a/frontend/src/config/sidebarNavigation.js +++ b/frontend/src/config/sidebarNavigation.js @@ -1,189 +1,202 @@ -export const DEFAULT_SIDEBAR_CONFIG = { - collapsedByDefault: false, - darkModeByDefault: false, - entries: [ - { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 }, - { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 }, - { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 }, - { - key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true, - children: [ - { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 }, - { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 }, - ], - }, - { - key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true, - children: [ - { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, - { key: 'modular-stock-monitoring', label: '模块化库存监控', icon: 'table', routeKey: 'modular-stock-monitoring', visible: true, order: 30 }, - ], - }, - { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, - { - key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, - children: [ - { key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true }, - { key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true }, - { key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true }, - ], - }, - ], -}; - -const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG)); - -const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [ - entry.key, - ...flattenEntryKeys(entry.children || []), -]).filter(Boolean); - -const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [ - entry.routeKey, - ...flattenRouteKeys(entry.children || []), -]).filter(Boolean); - -const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries)); -const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries)); - -// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置 -const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板'; -const OPS_B_INCENTIVE_LEGACY_ORDER = 6; -const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']); -// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并 -const extractPromotedTopLevelEntries = (entries = []) => { - const promoted = []; - const nextEntries = entries.map((entry) => { - if (!Array.isArray(entry?.children)) return entry; - const children = []; - entry.children.forEach((child) => { - if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) { - promoted.push(child); - } else { - children.push(child); - } - }); - return children.length === entry.children.length ? entry : { ...entry, children }; - }); - return { nextEntries, promoted }; -}; - -const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { - const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key)); - const configuredKeys = new Set(); - - const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => { - const merged = { ...defaultEntry, ...configuredEntry }; - if (defaultEntry.type) { - merged.type = defaultEntry.type; - } - if (defaultEntry.routeKey) { - merged.routeKey = defaultEntry.routeKey; - } - if (typeof defaultEntry.requiresAdmin !== 'undefined') { - merged.requiresAdmin = defaultEntry.requiresAdmin; - } - if ( - defaultEntry.key === 'ops-b-incentive' - && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL) - ) { - merged.label = defaultEntry.label; - } - if ( - defaultEntry.key === 'ops-b-incentive' - && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER) - ) { - merged.order = defaultEntry.order; - } - const hasDefaultChildren = Array.isArray(defaultEntry.children); - const hasConfiguredChildren = Array.isArray(configuredEntry.children); - - if (hasDefaultChildren || hasConfiguredChildren) { - if (hasDefaultChildren) { - merged.type = defaultEntry.type; - } else if (defaultEntry.routeKey) { - merged.type = defaultEntry.type || 'item'; - delete merged.children; - delete merged.defaultExpanded; - return merged; - } - merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []); - } - - return merged; - }; - - const configuredMerged = configuredEntries.map((entry) => { - if (entry?.key) { - configuredKeys.add(entry.key); - } - - const defaultEntry = defaultByKey.get(entry?.key); - return defaultEntry ? mergeEntry(defaultEntry, entry) : entry; - }); - - const missingDefaults = defaultEntries - .filter((entry) => entry?.key && !configuredKeys.has(entry.key)) - .map((entry) => mergeEntry(entry, {})); - - return [...configuredMerged, ...missingDefaults]; -}; - -const mergeSidebarConfigWithDefaults = (config) => { - const defaults = cloneDefaultConfig(); - const nestedEntries = (config?.entries || []); - const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries); - const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean)); - const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted; - - return { - ...defaults, - ...config, - entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]), - }; -}; - -const pruneSidebarEntry = (entry) => { - if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) { - return null; - } - - const children = Array.isArray(entry.children) - ? entry.children.map(pruneSidebarEntry).filter(Boolean) - : undefined; - const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false; - const hasAllowedChildren = Array.isArray(children) && children.length > 0; - const keepEntry = hasAllowedRoute || hasAllowedChildren; - - if (!keepEntry) { - return null; - } - - return { - ...entry, - ...(Array.isArray(entry.children) ? { children } : {}), - }; -}; - -const removeRetiredEntries = (config) => ({ - ...config, - entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean), -}); - -export const parseSidebarConfig = (value) => { - if (!value) return cloneDefaultConfig(); - - try { - const parsed = typeof value === 'string' ? JSON.parse(value) : value; - if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig(); - return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed)); - } catch { - return cloneDefaultConfig(); - } -}; - -export const sortSidebarEntries = (entries = []) => ( - [...entries] - .filter((entry) => entry?.visible !== false) - .sort((a, b) => Number(a.order || 0) - Number(b.order || 0)) -); +export const DEFAULT_SIDEBAR_CONFIG = { + collapsedByDefault: false, + darkModeByDefault: false, + entries: [ + { key: 'product-monitoring', type: 'item', label: '商品监控', icon: 'appstore', routeKey: 'product-monitoring', visible: true, order: 12 }, + { key: 'ops-b-incentive', type: 'item', label: '激励看板', icon: 'line-chart', routeKey: 'ops-b-incentive', visible: true, order: 15 }, + { key: 'dashboard-preview', type: 'item', label: '仪表盘', icon: 'dashboard', routeKey: 'dashboard-preview', visible: true, order: 50 }, + { + key: 'marketing-management', type: 'group', label: '营销管理', icon: 'shopping', visible: true, order: 60, defaultExpanded: true, + children: [ + { key: 'marketing-events', label: '营销活动', icon: 'projection', routeKey: 'marketing-events', visible: true, order: 30 }, + { key: 'promotion-module', label: '推广模块', icon: 'line-chart', routeKey: 'promotion-module', visible: true, order: 50 }, + ], + }, + { + key: 'product-management', type: 'group', label: '商品管理', icon: 'appstore', visible: true, order: 70, defaultExpanded: true, + children: [ + { key: 'product-analysis', label: '商品分析', icon: 'table', routeKey: 'product-analysis', visible: true, order: 20 }, + { key: 'modular-stock-monitoring', label: '模块化库存监控', icon: 'table', routeKey: 'modular-stock-monitoring', visible: true, order: 30 }, + ], + }, + { key: 'work-orders', type: 'item', label: '工单待办', icon: 'message', routeKey: 'work-orders', visible: true, order: 100 }, + { + key: 'modular-stock-monitor', type: 'group', label: '模块化库存监控', icon: 'appstore', visible: true, order: 105, defaultExpanded: true, + children: [ + { key: 'modular-stock-monitor-accessory', label: '配件库存监控', icon: 'table', routeKey: 'modular-stock-monitor-accessory', visible: true, order: 10 }, + { key: 'mkh-bom-data', label: 'MKH-BOM资料', icon: 'table', routeKey: 'mkh-bom-data', visible: true, order: 20 }, + ], + }, + { + key: 'large-stock-monitor', type: 'group', label: '大货库存监控', icon: 'appstore', visible: true, order: 200, defaultExpanded: true, + children: [ + { key: 'daily-inventory', label: '每日进销存', icon: 'table', routeKey: 'daily-inventory', visible: true, order: 10 }, + ], + }, + { + key: 'manage', type: 'group', label: '系统管理', icon: 'setting', visible: true, order: 900, defaultExpanded: false, requiresAdmin: true, + children: [ + { key: 'settings', label: '系统设置', icon: 'setting', routeKey: 'settings', visible: true, order: 10, requiresAdmin: true }, + { key: 'online-desktop-users', label: '在线客户端', icon: 'team', routeKey: 'online-desktop-users', visible: true, order: 30, requiresAdmin: true }, + { key: 'desktop-execution-tasks', label: '执行任务', icon: 'projection', routeKey: 'desktop-execution-tasks', visible: true, order: 40, requiresAdmin: true }, + ], + }, + ], +}; + +const cloneDefaultConfig = () => JSON.parse(JSON.stringify(DEFAULT_SIDEBAR_CONFIG)); + +const flattenEntryKeys = (entries = []) => entries.flatMap((entry) => [ + entry.key, + ...flattenEntryKeys(entry.children || []), +]).filter(Boolean); + +const flattenRouteKeys = (entries = []) => entries.flatMap((entry) => [ + entry.routeKey, + ...flattenRouteKeys(entry.children || []), +]).filter(Boolean); + +const ALLOWED_ENTRY_KEYS = new Set(flattenEntryKeys(DEFAULT_SIDEBAR_CONFIG.entries)); +const ALLOWED_ROUTE_KEYS = new Set(flattenRouteKeys(DEFAULT_SIDEBAR_CONFIG.entries)); + +// 激励看板原为「运营工具 → 运营中心B激励看板」,现升级为顶层入口;用于迁移存量侧边栏配置 +const OPS_B_INCENTIVE_LEGACY_LABEL = '运营中心B激励看板'; +const OPS_B_INCENTIVE_LEGACY_ORDER = 6; +const PROMOTED_TOP_LEVEL_ENTRY_KEYS = new Set(['ops-b-incentive']); +// 把存量配置里仍嵌在分组下的已升级条目摘出来,提升为顶层配置参与合并 +const extractPromotedTopLevelEntries = (entries = []) => { + const promoted = []; + const nextEntries = entries.map((entry) => { + if (!Array.isArray(entry?.children)) return entry; + const children = []; + entry.children.forEach((child) => { + if (child?.key && PROMOTED_TOP_LEVEL_ENTRY_KEYS.has(child.key)) { + promoted.push(child); + } else { + children.push(child); + } + }); + return children.length === entry.children.length ? entry : { ...entry, children }; + }); + return { nextEntries, promoted }; +}; + +const mergeSidebarEntries = (defaultEntries = [], configuredEntries = []) => { + const defaultByKey = new Map(defaultEntries.map((entry) => [entry?.key, entry]).filter(([key]) => key)); + const configuredKeys = new Set(); + + const mergeEntry = (defaultEntry = {}, configuredEntry = {}) => { + const merged = { ...defaultEntry, ...configuredEntry }; + if (defaultEntry.type) { + merged.type = defaultEntry.type; + } + if (defaultEntry.routeKey) { + merged.routeKey = defaultEntry.routeKey; + } + if (typeof defaultEntry.requiresAdmin !== 'undefined') { + merged.requiresAdmin = defaultEntry.requiresAdmin; + } + if ( + defaultEntry.key === 'ops-b-incentive' + && (!configuredEntry.label || configuredEntry.label === OPS_B_INCENTIVE_LEGACY_LABEL) + ) { + merged.label = defaultEntry.label; + } + if ( + defaultEntry.key === 'ops-b-incentive' + && (configuredEntry.order == null || configuredEntry.order === OPS_B_INCENTIVE_LEGACY_ORDER) + ) { + merged.order = defaultEntry.order; + } + const hasDefaultChildren = Array.isArray(defaultEntry.children); + const hasConfiguredChildren = Array.isArray(configuredEntry.children); + + if (hasDefaultChildren || hasConfiguredChildren) { + if (hasDefaultChildren) { + merged.type = defaultEntry.type; + } else if (defaultEntry.routeKey) { + merged.type = defaultEntry.type || 'item'; + delete merged.children; + delete merged.defaultExpanded; + return merged; + } + merged.children = mergeSidebarEntries(defaultEntry.children || [], configuredEntry.children || []); + } + + return merged; + }; + + const configuredMerged = configuredEntries.map((entry) => { + if (entry?.key) { + configuredKeys.add(entry.key); + } + + const defaultEntry = defaultByKey.get(entry?.key); + return defaultEntry ? mergeEntry(defaultEntry, entry) : entry; + }); + + const missingDefaults = defaultEntries + .filter((entry) => entry?.key && !configuredKeys.has(entry.key)) + .map((entry) => mergeEntry(entry, {})); + + return [...configuredMerged, ...missingDefaults]; +}; + +const mergeSidebarConfigWithDefaults = (config) => { + const defaults = cloneDefaultConfig(); + const nestedEntries = (config?.entries || []); + const { nextEntries, promoted } = extractPromotedTopLevelEntries(nestedEntries); + const topLevelKeys = new Set(nextEntries.map((entry) => entry?.key).filter(Boolean)); + const promotedNotDuplicated = promoted.some((entry) => topLevelKeys.has(entry.key)) ? [] : promoted; + + return { + ...defaults, + ...config, + entries: mergeSidebarEntries(defaults.entries, [...nextEntries, ...promotedNotDuplicated]), + }; +}; + +const pruneSidebarEntry = (entry) => { + if (!entry || !ALLOWED_ENTRY_KEYS.has(entry.key)) { + return null; + } + + const children = Array.isArray(entry.children) + ? entry.children.map(pruneSidebarEntry).filter(Boolean) + : undefined; + const hasAllowedRoute = entry.routeKey ? ALLOWED_ROUTE_KEYS.has(entry.routeKey) : false; + const hasAllowedChildren = Array.isArray(children) && children.length > 0; + const keepEntry = hasAllowedRoute || hasAllowedChildren; + + if (!keepEntry) { + return null; + } + + return { + ...entry, + ...(Array.isArray(entry.children) ? { children } : {}), + }; +}; + +const removeRetiredEntries = (config) => ({ + ...config, + entries: (config.entries || []).map(pruneSidebarEntry).filter(Boolean), +}); + +export const parseSidebarConfig = (value) => { + if (!value) return cloneDefaultConfig(); + + try { + const parsed = typeof value === 'string' ? JSON.parse(value) : value; + if (!parsed || !Array.isArray(parsed.entries)) return cloneDefaultConfig(); + return removeRetiredEntries(mergeSidebarConfigWithDefaults(parsed)); + } catch { + return cloneDefaultConfig(); + } +}; + +export const sortSidebarEntries = (entries = []) => ( + [...entries] + .filter((entry) => entry?.visible !== false) + .sort((a, b) => Number(a.order || 0) - Number(b.order || 0)) +); diff --git a/frontend/src/constants/dailyInventory.js b/frontend/src/constants/dailyInventory.js new file mode 100644 index 0000000..c66257f --- /dev/null +++ b/frontend/src/constants/dailyInventory.js @@ -0,0 +1,61 @@ +/** + * 每日进销存 —— 列定义 + * + * 表头取自桌面《库存监控.xlsx》,顺序与表格一致。 + * source 标记该列数据来源,便于在页面上如实标注口径: + * - baseline:22 号基准值(业务库无对应单据表,无法还原) + * - runtime:运行时从业务库实时取数 + */ + +export const COLUMN_SOURCE = { + baseline: 'baseline', + runtime: 'runtime', +}; + +/** 表格存在两个名为「期初」的列,第二个加后缀区分 */ +export const DAILY_INVENTORY_COLUMNS = [ + { key: 'warehouse', label: '仓库', source: 'baseline', tip: '取自主数据表 rpt.jst_warehouseInfo 的仓库名,表格显示为「XX主仓」' }, + { key: 'openingQty', label: '期初数量', source: 'baseline', tip: '22 号进销存基准值。业务库无按天的历史库存结存表,无法还原,取自 9-22 导出数据' }, + { key: 'processIn', label: '加工进仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' }, + { key: 'purchaseReturn', label: '采购退货', source: 'baseline', tip: '22 号基准值(业务库 jst_purchaseout 虽有单据,但数值与该进销存口径不一致)' }, + { key: 'purchaseIn', label: '采购进仓', source: 'baseline', tip: '22 号基准值(同上,业务库数值与进销存口径不一致)' }, + { key: 'otherIn', label: '其它进仓', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' }, + { key: 'otherReturn', label: '其它退货', source: 'baseline', tip: '22 号基准值。业务库无对应单据表' }, + { key: 'salesOut', label: '销售出仓', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库 jst_salesOutboundInfo 数值与该进销存口径不一致' }, + { key: 'stocktaking', label: '盘点', source: 'baseline', tip: '22 号基准值。业务库无盘点单据表' }, + { key: 'processOut', label: '加工出仓', source: 'baseline', tip: '22 号基准值。业务库无加工单据表' }, + { key: 'transferOut', label: '调拨出', source: 'baseline', tip: '22 号基准值(负数为调出)。业务库无调拨单据表' }, + { key: 'salesReturn', label: '销售退货', source: 'baseline', tip: '22 号基准值(业务库 jst_returnAndRefundInfo 数值与该进销存口径不一致)' }, + { key: 'transferIn', label: '调拨入', source: 'baseline', tip: '22 号基准值。业务库无调拨单据表' }, + { key: 'openingQtyExtra', label: '期初(2)', source: 'baseline', tip: '表格中的第二个「期初」列(9-22 导出值)' }, + { key: 'otherOut', label: '其它出库', source: 'baseline', tip: '22 号基准值(负数为出库)。业务库无对应单据表' }, + { key: 'closingQty', label: '期末数量', source: 'runtime', tip: '聚水潭实时库存 rpt.jst_inventoryInfo,按仓库 wms_co_id 单仓统计(与表格 22 号期末一致,24/25 仓精确匹配)' }, + { key: 'turnoverDays', label: '库存周转天数', source: 'runtime', tip: '月度发货总件数 / 月度日均库存件数 × 100%(全员均值),数据来自 pm_stockMonitor' }, + { key: 'warnLevel', label: '滞销预警级库存量', source: 'runtime', tip: '库龄 < 3 个月 且 30% ≤ 周转率 ≤ 199% 的 SKU 库存合计' }, + { key: 'slowLevel', label: '商品慢流级库存量', source: 'runtime', tip: '库龄 < 3 个月且周转率 ≥ 200%,或库龄 ≥ 3 个月且周转率 30%~99% 的 SKU 库存合计' }, + { key: 'staleLevel', label: '商品呆滞级库存量', source: 'runtime', tip: '库龄 3~12 个月且周转率 ≥ 100%,或库龄 > 12 个月且周转率 100%~199% 的 SKU 库存合计' }, + { key: 'zombieLevel', label: '商品僵尸级库存量', source: 'runtime', tip: '库龄 > 12 个月 且 周转率 ≥ 200% 的 SKU 库存合计' }, +]; + +/** 口径说明弹框文案 */ +export const FORMULA_DESCRIPTION = [ + '数据来源说明:', + '· 期初数量 / 加工进仓 / 采购退货 / 采购进仓 / 其它进仓 / 其它退货 / 销售出仓 / 盘点 / 加工出仓 / 调拨出 / 销售退货 / 调拨入 / 其它出库:', + ' 取自 2026-09-22 聚水潭进销存导出值。业务库 JSReportReplica 无历史库存结存表,', + ' 也缺少调拨 / 盘点 / 加工 / 其它出入库单据表;已有的采购与销售单据数值与该进销存口径不一致,故采用导出值。', + '· 期末数量:运行时取聚水潭实时库存 rpt.jst_inventoryInfo(按 wms_co_id 单仓),随刷新滚动。', + '· 库存周转天数 / 四级库存量:运行时由库存快照 rpt.pm_stockMonitor 计算,基准时刻为快照日次日 01:00。', + '', + '算式:', + '· 期末数量 = 期初数量 + 加工进仓 + 采购进仓 + 其它进仓 + 其它退货 + 销售退货 + 调拨入', + ' − 采购退货 − 销售出仓 − 盘点 − 加工出仓 − 调拨出 − 其它出库', + '· 库存周转天数 = 月度发货总件数 / 月度日均库存件数 × 100%', + ' 其中 月度发货总件数 = threeAvgSale × 30,月度日均库存件数 = jsStock', + '· 库存分级(库龄 + 周转率双维度):', + ' 库龄 < 3 个月 且 周转率 30%~199% → 滞销预警', + ' 库龄 < 3 个月 且 周转率 ≥ 200% → 商品慢流', + ' 库龄 ≥ 3 个月 且 周转率 30%~99% → 商品慢流', + ' 库龄 3~12 个月 且 周转率 ≥ 100% → 商品呆滞', + ' 库龄 > 12 个月 且 周转率 100%~199% → 商品呆滞', + ' 库龄 > 12 个月 且 周转率 ≥ 200% → 商品僵尸', +]; diff --git a/frontend/src/constants/sidebarPages.js b/frontend/src/constants/sidebarPages.js index 4545a75..3e72ea9 100644 --- a/frontend/src/constants/sidebarPages.js +++ b/frontend/src/constants/sidebarPages.js @@ -1,12 +1,13 @@ -const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false }; -const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false }; - -export const SIDEBAR_PAGES = [ - { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, - { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, -]; - -export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { - acc[item.pageKey] = item; - return acc; -}, {}); +const DEFAULT_PLANNING_ROLES = { admin: true, analyst: true, user: true, guest: false }; +const DEFAULT_SYSTEM_ROLES = { admin: true, analyst: false, user: false, guest: false }; + +export const SIDEBAR_PAGES = [ + { pageKey: 'dashboard_preview', path: '/planning/dev-style-preview', name: '仪表盘', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, + { pageKey: 'settings', path: '/settings', name: '系统设置', category: 'system', defaultRoles: DEFAULT_SYSTEM_ROLES }, + { pageKey: 'daily_inventory', path: '/planning/operation/daily-inventory', name: '每日进销存', category: 'planning', defaultRoles: DEFAULT_PLANNING_ROLES }, +]; + +export const SIDEBAR_PAGE_MAP = SIDEBAR_PAGES.reduce((acc, item) => { + acc[item.pageKey] = item; + return acc; +}, {}); diff --git a/frontend/src/layouts/BasicLayout.jsx b/frontend/src/layouts/BasicLayout.jsx index da2ed45..bae3569 100644 --- a/frontend/src/layouts/BasicLayout.jsx +++ b/frontend/src/layouts/BasicLayout.jsx @@ -1,1315 +1,1327 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { - AppstoreOutlined, - CloudOutlined, - DashboardOutlined, - DesktopOutlined, - DownOutlined, - ExperimentOutlined, - FundProjectionScreenOutlined, - LineChartOutlined, - LogoutOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, - MessageOutlined, - MoonOutlined, - RightOutlined, - RobotOutlined, - SettingOutlined, - ShoppingOutlined, - TableOutlined, - TeamOutlined, - UserOutlined, - SunOutlined, -} from '@ant-design/icons'; -import { Dropdown, Switch } from 'antd'; -import { verifyToken } from '../api/auth'; -import { getSetupStatus, getSystemConfig } from '../api/admin'; -import { getInstallStatus } from '../api/install'; -import ErrorBoundary from '../components/ErrorBoundary'; -import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; -import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; -import { - canRestoreDesktopSessionImmediately, - isDesktopEmbedSession, - markDesktopEmbedSession, - requestDesktopSessionRenewal, -} from '../utils/desktopEmbedSession'; - -const AUTH_USER_STORAGE_KEY = 'auth_user'; -const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; -const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; -const PLANNING_HOME_URL = '/planning/dev-style-preview'; -const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; -const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; -const readSidebarDarkModePreference = () => { - try { - const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); - if (value === 'dark') return true; - if (value === 'light') return false; - } catch { - // Local persistence is optional in restricted browser contexts. - } - return null; -}; - -const SIDEBAR_ICON_MAP = { - appstore: AppstoreOutlined, - cloud: CloudOutlined, - dashboard: DashboardOutlined, - desktop: DesktopOutlined, - experiment: ExperimentOutlined, - 'line-chart': LineChartOutlined, - message: MessageOutlined, - projection: FundProjectionScreenOutlined, - robot: RobotOutlined, - setting: SettingOutlined, - shopping: ShoppingOutlined, - table: TableOutlined, - team: TeamOutlined, -}; - -const SIDEBAR_ROUTE_MAP = { - 'dashboard-preview': { path: '/planning/dev-style-preview' }, - 'marketing-events': { - path: '/planning/operation/marketing-events', - activePath: '/planning/operation/marketing-events', - matchMode: 'prefix', - }, - 'promotion-module': { path: '/planning/operation/promotion-module' }, - 'product-monitoring': { path: '/planning/operation/product-monitoring' }, - 'modular-stock-monitoring': { path: '/planning/operation/modular-stock-monitoring' }, - 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, - 'product-analysis': { path: '/planning/operation/product-analysis' }, - 'work-orders': { path: '/planning/operation/work-orders' }, - settings: { path: '/settings' }, - 'online-desktop-users': { path: '/settings/online-desktop-users' }, - 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, -}; - -const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { - if (entry.requiresAdmin && !canUseSettings) return []; - if (entry.type === 'section') return [{ ...entry }]; - - const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; - const routeMatches = (itemRoute = {}, pathname = '') => { - const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; - if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { - return false; - } - - const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; - if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { - return true; - } - - const matchPath = itemRoute.activePath || itemRoute.path; - if (!matchPath) return false; - return itemRoute.matchMode === 'prefix' - ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) - : pathname === matchPath; - }; - const buildItem = (item) => { - if (item.requiresAdmin && !canUseSettings) return null; - const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; - return { - ...item, - icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, - path: itemRoute.path, - external: Boolean(itemRoute.externalUrl), - onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, - match: (path) => routeMatches(itemRoute, path), - }; - }; - - const normalized = { - ...entry, - icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, - path: route.path, - external: Boolean(route.externalUrl), - onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, - match: (path) => routeMatches(route, path), - }; - - if (entry.type === 'group') { - normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); - } - - return [normalized]; -}); - -function parseJwtPayload(token) { - if (!token) { - return null; - } - - try { - const [, payload] = token.split('.'); - if (!payload) { - return null; - } - - const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); - const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); - return JSON.parse(window.atob(padded)); - } catch { - return null; - } -} - -function isExpiredJwtPayload(payload) { - const exp = Number(payload?.exp || 0); - return exp > 0 && Math.floor(Date.now() / 1000) >= exp; -} - -function readStoredAuthUser(tokenSub) { - try { - const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); - if (!raw) { - return null; - } - - const storedUser = JSON.parse(raw); - const storedSub = storedUser?.sub ?? storedUser?.id; - return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; - } catch { - return null; - } -} - -function clearAuthSession() { - localStorage.removeItem('token'); - localStorage.removeItem(AUTH_USER_STORAGE_KEY); -} - -function looksLikeMojibake(text) { - return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); -} - -function repairMojibake(text) { - if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { - return text; - } - - try { - const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); - const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); - return repaired || text; - } catch { - return text; - } -} - -function normalizeDisplayName(...candidates) { - const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); - return repairMojibake(rawName?.trim() || '个人中心'); -} - -function buildFallbackUserFromToken(token) { - const payload = parseJwtPayload(token); - if (!payload?.sub || isExpiredJwtPayload(payload)) { - return null; - } - - const storedUser = readStoredAuthUser(payload.sub); - return { - sub: payload.sub, - username: payload.username || '', - name: payload.name || payload.username || '当前用户', - email: payload.email || '', - role: payload.role || storedUser?.role || 'user', - picture: payload.picture || storedUser?.picture || '', - authSource: payload.authSource || storedUser?.authSource || 'local', - departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, - departmentName: payload.departmentName || storedUser?.departmentName || '', - allowedPageKeys: Array.isArray(payload.allowedPageKeys) - ? payload.allowedPageKeys - : Array.isArray(storedUser?.allowedPageKeys) - ? storedUser.allowedPageKeys - : [], - }; -} - -function canFallbackToJwtPayload(error, fallbackUser) { - if (!fallbackUser) { - return false; - } - - if (fallbackUser.authSource === 'local-preview') { - return true; - } - - const status = Number(error?.response?.status || 0); - return !status || status >= 500; -} - -const BasicLayout = () => { - const location = useLocation(); - const navigate = useNavigate(); - const [user, setUser] = useState(null); - const [authReady, setAuthReady] = useState(false); - const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); - const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); - const [sidebarCollapsed, setSidebarCollapsed] = useState( - () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault - ); - const [sidebarDarkMode, setSidebarDarkMode] = useState( - () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault - ); - const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( - DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); - - const pathname = location.pathname; - const isImmersivePlanningRoute = false; - const canUseSettings = user?.role === 'admin'; - const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); - const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; - - const sidebarSections = useMemo( - () => buildSidebarSections(sidebarConfig, canUseSettings), - [canUseSettings, sidebarConfig] - ); - useEffect(() => { - const loadSiteConfig = async () => { - try { - const config = await getSystemConfig(); - const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; - const nextLogo = config?.logo || FALLBACK_LOGO; - const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); - - setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); - setSidebarConfig(nextSidebarConfig); - setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); - setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); - setExpandedSidebarKeys( - nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) - ); - document.title = nextSiteName; - - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = nextLogo; - } catch { - document.title = FALLBACK_SITE_NAME; - } - }; - - loadSiteConfig(); - }, []); - - useEffect(() => { - const handleSiteConfigUpdated = (event) => { - const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; - const nextLogo = event.detail?.logo; - - setSiteConfig((current) => ({ - siteName: nextSiteName, - logo: nextLogo || current.logo, - })); - document.title = nextSiteName; - - if (nextLogo) { - let link = document.querySelector("link[rel*='icon']"); - if (!link) { - link = document.createElement('link'); - link.rel = 'icon'; - document.head.appendChild(link); - } - link.href = nextLogo; - } - }; - - window.addEventListener('site-config-updated', handleSiteConfigUpdated); - return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); - }, []); - - useEffect(() => { - const checkAuth = async () => { - markDesktopEmbedSession(); - const token = localStorage.getItem('token'); - const fallbackUser = buildFallbackUserFromToken(token); - - if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { - setUser(fallbackUser); - setAuthReady(true); - - try { - const verification = await verifyToken(); - if (!verification?.valid || !verification.user) { - clearAuthSession(); - requestDesktopSessionRenewal('web-session-invalid'); - return; - } - - const resolvedUser = verification.user; - localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); - setUser(resolvedUser); - - if (resolvedUser.role === 'admin') { - const setup = await getSetupStatus().catch((error) => { - console.warn('Setup status unavailable, continue with current session:', error); - return null; - }); - if (setup?.shouldShowWizard) { - navigate('/setup', { replace: true }); - } - } - } catch (error) { - if (!canFallbackToJwtPayload(error, fallbackUser)) { - clearAuthSession(); - requestDesktopSessionRenewal('web-session-invalid'); - } - } - return; - } - - try { - const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); - const verificationPromise = verifyToken() - .then((res) => ({ res, error: null })) - .catch((error) => ({ res: null, error })); - const setupStatusPromise = fallbackUser?.role === 'admin' - ? getSetupStatus().catch((error) => { - console.warn('Setup status unavailable, continue with current session:', error); - return null; - }) - : null; - const [installStatus, verification] = await Promise.all([ - installStatusPromise, - verificationPromise, - ]); - if (installStatus?.required && !fallbackUser) { - clearAuthSession(); - navigate('/install', { replace: true }); - return; - } - - let resolvedUser = null; - const verifyError = verification.error; - if (verification.res?.valid) { - resolvedUser = verification.res.user; - } else if (verifyError) { - console.warn('Token verification failed:', verifyError); - } - - if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { - resolvedUser = fallbackUser; - } - - if (resolvedUser) { - localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); - if (resolvedUser.role === 'admin') { - try { - const setup = setupStatusPromise - ? await setupStatusPromise - : await getSetupStatus(); - if (setup?.shouldShowWizard) { - navigate('/setup', { replace: true }); - return; - } - } catch (error) { - console.warn('Setup status unavailable, continue with current session:', error); - } - } - - setUser(resolvedUser); - } else if (installStatus?.required) { - clearAuthSession(); - navigate('/install', { replace: true }); - } else { - clearAuthSession(); - if (!requestDesktopSessionRenewal('web-session-missing')) { - navigate('/login'); - } - } - } catch (error) { - console.error(error); - if (fallbackUser) { - setUser(fallbackUser); - } else { - clearAuthSession(); - if (!requestDesktopSessionRenewal('web-session-invalid')) { - navigate('/login'); - } - } - } finally { - setAuthReady(true); - } - }; - - checkAuth(); - }, [navigate]); - - useEffect(() => { - if (!authReady || !user) { - return; - } - - if (pathname === '/' || pathname === '/dashboard') { - navigate(PLANNING_HOME_URL, { replace: true }); - return; - } - - if (pathname.startsWith('/settings') && !canUseSettings) { - navigate(PLANNING_HOME_URL, { replace: true }); - return; - } - - const isAllowedPath = ( - pathname === '/planning' - || pathname.startsWith('/planning/') - || pathname === '/settings' - || pathname.startsWith('/settings/') - ); - - if (!isAllowedPath) { - navigate(PLANNING_HOME_URL, { replace: true }); - } - }, [authReady, canUseSettings, navigate, pathname, user]); - - const handleLogout = () => { - clearAuthSession(); - navigate('/login'); - }; - - const userMenu = [ - canUseSettings - ? { - key: 'settings', - icon: , - label: '系统设置', - onClick: () => navigate('/settings'), - } - : null, - { - key: 'logout', - icon: , - label: '退出登录', - onClick: handleLogout, - }, - ].filter(Boolean); - - const isSidebarEntryActive = (entry) => { - if (!entry) return false; - if (entry.match?.(pathname)) return true; - return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); - }; - - const toggleSidebarGroup = (key) => { - setSidebarCollapsed(false); - setExpandedSidebarKeys((keys) => ( - keys.includes(key) - ? keys.filter((item) => item !== key) - : [...keys, key] - )); - }; - - const handleSidebarLeafClick = (item) => { - if (item.external) { - item.onClick?.(); - return; - } - if (item.path) { - navigate(item.path); - } - }; - - const handleSidebarDarkModeChange = (checked) => { - setSidebarDarkMode(checked); - try { - localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); - } catch { - // Keep the current-session preference when persistence is unavailable. - } - }; - - if (!authReady) { - return ; - } - - return ( -
- - -
- - - -
- - -
- ); -}; - -export default BasicLayout; +import React, { useEffect, useMemo, useState } from 'react'; +import { Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { + AppstoreOutlined, + CloudOutlined, + DashboardOutlined, + DesktopOutlined, + DownOutlined, + ExperimentOutlined, + FundProjectionScreenOutlined, + LineChartOutlined, + LogoutOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + MessageOutlined, + MoonOutlined, + RightOutlined, + RobotOutlined, + SettingOutlined, + ShoppingOutlined, + TableOutlined, + TeamOutlined, + UserOutlined, + SunOutlined, +} from '@ant-design/icons'; +import { Dropdown, Switch } from 'antd'; +import { verifyToken } from '../api/auth'; +import { getSetupStatus, getSystemConfig } from '../api/admin'; +import { getInstallStatus } from '../api/install'; +import ErrorBoundary from '../components/ErrorBoundary'; +import { AppShellSkeleton } from '../components/AppLoadingSkeleton'; +import { DEFAULT_SIDEBAR_CONFIG, parseSidebarConfig, sortSidebarEntries } from '../config/sidebarNavigation'; +import { + canRestoreDesktopSessionImmediately, + isDesktopEmbedSession, + markDesktopEmbedSession, + requestDesktopSessionRenewal, +} from '../utils/desktopEmbedSession'; + +const AUTH_USER_STORAGE_KEY = 'auth_user'; +const SIDEBAR_DARK_MODE_STORAGE_KEY = 'maixiang_sidebar_dark_mode'; +const BRAND_HOME_URL = 'https://yyd.jiansuntuzhi.cn/analysis/summary'; +const PLANNING_HOME_URL = '/planning/dev-style-preview'; +const FALLBACK_SITE_NAME = '拼多多自动化运营平台'; +const FALLBACK_LOGO = 'https://s3.jiansun.vip/gongxiangpan/logos/1775097467497-1d6a81b8a76a.png'; +const readSidebarDarkModePreference = () => { + try { + const value = localStorage.getItem(SIDEBAR_DARK_MODE_STORAGE_KEY); + if (value === 'dark') return true; + if (value === 'light') return false; + } catch { + // Local persistence is optional in restricted browser contexts. + } + return null; +}; + +const SIDEBAR_ICON_MAP = { + appstore: AppstoreOutlined, + cloud: CloudOutlined, + dashboard: DashboardOutlined, + desktop: DesktopOutlined, + experiment: ExperimentOutlined, + 'line-chart': LineChartOutlined, + message: MessageOutlined, + projection: FundProjectionScreenOutlined, + robot: RobotOutlined, + setting: SettingOutlined, + shopping: ShoppingOutlined, + table: TableOutlined, + team: TeamOutlined, +}; + +const SIDEBAR_ROUTE_MAP = { + 'dashboard-preview': { path: '/planning/dev-style-preview' }, + 'marketing-events': { + path: '/planning/operation/marketing-events', + activePath: '/planning/operation/marketing-events', + matchMode: 'prefix', + }, + 'promotion-module': { path: '/planning/operation/promotion-module' }, + 'product-monitoring': { path: '/planning/operation/product-monitoring' }, + 'ops-b-incentive': { path: '/planning/operation/ops-b-incentive' }, + 'product-analysis': { path: '/planning/operation/product-analysis' }, + 'modular-stock-monitoring': { path: '/planning/operation/modular-stock-monitoring' }, + 'work-orders': { path: '/planning/operation/work-orders' }, + 'modular-stock-monitor-accessory': { + path: '/planning/operation/modular-stock-monitor', + activePath: '/planning/operation/modular-stock-monitor', + matchMode: 'prefix', + }, + 'mkh-bom-data': { + path: '/planning/operation/mkh-bom-data', + activePath: '/planning/operation/mkh-bom-data', + matchMode: 'prefix', + }, + 'daily-inventory': { path: '/planning/operation/daily-inventory' }, + settings: { path: '/settings' }, + 'online-desktop-users': { path: '/settings/online-desktop-users' }, + 'desktop-execution-tasks': { path: '/settings/desktop-execution-tasks' }, +}; + + +const buildSidebarSections = (config, canUseSettings) => sortSidebarEntries(config.entries).flatMap((entry) => { + if (entry.requiresAdmin && !canUseSettings) return []; + if (entry.type === 'section') return [{ ...entry }]; + + const route = SIDEBAR_ROUTE_MAP[entry.routeKey] || {}; + const routeMatches = (itemRoute = {}, pathname = '') => { + const excludedPaths = Array.isArray(itemRoute.excludePaths) ? itemRoute.excludePaths : []; + if (excludedPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return false; + } + + const exactPaths = Array.isArray(itemRoute.matchPaths) ? itemRoute.matchPaths : []; + if (exactPaths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return true; + } + + const matchPath = itemRoute.activePath || itemRoute.path; + if (!matchPath) return false; + return itemRoute.matchMode === 'prefix' + ? pathname === matchPath || pathname.startsWith(`${matchPath}/`) + : pathname === matchPath; + }; + const buildItem = (item) => { + if (item.requiresAdmin && !canUseSettings) return null; + const itemRoute = SIDEBAR_ROUTE_MAP[item.routeKey] || {}; + return { + ...item, + icon: SIDEBAR_ICON_MAP[item.icon] || AppstoreOutlined, + path: itemRoute.path, + external: Boolean(itemRoute.externalUrl), + onClick: itemRoute.externalUrl ? () => { window.location.href = itemRoute.externalUrl; } : undefined, + match: (path) => routeMatches(itemRoute, path), + }; + }; + + const normalized = { + ...entry, + icon: SIDEBAR_ICON_MAP[entry.icon] || AppstoreOutlined, + path: route.path, + external: Boolean(route.externalUrl), + onClick: route.externalUrl ? () => { window.location.href = route.externalUrl; } : undefined, + match: (path) => routeMatches(route, path), + }; + + if (entry.type === 'group') { + normalized.children = sortSidebarEntries(entry.children).map(buildItem).filter(Boolean); + } + + return [normalized]; +}); + +function parseJwtPayload(token) { + if (!token) { + return null; + } + + try { + const [, payload] = token.split('.'); + if (!payload) { + return null; + } + + const normalized = payload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), '='); + return JSON.parse(window.atob(padded)); + } catch { + return null; + } +} + +function isExpiredJwtPayload(payload) { + const exp = Number(payload?.exp || 0); + return exp > 0 && Math.floor(Date.now() / 1000) >= exp; +} + +function readStoredAuthUser(tokenSub) { + try { + const raw = localStorage.getItem(AUTH_USER_STORAGE_KEY); + if (!raw) { + return null; + } + + const storedUser = JSON.parse(raw); + const storedSub = storedUser?.sub ?? storedUser?.id; + return storedSub && String(storedSub) === String(tokenSub) ? storedUser : null; + } catch { + return null; + } +} + +function clearAuthSession() { + localStorage.removeItem('token'); + localStorage.removeItem(AUTH_USER_STORAGE_KEY); +} + +function looksLikeMojibake(text) { + return /[ÃÂ][\u0080-\u00ff]|[äåæçèé][\u0080-\u00ff]|[\u00c0-\u00ff][\u0080-\u00bf]/.test(text); +} + +function repairMojibake(text) { + if (!text || !looksLikeMojibake(text) || typeof TextDecoder === 'undefined') { + return text; + } + + try { + const bytes = Uint8Array.from([...text].map((char) => char.charCodeAt(0) & 0xff)); + const repaired = new TextDecoder('utf-8', { fatal: true }).decode(bytes).trim(); + return repaired || text; + } catch { + return text; + } +} + +function normalizeDisplayName(...candidates) { + const rawName = candidates.find((item) => typeof item === 'string' && item.trim()); + return repairMojibake(rawName?.trim() || '个人中心'); +} + +function buildFallbackUserFromToken(token) { + const payload = parseJwtPayload(token); + if (!payload?.sub || isExpiredJwtPayload(payload)) { + return null; + } + + const storedUser = readStoredAuthUser(payload.sub); + return { + sub: payload.sub, + username: payload.username || '', + name: payload.name || payload.username || '当前用户', + email: payload.email || '', + role: payload.role || storedUser?.role || 'user', + picture: payload.picture || storedUser?.picture || '', + authSource: payload.authSource || storedUser?.authSource || 'local', + departmentId: payload.departmentId ?? storedUser?.departmentId ?? null, + departmentName: payload.departmentName || storedUser?.departmentName || '', + allowedPageKeys: Array.isArray(payload.allowedPageKeys) + ? payload.allowedPageKeys + : Array.isArray(storedUser?.allowedPageKeys) + ? storedUser.allowedPageKeys + : [], + }; +} + +function canFallbackToJwtPayload(error, fallbackUser) { + if (!fallbackUser) { + return false; + } + + if (fallbackUser.authSource === 'local-preview') { + return true; + } + + const status = Number(error?.response?.status || 0); + return !status || status >= 500; +} + +const BasicLayout = () => { + const location = useLocation(); + const navigate = useNavigate(); + const [user, setUser] = useState(null); + const [authReady, setAuthReady] = useState(false); + const [siteConfig, setSiteConfig] = useState({ siteName: FALLBACK_SITE_NAME, logo: FALLBACK_LOGO }); + const [sidebarConfig, setSidebarConfig] = useState(DEFAULT_SIDEBAR_CONFIG); + const [sidebarCollapsed, setSidebarCollapsed] = useState( + () => DEFAULT_SIDEBAR_CONFIG.collapsedByDefault + ); + const [sidebarDarkMode, setSidebarDarkMode] = useState( + () => readSidebarDarkModePreference() ?? DEFAULT_SIDEBAR_CONFIG.darkModeByDefault + ); + const [expandedSidebarKeys, setExpandedSidebarKeys] = useState( + DEFAULT_SIDEBAR_CONFIG.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + + const pathname = location.pathname; + const isImmersivePlanningRoute = false; + const canUseSettings = user?.role === 'admin'; + const userDisplayName = normalizeDisplayName(user?.name, user?.username, user?.email); + const userAvatar = user?.picture || siteConfig.logo || FALLBACK_LOGO; + + const sidebarSections = useMemo( + () => buildSidebarSections(sidebarConfig, canUseSettings), + [canUseSettings, sidebarConfig] + ); + useEffect(() => { + const loadSiteConfig = async () => { + try { + const config = await getSystemConfig(); + const nextSiteName = config?.site_name || FALLBACK_SITE_NAME; + const nextLogo = config?.logo || FALLBACK_LOGO; + const nextSidebarConfig = parseSidebarConfig(config?.sidebar_navigation); + + setSiteConfig({ siteName: nextSiteName, logo: nextLogo }); + setSidebarConfig(nextSidebarConfig); + setSidebarCollapsed(Boolean(nextSidebarConfig.collapsedByDefault)); + setSidebarDarkMode(readSidebarDarkModePreference() ?? Boolean(nextSidebarConfig.darkModeByDefault)); + setExpandedSidebarKeys( + nextSidebarConfig.entries.filter((entry) => entry.defaultExpanded).map((entry) => entry.key) + ); + document.title = nextSiteName; + + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } catch { + document.title = FALLBACK_SITE_NAME; + } + }; + + loadSiteConfig(); + }, []); + + useEffect(() => { + const handleSiteConfigUpdated = (event) => { + const nextSiteName = event.detail?.siteName || FALLBACK_SITE_NAME; + const nextLogo = event.detail?.logo; + + setSiteConfig((current) => ({ + siteName: nextSiteName, + logo: nextLogo || current.logo, + })); + document.title = nextSiteName; + + if (nextLogo) { + let link = document.querySelector("link[rel*='icon']"); + if (!link) { + link = document.createElement('link'); + link.rel = 'icon'; + document.head.appendChild(link); + } + link.href = nextLogo; + } + }; + + window.addEventListener('site-config-updated', handleSiteConfigUpdated); + return () => window.removeEventListener('site-config-updated', handleSiteConfigUpdated); + }, []); + + useEffect(() => { + const checkAuth = async () => { + markDesktopEmbedSession(); + const token = localStorage.getItem('token'); + const fallbackUser = buildFallbackUserFromToken(token); + + if (canRestoreDesktopSessionImmediately(isDesktopEmbedSession(), fallbackUser)) { + setUser(fallbackUser); + setAuthReady(true); + + try { + const verification = await verifyToken(); + if (!verification?.valid || !verification.user) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + return; + } + + const resolvedUser = verification.user; + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + setUser(resolvedUser); + + if (resolvedUser.role === 'admin') { + const setup = await getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + } + } + } catch (error) { + if (!canFallbackToJwtPayload(error, fallbackUser)) { + clearAuthSession(); + requestDesktopSessionRenewal('web-session-invalid'); + } + } + return; + } + + try { + const installStatusPromise = getInstallStatus().catch(() => ({ required: false })); + const verificationPromise = verifyToken() + .then((res) => ({ res, error: null })) + .catch((error) => ({ res: null, error })); + const setupStatusPromise = fallbackUser?.role === 'admin' + ? getSetupStatus().catch((error) => { + console.warn('Setup status unavailable, continue with current session:', error); + return null; + }) + : null; + const [installStatus, verification] = await Promise.all([ + installStatusPromise, + verificationPromise, + ]); + if (installStatus?.required && !fallbackUser) { + clearAuthSession(); + navigate('/install', { replace: true }); + return; + } + + let resolvedUser = null; + const verifyError = verification.error; + if (verification.res?.valid) { + resolvedUser = verification.res.user; + } else if (verifyError) { + console.warn('Token verification failed:', verifyError); + } + + if (!resolvedUser && canFallbackToJwtPayload(verifyError, fallbackUser)) { + resolvedUser = fallbackUser; + } + + if (resolvedUser) { + localStorage.setItem(AUTH_USER_STORAGE_KEY, JSON.stringify(resolvedUser)); + if (resolvedUser.role === 'admin') { + try { + const setup = setupStatusPromise + ? await setupStatusPromise + : await getSetupStatus(); + if (setup?.shouldShowWizard) { + navigate('/setup', { replace: true }); + return; + } + } catch (error) { + console.warn('Setup status unavailable, continue with current session:', error); + } + } + + setUser(resolvedUser); + } else if (installStatus?.required) { + clearAuthSession(); + navigate('/install', { replace: true }); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-missing')) { + navigate('/login'); + } + } + } catch (error) { + console.error(error); + if (fallbackUser) { + setUser(fallbackUser); + } else { + clearAuthSession(); + if (!requestDesktopSessionRenewal('web-session-invalid')) { + navigate('/login'); + } + } + } finally { + setAuthReady(true); + } + }; + + checkAuth(); + }, [navigate]); + + useEffect(() => { + if (!authReady || !user) { + return; + } + + if (pathname === '/' || pathname === '/dashboard') { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + if (pathname.startsWith('/settings') && !canUseSettings) { + navigate(PLANNING_HOME_URL, { replace: true }); + return; + } + + const isAllowedPath = ( + pathname === '/planning' + || pathname.startsWith('/planning/') + || pathname === '/settings' + || pathname.startsWith('/settings/') + ); + + if (!isAllowedPath) { + navigate(PLANNING_HOME_URL, { replace: true }); + } + }, [authReady, canUseSettings, navigate, pathname, user]); + + const handleLogout = () => { + clearAuthSession(); + navigate('/login'); + }; + + const userMenu = [ + canUseSettings + ? { + key: 'settings', + icon: , + label: '系统设置', + onClick: () => navigate('/settings'), + } + : null, + { + key: 'logout', + icon: , + label: '退出登录', + onClick: handleLogout, + }, + ].filter(Boolean); + + const isSidebarEntryActive = (entry) => { + if (!entry) return false; + if (entry.match?.(pathname)) return true; + return Array.isArray(entry.children) && entry.children.some((child) => isSidebarEntryActive(child)); + }; + + const toggleSidebarGroup = (key) => { + setSidebarCollapsed(false); + setExpandedSidebarKeys((keys) => ( + keys.includes(key) + ? keys.filter((item) => item !== key) + : [...keys, key] + )); + }; + + const handleSidebarLeafClick = (item) => { + if (item.external) { + item.onClick?.(); + return; + } + if (item.path) { + navigate(item.path); + } + }; + + const handleSidebarDarkModeChange = (checked) => { + setSidebarDarkMode(checked); + try { + localStorage.setItem(SIDEBAR_DARK_MODE_STORAGE_KEY, checked ? 'dark' : 'light'); + } catch { + // Keep the current-session preference when persistence is unavailable. + } + }; + + if (!authReady) { + return ; + } + + return ( +
+ + +
+ + + +
+ + +
+ ); +}; + +export default BasicLayout; diff --git a/frontend/src/pages/DailyInventory/index.jsx b/frontend/src/pages/DailyInventory/index.jsx new file mode 100644 index 0000000..d22ac5a --- /dev/null +++ b/frontend/src/pages/DailyInventory/index.jsx @@ -0,0 +1,177 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Alert, Button, Modal, Space, Spin, Table, Tooltip, Typography } from 'antd'; +import { InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getDailyInventory } from '../../api/dailyInventory'; +import { + DAILY_INVENTORY_COLUMNS, + COLUMN_SOURCE, + FORMULA_DESCRIPTION, +} from '../../constants/dailyInventory.js'; +import './style.css'; + +const { Title, Text } = Typography; + +const TipTitle = ({ tip, children }) => ( + + {children} + +); + +const int = (value) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { maximumFractionDigits: 0 }); +}; + +const decimal = (value, digits = 1) => { + if (value === null || value === undefined || value === '') return '—'; + return Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +}; + +/** 负数标红、正数标黑:出入库列以负数表示流出,便于一眼分辨方向 */ +const renderSigned = (value) => { + if (value === null || value === undefined || value === '') return '—'; + const n = Number(value); + if (!Number.isFinite(n) || n === 0) return 0; + return {int(n)}; +}; + +const DailyInventory = () => { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [data, setData] = useState(null); + const [helpOpen, setHelpOpen] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await getDailyInventory(); + setData(res?.data ?? null); + } catch (err) { + setError(err?.response?.data?.error || err?.message || '每日进销存数据加载失败'); + setData(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + const items = useMemo(() => data?.items ?? [], [data]); + + const columns = useMemo(() => DAILY_INVENTORY_COLUMNS.map((col) => { + const isRuntime = col.source === COLUMN_SOURCE.runtime; + return { + title: {col.label}, + dataIndex: col.key, + key: col.key, + align: col.key === 'warehouse' ? 'left' : 'right', + width: col.key === 'warehouse' ? 240 : 130, + fixed: col.key === 'warehouse' ? 'left' : undefined, + className: isRuntime ? 'daily-inventory-col--runtime' : undefined, + render: (value, record) => { + if (record?.isTotal) { + // 合计行:周转天数不累加,显示「—」 + if (col.key === 'turnoverDays') return '—'; + return int(value); + } + if (col.key === 'warehouse') return {value}; + if (col.key === 'closingQty') return int(value); + if (col.key === 'turnoverDays') return decimal(value, 1); + return renderSigned(value); + }, + }; + }), []); + + const tableRows = useMemo(() => { + if (!data?.totals) return items; + return [...items, { ...data.totals, key: '__total__' }]; + }, [items, data]); + + const noGradeCount = useMemo(() => items.filter((it) => !it.hasGrade).length, [items]); + + return ( + +
+
+ 每日进销存 + +
+
+ 基准日期:{data?.baselineDate || '—'} + 库存快照日:{data?.latestBizDate || '—'} + 分级基准时刻:{data?.asOf || '—'} + {data?.items?.[0]?.closingTakenAt ? ( + + 库存取数时间:{new Date(data.items[0].closingTakenAt).toLocaleString('zh-CN', { hour12: false })} + + ) : null} +
+ + + +
+ + + 期初数量与 12 个流水列取自 {data?.baselineDate || '2026-09-22'} 聚水潭进销存导出值(业务库缺少调拨/盘点/加工/其它出入库单据表,无法还原); + 期末数量与右侧 5 列为运行时实时取数。悬浮表头可看每列口径。 + {noGradeCount > 0 ? ` 其中 ${noGradeCount} 个仓在库存快照中无数据,周转天数与四级库存量显示「—」。` : ''} + + } + style={{ marginBottom: 12 }} + /> + + {error ? : null} + + + record.key || record.warehouse} + size="small" + bordered + pagination={false} + scroll={{ x: 'max-content', y: 'calc(100vh - 300px)' }} + rowClassName={(record) => (record?.isTotal ? 'daily-inventory-row--total' : '')} + /> + + + + 共 {items.length} 个仓库;蓝色底纹列为运行时取数,其余为基准值。 + + + setHelpOpen(false)} + footer={null} + width={720} + > +
+ {FORMULA_DESCRIPTION.map((line, i) => ( +

{line}

+ ))} +
+
+ + ); +}; + +export default DailyInventory; diff --git a/frontend/src/pages/DailyInventory/style.css b/frontend/src/pages/DailyInventory/style.css new file mode 100644 index 0000000..2019966 --- /dev/null +++ b/frontend/src/pages/DailyInventory/style.css @@ -0,0 +1,91 @@ +.daily-inventory-page { + padding: 16px 20px 24px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.daily-inventory-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.daily-inventory-header__title { + display: flex; + align-items: center; + gap: 4px; +} + +.daily-inventory-header__help { + font-size: 13px; +} + +.daily-inventory-header__meta { + display: flex; + flex-wrap: wrap; + gap: 16px; + font-size: 13px; + color: rgba(0, 0, 0, 0.55); +} + +.daily-inventory-th-tip { + border-bottom: 1px dashed rgba(0, 0, 0, 0.35); + cursor: help; + white-space: nowrap; +} + +.daily-inventory-table .ant-table-thead > tr > th { + white-space: nowrap; + font-size: 12px; + padding: 6px 8px; +} + +.daily-inventory-table .ant-table-tbody > tr > td { + padding: 5px 8px; + font-size: 13px; +} + +/* 运行时取数的列:加淡蓝底纹,与基准值区分 */ +.daily-inventory-table .daily-inventory-col--runtime { + background: rgba(22, 119, 255, 0.04); +} + +.daily-inventory-warehouse { + white-space: nowrap; +} + +.daily-inventory-cell--out { + color: #cf1322; +} + +.daily-inventory-cell--in { + color: rgba(0, 0, 0, 0.85); +} + +.daily-inventory-cell--zero { + color: rgba(0, 0, 0, 0.3); +} + +.daily-inventory-row--total > td { + font-weight: 600; + background: rgba(0, 0, 0, 0.03); +} + +.daily-inventory-footer { + font-size: 12px; +} + +.daily-inventory-help p { + margin: 0 0 6px; + line-height: 1.7; + font-size: 13px; + white-space: pre-wrap; +} + +.daily-inventory-help p.is-indent { + padding-left: 16px; + color: rgba(0, 0, 0, 0.65); +} diff --git a/frontend/src/pages/MkhBomData/index.jsx b/frontend/src/pages/MkhBomData/index.jsx new file mode 100644 index 0000000..993ac5f --- /dev/null +++ b/frontend/src/pages/MkhBomData/index.jsx @@ -0,0 +1,1353 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, Button, Card, Collapse, Dropdown, Input, Modal, Progress, Segmented, Select, Space, Spin, Switch, + Table, Tooltip, Typography, message, +} from 'antd'; +import { + BellOutlined, DownloadOutlined, PrinterOutlined, ReloadOutlined, SearchOutlined, SyncOutlined, +} from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { + getMkhBomList, + getMkhBomDetail, + getMkhBomExportRows, + getMkhReplenishAlertConfig, + saveMkhReplenishAlertConfig, + saveMkhNotificationCenter, + checkMkhReplenishAlert, + syncMkhBom, +} from '../../api/mkhBom'; +import { createRequestGate } from '../../utils/requestGate'; +import { requireMkhFreshContract } from '../../utils/mkhFreshContract'; +import { exportTableFile } from '../../utils/tableExport'; +import './style.css'; + +const { Title, Text } = Typography; + +const number = (value, digits = 0) => (value === null || value === undefined || value === '' + ? '0' + : Number(value).toLocaleString('zh-CN', { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + })); + +const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); + +const EMPTY_FILTERS = { keyword: '', scope: 'warning' }; +// 页面长时打开时定期重读。后端共享 10 分钟索引缓存,因此多个页面的轮询 +// 会合并为每个缓存周期一次业务库读取,不会每 2 分钟重复扫远程大表。 +const AUTO_REFRESH_MS = 2 * 60 * 1000; + +const getRefreshProgress = (elapsedSeconds) => { + if (elapsedSeconds < 3) { + return { percent: 12 + elapsedSeconds * 8, stage: '正在读取实时库存、箱表和加工单…' }; + } + if (elapsedSeconds < 15) { + return { percent: 36 + (elapsedSeconds - 3) * 3, stage: '正在核对待发货订单和近15天销量…' }; + } + return { + percent: Math.min(95, 72 + Math.floor((elapsedSeconds - 15) / 2)), + stage: '正在合并最新数据与 BOM 配件结果…', + }; +}; + +// 预警等级 → 标签配色 +const WARNING_TONE = { + 缺货: 'danger', + 预警: 'warn', + 已排产: 'scheduled', + 正常: 'ok', + 无销量: 'muted', +}; + +// 配件不足说明(口径见后端 mkhBomService 文件头 ⑪): +// 可组装数 = min(⌊(配件库存 − 未完成加工单占用) ÷ 单台用量⌋),为 0 即凑不出一台、补不出来 +const shortageTooltip = (row) => { + const bottlenecks = Array.isArray(row.bottleneckComponents) ? row.bottleneckComponents : []; + const details = bottlenecks + .filter((item) => Number(item.available) <= 0) + .map((item) => `短板 ${item.code}${item.name ? `(${item.name})` : ''}` + + `:库存 ${number(item.stock)}` + + `${Number(item.occupied) > 0 ? `,未完成加工单占用 ${number(item.occupied)}` : ''}` + + `,单台用量 ${number(item.perQty)}`); + return [ + `配件不足,无法补货:现有配件最多只能组装 ${number(row.assemblableQty)} 台`, + ...details, + '口径:可组装数 = 各配件 ⌊(库存 − 未完成加工单占用) ÷ 单台用量⌋ 取最小;为 0 即补不出来', + ].filter(Boolean).join(';'); +}; + +// 展示范围切换 +const SCOPE_OPTIONS = [ + { label: '仅预警', value: 'warning' }, + { label: '有日销', value: 'sold' }, + { label: '加工在途', value: 'work-order' }, + { label: '全部', value: 'all' }, +]; + +// ── 导出(列口径与后端 listMkhBomExportRows 的字段一一对应)────────── +// 商品清单:与页面表格列一致,去掉「操作」列 +const PRODUCT_EXPORT_COLUMNS = [ + { title: 'MKH商品编码', dataIndex: 'skuId' }, + { title: '商品名称', dataIndex: 'productName' }, + { title: '预警', dataIndex: 'warningLevel' }, + { title: '近15天日销', dataIndex: 'dailySale' }, + { title: '现有库存', dataIndex: 'stock' }, + { title: '在途加工量', dataIndex: 'pendingWorkOrderQty' }, + { title: '待发货订单', dataIndex: 'pendingOrderQty' }, + { title: '结余库存', dataIndex: 'availableStock' }, + { + title: '可用天数', + dataIndex: 'stockDays', + // 无销量且无代发订单的商品 stockDays 为 null,导出成文字而非空白; + // 订单驱动分支(日销=0 但有代发/待发货订单)现已如实给出可用天数(库存÷代发件数),不会进这里 + exportValue: (value, row) => { + if (value !== null && value !== undefined) return value; + return row.orderDriven ? '无日销·按订单' : '无销量'; + }, + }, + { + title: '建议补货量', + dataIndex: 'replenishQty', + // 与页面一致:非预警商品不显示数字;配件不足(可组装数 = 0)时显示「—」不给补货目标 + exportValue: (value, row) => { + if (row.componentShortage) return '—'; + return (row.warningLevel === '缺货' || row.warningLevel === '预警') && Number(value) > 0 ? value : ''; + }, + }, + { title: '配件不足', dataIndex: 'componentShortage' }, + { title: 'BOM 状态', dataIndex: 'bomConfigured' }, + { title: '配件种类', dataIndex: 'accessoryCount' }, + { title: '配件总数量', dataIndex: 'accessoryQtySum' }, +]; + +// 配件明细:与打印模板一致,额外带部件条码便于下游核对 +const COMPONENT_EXPORT_COLUMNS = [ + { title: 'MKH商品编码', dataIndex: 'skuId' }, + { title: '部件条码', dataIndex: 'componentBarcode' }, + { title: '配件名称', dataIndex: 'componentName' }, + { title: '配件数量', dataIndex: 'qty' }, + { title: '仓位', dataIndex: 'location' }, +]; + +// 导出下拉菜单:key = `-` +const EXPORT_MENU_ITEMS = [ + { key: 'products-xlsx', label: '商品清单 · XLSX' }, + { key: 'products-csv', label: '商品清单 · CSV' }, + { type: 'divider' }, + { key: 'components-xlsx', label: '配件明细 · XLSX' }, + { key: 'components-csv', label: '配件明细 · CSV' }, +]; + +const EXPORT_SCOPE_LABEL = { + warning: '仅预警', sold: '有日销', 'work-order': '加工在途', all: '全部', +}; + +// 补货预警推送:范围选项与跳过原因文案 +const ALERT_SCOPE_OPTIONS = [ + { label: '缺货 + 3天内预警', value: 'warning' }, + { label: '仅缺货', value: 'outOfStock' }, +]; + +const ALERT_REASON_TEXT = { + no_new_alert: '没有新出现的预警,未发送(已推送过的不重复提醒)', + no_alert: '当前没有需要补货的 MKH 商品,未发送', + disabled: '推送开关已关闭,未发送', + recipient_not_configured: '未配置收件人,未发送', + notification_center_not_configured: '统一通知中心尚未配置,未发送', + error: '执行出错,未发送', +}; + +const ALERT_RECIPIENT_SOURCE_TEXT = { + config: '来自本页配置', + environment: '来自环境变量', + notification_center: '来自统一通知中心默认收件人', + none: '未找到可用的收件人', +}; + +// 「现有库存」列的取值来源说明(后端 stockSource 字段): +// realtime = 品牌仓实时库存表(jst_inventoryInfo,日更) +// pack = 品牌仓箱/货位表(jst_packInventoryInfo,小时级)—— +// 实时库存表当天还没同步到这条货(当日入库)时用它兜底 +// monitor = 两个实时源都没有记录,退回监控快照库存 +const STOCK_SOURCE_TEXT = { + realtime: '品牌仓实时库存', + pack: '品牌仓箱/货位库存(当日入库,实时库存表尚未同步)', + monitor: '品牌仓实时库存缺失,取监控快照库存', + 'live-missing': '实时库存表和箱/货位库存表均无记录,实时口径按 0 计算', +}; + +// A4 打印预览:表头 MKH商品编码 | 配件明细 | 数量 | 配件仓位 +// 仓位在库里是「仓库名 · 货位」的形式(如「常州简尚家居有限公司 · BCP01-01-1」), +// 打印给车间/仓库用时只留货位,去掉公司名前缀(业务要求 2026-09-18)。 +const stripWarehousePrefix = (value) => String(value || '') + .split('、') + .map((part) => { + const idx = part.lastIndexOf('·'); + return (idx >= 0 ? part.slice(idx + 1) : part).trim(); + }) + .filter(Boolean) + .join('、'); + +const PrintPreview = ({ data, onClose }) => { + const now = useMemo(() => new Date().toLocaleString('zh-CN'), []); + const items = data.items || []; + + useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
+
+ + + + 按 Esc 关闭预览 + +
+ +
+
+
MKH-BOM 资料
+
模块化商品配件明细 · 打印模板
+
+ +
+
打印时间:{now}
+
配件种类:{items.length}
+
+ +
+ + + + + + + + + + {items.map((item, idx) => ( + + {idx === 0 ? ( + + ) : null} + + + + + ))} + {items.length === 0 ? ( + + + + + ) : null} + +
MKH商品编码配件明细数量配件仓位
{text(data.skuId)}{text(item.skuName)}{item.skuId ? {item.skuId} : null}{number(item.qty)}{item.location ? text(stripWarehousePrefix(item.location)) : 未维护}
{text(data.skuId)}该 MKH 商品尚无可用的聚水潭商品 BOM 明细
+ +
+ + MKH 商品编码取自指定仓库商品主档;配件优先取聚水潭商品 BOM,业务组合 BOM 与产品资料系统作为补充; + 配件仓位只取聚水潭「箱及仓位库存」,未维护货位时显示箱号,无装箱记录显示「未维护」。 + +
+ + + ); +}; + +const MkhBomData = () => { + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + // 默认按可用天数升序 —— 补货预警场景下最紧急的排最前 + const [sorter, setSorter] = useState({ field: 'stockDays', order: 'ASC' }); + + const [expandedKeys, setExpandedKeys] = useState({}); + const [detailCache, setDetailCache] = useState({}); + const [detailLoading, setDetailLoading] = useState({}); + + const [printData, setPrintData] = useState(null); + const [printLoading, setPrintLoading] = useState(false); + + // 导出(当前筛选条件下的全部数据) + const [exporting, setExporting] = useState(false); + const [syncing, setSyncing] = useState(false); + const [refreshProgress, setRefreshProgress] = useState({ open: false, elapsedSeconds: 0, title: '' }); + + // 补货预警推送设置 + const [alertOpen, setAlertOpen] = useState(false); + const [alertConfig, setAlertConfig] = useState(null); + const [alertLoading, setAlertLoading] = useState(false); + const [alertBusy, setAlertBusy] = useState(false); + const [alertForm, setAlertForm] = useState({ enabled: true, recipientAlias: '', scope: 'warning' }); + const [alertResult, setAlertResult] = useState(null); + const [centerForm, setCenterForm] = useState({ baseUrl: '', projectCode: '', projectSecret: '' }); + const [centerSaving, setCenterSaving] = useState(false); + + const tableWrapRef = useRef(null); + const listRequestGateRef = useRef(createRequestGate()); + const detailGenerationRef = useRef(0); + const refreshProgressGenerationRef = useRef(0); + const lastListRefreshAtRef = useRef(0); + const autoRefreshPendingRef = useRef(false); + const [bodyHeight, setBodyHeight] = useState(420); + + const beginRefreshProgress = useCallback((title) => { + refreshProgressGenerationRef.current += 1; + const generation = refreshProgressGenerationRef.current; + setRefreshProgress({ open: true, elapsedSeconds: 0, title, generation }); + return generation; + }, []); + + const endRefreshProgress = useCallback((generation) => { + if (generation !== refreshProgressGenerationRef.current) return; + setRefreshProgress((current) => ({ ...current, open: false })); + }, []); + + useEffect(() => { + if (!refreshProgress.open) return undefined; + const startedAt = Date.now(); + const timer = window.setInterval(() => { + setRefreshProgress((current) => ({ + ...current, + elapsedSeconds: Math.max(0, Math.floor((Date.now() - startedAt) / 1000)), + })); + }, 1000); + return () => window.clearInterval(timer); + }, [refreshProgress.open]); + + useEffect(() => { + const el = tableWrapRef.current; + if (!el) return undefined; + const RESERVE_BELOW = 104; + const measure = () => { + const scroller = el.closest('.planning-subsystem-content'); + const elRect = el.getBoundingClientRect(); + let available; + if (scroller) { + const scrollerRect = scroller.getBoundingClientRect(); + available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop; + } else { + available = window.innerHeight - elRect.top - RESERVE_BELOW; + } + const next = Math.max(240, Math.round(available)); + setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + if (el.parentElement) ro.observe(el.parentElement); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + const query = useMemo(() => { + const params = { page, pageSize, sortField: sorter.field, sortOrder: sorter.order }; + Object.entries(filters).forEach(([key, value]) => { if (value) params[key] = value; }); + return params; + }, [page, pageSize, sorter, filters]); + + const load = useCallback(async ({ background = false } = {}) => { + const requestGeneration = listRequestGateRef.current.begin(); + let showProgress = false; + let progressGeneration = null; + if (!background) setLoading(true); + if (!background) setError(''); + try { + const now = Date.now(); + const forceFresh = !lastListRefreshAtRef.current + || now - lastListRefreshAtRef.current >= AUTO_REFRESH_MS; + showProgress = forceFresh && !background; + if (showProgress) progressGeneration = beginRefreshProgress('正在更新最新业务数据'); + // 首次打开/F5 和定时轮询才重读云库;筛选、分页只复用刚刷新的内存索引。 + // 后端仍会合并多用户的并发刷新,不会对业务云库造成重复全量查询。 + const result = await getMkhBomList({ + ...query, + live: 1, + ...(forceFresh ? { fresh: 1 } : null), + _refresh: now, + }); + if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; + if (!result || result.success === false) { + setError(result?.error || '加载 MKH-BOM 资料失败'); + return; + } + const snapshot = result?.data || null; + try { + requireMkhFreshContract(snapshot, { requireApplied: forceFresh }); + } catch (contractError) { + setData({ rows: [], total: 0, page: 1, pageSize: 50, bomReady: false }); + throw contractError; + } + setData(snapshot); + if (forceFresh) lastListRefreshAtRef.current = Date.now(); + setError(''); + } catch (requestError) { + if (!listRequestGateRef.current.isCurrent(requestGeneration)) return; + setError(requestError?.response?.data?.error || requestError?.message || '加载 MKH-BOM 资料失败'); + } finally { + if (!background && listRequestGateRef.current.isCurrent(requestGeneration)) setLoading(false); + if (showProgress) endRefreshProgress(progressGeneration); + } + }, [beginRefreshProgress, endRefreshProgress, query]); + + useEffect(() => { load(); }, [load]); + + // 页面保持打开时自动更新;从后台切回且数据已超过轮询周期时立即重读。 + // 后台刷新保留当前表格,不显示全页加载态;请求门仍会阻止旧响应覆盖新数据。 + useEffect(() => { + const refreshIfStale = () => { + if (document.visibilityState !== 'visible' || syncing || autoRefreshPendingRef.current) return; + if (Date.now() - lastListRefreshAtRef.current < AUTO_REFRESH_MS) return; + autoRefreshPendingRef.current = true; + load({ background: true }).finally(() => { + autoRefreshPendingRef.current = false; + }); + }; + + const timer = window.setInterval(refreshIfStale, AUTO_REFRESH_MS); + document.addEventListener('visibilitychange', refreshIfStale); + return () => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', refreshIfStale); + }; + }, [load, syncing]); + + const changeFilter = (key) => (value) => { + setFilters((old) => ({ ...old, [key]: value || '' })); + setPage(1); + // 点击「仅预警 / 有日销 / 加工在途 / 全部」后自动滚动到下方表格明细 + if (key === 'scope') { + setTimeout(() => { + tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + } + }; + const resetFilters = () => { setFilters(EMPTY_FILTERS); setPage(1); }; + + // ── 导出 ──────────────────────────────────────────────────── + // 与列表共用同一套筛选/排序口径,但走不分页接口(导出当前筛选条件下的「全部」行)。 + const handleExport = async (menuKey) => { + const [mode, format] = String(menuKey || '').split('-'); + if (!mode || !format) return; + + setExporting(true); + try { + const result = await getMkhBomExportRows({ + mode, + keyword: filters.keyword, + scope: filters.scope, + sortField: sorter.field, + sortOrder: sorter.order, + }); + if (!result || result.success === false) { + message.error(result?.error || '导出失败'); + return; + } + + const payload = result.data || {}; + const rows = payload.rows || []; + if (!rows.length) { + message.warning('当前筛选条件下没有可导出的数据'); + return; + } + + const isComponents = mode === 'components'; + const scopeLabel = EXPORT_SCOPE_LABEL[payload.scope] || '全部'; + const stamp = payload.monitorDate || new Date().toISOString().slice(0, 10); + + await exportTableFile( + format, + `${isComponents ? 'MKH配件明细' : 'MKH商品清单'}_${scopeLabel}_${stamp}`, + isComponents ? COMPONENT_EXPORT_COLUMNS : PRODUCT_EXPORT_COLUMNS, + rows, + isComponents ? '配件明细' : '商品清单' + ); + message.success(`已导出 ${rows.length} 行(${format.toUpperCase()})`); + } catch (exportError) { + message.error(exportError?.response?.data?.error || exportError?.message || '导出失败'); + } finally { + setExporting(false); + } + }; + + // ── 补货预警推送 ──────────────────────────────────────────── + const applyAlertConfig = (config = {}) => { + setAlertConfig(config); + setAlertForm({ + enabled: config.enabled !== false, + recipientAlias: config.recipientAlias || '', + scope: config.scope || 'warning', + }); + setCenterForm({ + baseUrl: config.notificationCenter?.baseUrl || '', + projectCode: config.notificationCenter?.projectCode || '', + projectSecret: '', + }); + }; + + const saveCenterConfig = async () => { + setCenterSaving(true); + try { + const result = await saveMkhNotificationCenter(centerForm); + if (!result || result.success === false) { + message.error(result?.error || '保存推送通道失败'); + return; + } + applyAlertConfig(result.data || {}); + message.success('推送通道配置已保存'); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '保存推送通道失败'); + } finally { + setCenterSaving(false); + } + }; + + const openAlertSettings = async () => { + setAlertOpen(true); + setAlertResult(null); + setAlertLoading(true); + try { + const result = await getMkhReplenishAlertConfig(); + if (!result || result.success === false) { + message.error(result?.error || '加载推送设置失败'); + return; + } + applyAlertConfig(result.data || {}); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '加载推送设置失败'); + } finally { + setAlertLoading(false); + } + }; + + const saveAlertSettings = async () => { + setAlertBusy(true); + try { + const result = await saveMkhReplenishAlertConfig(alertForm); + if (!result || result.success === false) { + message.error(result?.error || '保存推送设置失败'); + return; + } + applyAlertConfig(result.data || {}); + message.success('推送设置已保存'); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '保存推送设置失败'); + } finally { + setAlertBusy(false); + } + }; + + const runAlertCheck = async (payload, doneText) => { + setAlertBusy(true); + setAlertResult(null); + try { + const result = await checkMkhReplenishAlert(payload); + if (!result || result.success === false) { + message.error(result?.error || '执行推送失败'); + return; + } + const outcome = result.data || {}; + setAlertResult(outcome); + if (outcome.sent) { + message.success(`${doneText || '已推送'}:新增 ${outcome.newCount} 个预警 → ${outcome.recipient}`); + } else if (outcome.reason === 'dry_run') { + message.info('已生成预览内容,未实际发送'); + } else { + message.warning(ALERT_REASON_TEXT[outcome.reason] || `未发送:${outcome.reason || '无新预警'}`); + } + const refreshed = await getMkhReplenishAlertConfig().catch(() => null); + if (refreshed?.success) applyAlertConfig(refreshed.data || {}); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '执行推送失败'); + } finally { + setAlertBusy(false); + } + }; + + const loadDetail = useCallback(async (skuId) => { + if (detailCache[skuId] || detailLoading[skuId]) return; + const detailGeneration = detailGenerationRef.current; + setDetailLoading((old) => ({ ...old, [skuId]: true })); + try { + const result = await getMkhBomDetail(skuId); + if (detailGeneration !== detailGenerationRef.current) return; + if (!result || result.success === false) { + message.error(result?.error || '加载配件明细失败'); + return; + } + setDetailCache((old) => ({ ...old, [skuId]: result?.data || { items: [] } })); + } catch (e) { + if (detailGeneration !== detailGenerationRef.current) return; + message.error(e?.response?.data?.error || e?.message || '加载配件明细失败'); + } finally { + if (detailGeneration === detailGenerationRef.current) { + setDetailLoading((old) => ({ ...old, [skuId]: false })); + } + } + }, [detailCache, detailLoading]); + + const handlePrint = async (skuId) => { + setPrintLoading(true); + try { + const result = await getMkhBomDetail(skuId); + if (!result || result.success === false) { + message.error(result?.error || '加载打印数据失败'); + return; + } + setPrintData(result?.data || { skuId, items: [] }); + } catch (e) { + message.error(e?.response?.data?.error || e?.message || '加载打印数据失败'); + } finally { + setPrintLoading(false); + } + }; + + const columns = useMemo(() => [ + { + title: 'MKH商品编码', + dataIndex: 'skuId', + width: 230, + fixed: 'left', + sorter: true, + render: (value) => {text(value)}, + }, + { + title: '商品名称', + dataIndex: 'productName', + ellipsis: true, + render: (value) => {text(value)}, + }, + { + title: '预警', + dataIndex: 'warningLevel', + width: 140, + align: 'center', + render: (value, row) => ( + + {text(value)} + {/* 配件不足(后端文件头 ⑪):可组装数 = 0,补不出来 —— 只加标记,不改变预警级别 */} + {row.componentShortage ? ( + + 配件不足 + + ) : null} + + ), + }, + { + title: '近15天日销', + dataIndex: 'dailySale', + width: 106, + align: 'right', + render: (value, row) => { + const text = Number(value) > 0 ? number(value, 2) : 0; + // 日销兜底(见服务文件头 ⑦-a):上游快照漏算时按订单表实算。 + // 必须标出来 —— 否则业务拿这个数字跟聚水潭里的 fifteenAvgSale 对照会对不上。 + if (!String(row.dailySaleSource || '').startsWith('orders')) return text; + const liveMode = row.dailySaleSource === 'orders-live'; + return ( + + {text} + + ); + }, + }, + { + title: '现有库存', + dataIndex: 'stock', + width: 100, + align: 'right', + render: (value, row) => ( + + {number(value)} + + ), + }, + { + title: '在途加工量', + dataIndex: 'pendingWorkOrderQty', + width: 116, + align: 'right', + render: (value, row) => (row.hasPendingWorkOrder ? ( + + {number(value)} + + ) : —), + }, + { + title: '待发货订单', + dataIndex: 'pendingOrderQty', + width: 116, + align: 'right', + render: (value, row) => (Number(value) > 0 ? ( + + {number(value)} + + ) : —), + }, + { + // 结余库存(2026-09-18 新增口径):现有库存先扣掉待发货订单,才是可自由支配的库存, + // 补货预警 / 可用天数一律按它算(待发订单为 0 时 ≡ 现有库存) + title: '结余库存', + dataIndex: 'availableStock', + width: 100, + align: 'right', + render: (value, row) => ( + 0 + ? `结余库存 = 现有库存 ${number(row.stock)} − 待发货订单 ${number(row.pendingOrderQty)}` + : '无待发货订单,结余库存 = 现有库存'} + > + {number(value)} + + ), + }, + { + title: '可用天数', + dataIndex: 'stockDays', + width: 106, + align: 'right', + sorter: true, + render: (value, row) => { + if (value === null || value === undefined) { + // 真正算不出天数:无日销且无代发订单 → 无销量;订单驱动分支现已如实给出天数,不会进这里 + return 无销量; + } + const text = {number(value, 2)}; + if (row.orderDriven) { + // 近 15 天无日销,可用天数按「库存 ÷ 代发订单件数」估算,≤ 3 天即预警 + return ( + + {text} + + ); + } + return ( + 0 + ? `可用天数 = 结余库存 ${number(row.availableStock)} ÷ 近15天日销 ${number(row.dailySale, 2)}(结余库存已扣待发货订单)` + : `可用天数 = 现有库存 ${number(row.stock)} ÷ 近15天日销 ${number(row.dailySale, 2)}` + }> + {text} + + ); + }, + }, + { + title: '建议补货量', + dataIndex: 'replenishQty', + width: 130, + align: 'right', + render: (value, row) => { + // 配件不足(后端文件头 ⑪):凑不出一台,不给补货目标,避免业务照着补不到的量去采购 + if (row.componentShortage) { + return ( + + — + + ); + } + return row.hasWarning && Number(value) > 0 + ? {number(value)} + : —; + }, + }, + { + title: 'BOM 状态', + dataIndex: 'bomConfigured', + width: 100, + align: 'center', + render: (value, row) => (row.bomConfigured === '已配置' + ? 已配置 + : 未配置), + }, + { + title: '配件种类', + dataIndex: 'accessoryCount', + width: 96, + align: 'right', + render: (value) => number(value), + }, + { + title: '配件总数量', + dataIndex: 'accessoryQtySum', + width: 110, + align: 'right', + render: (value) => number(value), + }, + { + title: '操作', + key: 'action', + width: 200, + fixed: 'right', + render: (_value, row) => ( + + + + + ), + }, + ], [expandedKeys, printLoading, loadDetail]); + + const expandedRowRender = (row) => { + const detail = detailCache[row.skuId]; + if (detailLoading[row.skuId] && !detail) { + return
加载配件明细…
; + } + const items = detail?.items || []; + return ( +
+ {items.length === 0 ? ( +
+ + 该 MKH 商品在聚水潭商品 BOM、业务组合 BOM 和「产品资料系统数据库」中均暂无配件明细。 + 上游维护好该商品的正式 BOM 后,此处会自动显示配件明细、数量与仓位。 + +
+ ) : null} + ( + + {text(v)} + {r.skuId ? {r.skuId} : null} + + ), + }, + { title: '数量', dataIndex: 'qty', width: 90, align: 'right', render: (v) => number(v) }, + { + title: '配件仓位', + dataIndex: 'location', + width: 140, + render: (v) => (v ? text(v) : ( + + 未维护 + + )), + }, + ]} + /> + + ); + }; + + // 手动同步:服务端清缓存后在同一次请求中直连副本库返回新快照。 + // 请求代际会阻止同步前尚未完成的旧请求覆盖这个新快照。 + const handleSync = async () => { + setSyncing(true); + setLoading(true); + const progressGeneration = beginRefreshProgress('正在强制同步全部最新数据'); + // 立即废弃同步前发出的列表/明细请求;它们即使稍后返回,也不能覆盖新快照。 + listRequestGateRef.current.invalidate(); + detailGenerationRef.current += 1; + setDetailCache({}); + setDetailLoading({}); + setExpandedKeys({}); + const syncGeneration = listRequestGateRef.current.begin(); + try { + const result = await syncMkhBom(query); + if (!listRequestGateRef.current.isCurrent(syncGeneration)) return; + if (!result || result.success === false || !result?.data?.snapshot) { + throw new Error(result?.error || '同步接口未返回数据库快照'); + } + setData(requireMkhFreshContract(result.data.snapshot, { requireApplied: true })); + lastListRefreshAtRef.current = Date.now(); + setError(''); + message.success('已实时读取最新订单、库存、BOM 与仓位数据'); + } catch (syncError) { + if (listRequestGateRef.current.isCurrent(syncGeneration)) { + setError(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } + message.error(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } finally { + if (listRequestGateRef.current.isCurrent(syncGeneration)) setLoading(false); + setSyncing(false); + endRefreshProgress(progressGeneration); + } + }; + + const progressView = getRefreshProgress(refreshProgress.elapsedSeconds); + + return ( + + + + + {progressView.stage} + 已用时 {refreshProgress.elapsedSeconds} 秒,完成后页面会自动显示新数据。 + 进度为查询阶段估算,实际完成以服务端返回为准。 + + +
+
+ MKH-BOM 资料 + + 模块化(MKH 开头)成品的配件明细与补货预警:近 15 天日销核算,库存低于 3 天日销即预警 + + + 数据口径:MKH 商品编码取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”,并且必须是聚水潭正式 BOM 的父项; + 配件明细与数量优先取自聚水潭商品 BOM,业务中台组合商品 BOM 与产品资料系统数据库中维护的配件作为补充; + 无指定仓库商品档案的 BOM 组合编码不会进入列表;配件仓位只取 rpt.jst_packInventoryInfo, + 优先显示具体仓位,没有具体仓位时显示箱号,表中没有有效记录时显示「未维护」。 +
+ 补货预警:近15天日销按已出库订单实时计算,现有库存取品牌仓当前库存,并用箱/货位库存补充当日入库; + 实时查询不回退延迟监控快照。结余库存 = 现有库存 − 待发货订单(已下单未发货的部分已被占用,不足 0 取 0), + 可用天数 = 结余库存 ÷ 近15天日销,低于 3 天即预警,结余库存为 0 判缺货; + 建议补货量 = 3 × 近15天日销 + 待发货订单 − 现有库存(向上取整,先补到能把订单发掉再补 3 天缓冲)。 + 无日销且无订单的商品不参与预警。 +
+ 待发货订单(代发):近15天暂无日销、但聚水潭存在「未发货订单」的 MKH 成品同样列入补货预警 + (新品刚接到订单时监控快照的日销还是 0,避免漏备货)。未发货 = 订单无发货时间且状态非「取消 / 已合并」; + 可用天数 = 现有库存 ÷ 代发(待发货)订单件数,< 3 天预警、= 0(无库存)缺货、≥ 3 天不预警; + 建议补货量:缺货时按「代发件数 − 现有库存」、预警时按「3 × 代发件数 − 现有库存」补到 3 天缓冲。 +
+ 在途加工单:「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」中已存在且未完成的加工单已纳入核算 + (作废单据不核算);只要该 MKH 商品存在未完成加工单,即视为已排产、不再列入补货预警。 +
+ 配件不足:缺货 / 预警的成品若配件「可组装数 = 0」则无法补货,标记「配件不足」、建议补货量显示「—」。 + 可组装数 = 各配件 ⌊(现有库存 − 未完成加工单占用) ÷ BOM 单台用量⌋ 取最小值(木桶短板决定), + 配件库存取「{data.componentStockWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」的现有库存,不含采购在途。 +
+
+
+ + + + } + placeholder="MKH商品编码 / 名称" + value={filters.keyword} + onChange={(e) => changeFilter('keyword')(e.target.value)} + style={{ width: 260 }} + /> + + + + + + + handleExport(key) }} + > + + + + + + + {error ? 重试} /> : null} + + {!loading && !error && data.workOrderDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && !error && data.pendingOrderDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && !error && data.componentStockDegraded ? ( + 刷新数据} + /> + ) : null} + + {!loading && (data.warningCount || 0) > 0 ? ( + + 按近 {data.saleWindowDays || 15} 天已出库订单实时核算: + 其中 {data.outOfStockCount || 0} 个已缺货, + {Math.max(0, (data.warningCount || 0) - (data.outOfStockCount || 0))} 个可用天数不足 {data.replenishDaysThreshold || 3} 天。 + 建议补货量 = {data.replenishDaysThreshold || 3} × 近15天日销 + 待发货订单 − 现有库存(库存已先扣待发货订单后再核算)。点「只看预警」即为补货清单。 + {(data.orderDrivenCount || 0) > 0 ? ( + <> +
+ 其中 {data.orderDrivenCount} 个近 15 天暂无日销,按代发(待发货)订单列入预警 + (待发货合计 {number(data.pendingOrderQtyTotal || 0)} 件),可用天数 = 库存 ÷ 代发件数、≤ 3 天即预警。 + + ) : null} + {(data.scheduledCount || 0) > 0 ? ( + <> +
+ 另有 {data.scheduledCount} 个商品在「{data.workOrderWarehouse || '简尚-崔桥工厂半成品包装项目仓'}」已有未完成加工单 + (在途合计 {number(data.pendingWorkOrderQtyTotal || 0)} 件),已排除在预警之外。 + + ) : null} + {(data.componentShortageCount || 0) > 0 ? ( + <> +
+ 其中 {data.componentShortageCount} 个商品配件不足 + :现有配件最多只能组装 0 台(已扣除未完成加工单占用的配件),补不出来, + 建议补货量显示「—」,需先采购配件。 + + ) : null} + + )} + action={( + + )} + /> + ) : null} + + {!loading && (data.total || 0) > 0 ? ( + 0 ? 'info' : 'warning'} + showIcon + message={data.bomRowCount > 0 + ? `已接入 ${data.bomRowCount} 个模块化商品的配件 BOM` + : '聚水潭商品 BOM 及补充来源中尚无可用的模块化商品配件明细'} + description={( + + MKH 商品编码({data.total || 0} 条)取自业务库 jst_sku 中“简尚-崔桥工厂品牌仓”的正式 BOM 父项; + 配件明细优先取自聚水潭商品 BOM({data.bomOfficialRowCount || 0} 个), + 业务组合 BOM({data.bomBusinessRowCount || 0} 个)与产品资料系统({data.bomProductDbRowCount || 0} 个)作为补充。 + BOM 中没有指定仓库商品档案的组合编码不会加入商品列表。 + 上游补充正式 BOM 后,本页「BOM 状态 / 配件种类 / 配件总数量」与展开明细会自动更新。 + + )} + /> + ) : null} + + MKH 模块化成品({data.total || 0} 条)} + > + +
+
expandedKeys[k]), + expandedRowRender, + onExpand: (expanded, record) => { + setExpandedKeys((old) => ({ ...old, [record.skuId]: expanded })); + if (expanded) loadDetail(record.skuId); + }, + }} + onChange={(pagination, _filter, nextSorter) => { + setPage(pagination.current || 1); + setPageSize(pagination.pageSize || 50); + if (nextSorter?.field) { + setSorter({ + field: nextSorter.field, + order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC', + }); + } + }} + pagination={{ + current: data.page || page, + pageSize: data.pageSize || pageSize, + total: data.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 1866, y: bodyHeight }} + /> + + + + + {printData ? setPrintData(null)} /> : null} + + setAlertOpen(false)} + width={680} + destroyOnHidden + styles={{ body: { maxHeight: '62vh', overflowY: 'auto', paddingRight: 12 } }} + footer={( + + + + + + + )} + > + + + +
+
+ 开启推送 + setAlertForm((old) => ({ ...old, enabled: checked }))} + /> + 关闭后自动任务不再发送,本页仍可手动推送 +
+ +
+ 收件人 + setAlertForm((old) => ({ ...old, recipientAlias: e.target.value }))} + style={{ width: 280 }} + /> +
+ +
+ 推送范围 + setCenterForm((old) => ({ ...old, baseUrl: e.target.value }))} + placeholder="https://sc.jiansun.vip/notification-center" + style={{ width: 380 }} + /> +
+
+ 项目代码 + setCenterForm((old) => ({ ...old, projectCode: e.target.value }))} + style={{ width: 280 }} + /> +
+
+ 项目密钥 + setCenterForm((old) => ({ ...old, projectSecret: e.target.value }))} + placeholder={alertConfig?.notificationCenter?.projectSecretConfigured + ? `已配置(${alertConfig.notificationCenter.projectSecretPreview}),留空则不修改` + : '未配置'} + style={{ width: 280 }} + /> +
+
+ +
+
+ ), + }]} + /> + + {alertResult ? ( + +
+ 当前预警 {alertResult.summary?.totalCount ?? 0} 个 + (缺货 {alertResult.summary?.outOfStockCount ?? 0} | + {alertResult.summary?.replenishDaysThreshold || 3}天内 {alertResult.summary?.warningOnlyCount ?? 0}) + ,本次新增 {alertResult.summary?.freshCount ?? 0} 个,历史已推 {alertResult.summary?.persistedCount ?? 0} 个。 +
+ {alertResult.payload ? ( +
+
{alertResult.payload.title}
+
{alertResult.payload.content}
+ 收件人:{alertResult.payload.recipientAlias} +
+ ) : null} + + )} + /> + ) : null} + +
+ 「立即检查并推送」按线上口径执行(只推新出现的);「重推全部预警」会清空已推送记录后重新推送当前全部预警,用于首次验证。 +
+
+
+ + ); +}; + +export default MkhBomData; diff --git a/frontend/src/pages/MkhBomData/style.css b/frontend/src/pages/MkhBomData/style.css new file mode 100644 index 0000000..afb18b0 --- /dev/null +++ b/frontend/src/pages/MkhBomData/style.css @@ -0,0 +1,393 @@ +.mkh-page { + padding: 16px 20px 24px; +} + +.mkh-header { + margin-bottom: 14px; +} + +.mkh-header__heading .ant-typography { + margin-bottom: 4px; +} + +.mkh-note { + display: block; + max-width: 1180px; + margin-top: 6px; + font-size: 12px; + line-height: 1.7; +} + +.mkh-filter-card { + margin-bottom: 12px; +} + +.mkh-panel { + margin-top: 4px; +} + +.mkh-sku { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 12px; + color: var(--sycm-text-primary); +} + +.mkh-muted { + color: var(--sycm-text-tertiary); +} + +.mkh-detail-loading { + padding: 12px 8px; + color: var(--sycm-text-secondary); +} + +.mkh-detail { + padding: 4px 8px 8px; +} + +.mkh-detail-tip { + padding: 4px 0 8px; + font-size: 12px; +} + +.mkh-detail-code { + display: block; + margin-top: 2px; + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + font-size: 11px; + color: var(--sycm-text-tertiary); +} + +.mkh-tag { + display: inline-block; + padding: 0 6px; + border-radius: 3px; + font-size: 12px; + line-height: 18px; + white-space: nowrap; +} + +.mkh-tag--ok { + color: #0f6e56; + background: rgba(29, 158, 117, 0.14); +} + +.mkh-tag--muted { + color: var(--sycm-text-tertiary); + background: rgba(136, 135, 128, 0.14); +} + +.mkh-tag--src { + color: #0958d9; + background: rgba(22, 119, 255, 0.12); +} + +/* ============ 补货预警 ============ */ +.mkh-alert { + margin-bottom: 12px; +} + +/* 缺货 */ +.mkh-tag--danger { + color: #a8071a; + background: rgba(255, 77, 79, 0.14); +} + +/* 预警 */ +.mkh-tag--warn { + color: #ad4e00; + background: rgba(250, 173, 20, 0.2); +} + +/* 可用天数低于阈值 */ +.mkh-days--warn { + color: #cf1322; + font-weight: 600; +} + +/* 建议补货量 */ +.mkh-replenish { + color: #0958d9; + font-weight: 600; +} + +/* 已排产:半成品包装仓已有未完成加工单,按业务口径不再预警 */ +.mkh-tag--scheduled { + color: #0958d9; + background: rgba(22, 119, 255, 0.14); +} + +/* 在途加工量(未完成加工单待入库数量) */ +.mkh-inbound { + color: #0958d9; + font-weight: 600; +} + +/* 待发货订单(聚水潭未发货订单件数,见文件头 ⑩) */ +.mkh-order { + color: #ad4e00; + font-weight: 600; +} + +/* 日销兜底(上游快照漏算时按订单表实算,见服务文件头 ⑦-a): + 虚线表示该值不是上游快照,悬停有说明,避免与源系统数字对照时误判为数据错误 */ +.mkh-sale-fallback { + border-bottom: 1px dashed currentColor; + cursor: help; +} + +/* 配件不足(可组装数 = 0,补不出来,见文件头 ⑪):与「缺货」红标签并排,用紫灰区分 */ +.mkh-tag--shortage { + color: #722ed1; + background: rgba(114, 46, 209, 0.14); + border-color: rgba(114, 46, 209, 0.35); +} + +/* ============ A4 打印预览 ============ */ +.mkh-print-mask { + position: fixed; + inset: 0; + z-index: 2000; + overflow: auto; + background: rgba(0, 0, 0, 0.55); + padding: 24px 0 40px; +} + +.mkh-print-toolbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + justify-content: center; + padding: 12px; + margin-bottom: 16px; +} + +.mkh-print-sheet { + width: 210mm; + min-height: 297mm; + margin: 0 auto; + padding: 14mm 12mm; + background: #fff; + color: #000; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35); + box-sizing: border-box; + font-family: -apple-system, 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif; + font-size: 12px; + line-height: 1.5; +} + +.mkh-print-head { + text-align: center; + border-bottom: 2px solid #000; + padding-bottom: 10px; + margin-bottom: 12px; +} + +.mkh-print-title { + font-size: 22px; + font-weight: 700; + letter-spacing: 2px; +} + +.mkh-print-sub { + margin-top: 4px; + font-size: 12px; + color: #444; +} + +.mkh-print-meta { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 24px; + margin-bottom: 12px; + font-size: 13px; +} + +.mkh-print-meta > div { + display: flex; +} + +.mkh-print-meta span { + color: #555; + min-width: 78px; +} + +.mkh-print-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} + +.mkh-print-table th, +.mkh-print-table td { + border: 1px solid #000; + padding: 6px 8px; + text-align: left; + word-break: break-all; +} + +.mkh-print-table thead th { + background: #f2f2f2; + font-weight: 700; +} + +.mkh-print-table .mkh-center { + text-align: center; +} + +.mkh-print-table .mkh-muted { + color: #999; +} + +.mkh-print-table .mkh-print-sku { + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + /* 2026-09-18 业务要求调大:原来是 11px,车间核对编码时太小 */ + font-size: 14px; + font-weight: 700; + letter-spacing: 0.3px; + vertical-align: middle; +} + +.mkh-print-code { + display: block; + margin-top: 2px; + font-family: 'SFMono-Regular', Consolas, Menlo, monospace; + /* 2026-09-18 业务要求调大:原来是 10px / #666,扫码与人工核对都偏小偏淡 */ + font-size: 12px; + font-weight: 600; + color: #222; +} + +.mkh-print-foot { + margin-top: 16px; + padding-top: 8px; + border-top: 1px dashed #999; + font-size: 11px; + color: #666; +} + +/* 打印时只输出 A4 纸张,隐藏其余界面 */ +@media print { + body * { + visibility: hidden !important; + } + + .mkh-print-sheet, + .mkh-print-sheet * { + visibility: visible !important; + } + + .mkh-print-toolbar { + display: none !important; + } + + .mkh-print-mask { + position: absolute !important; + inset: 0 !important; + background: #fff !important; + overflow: visible !important; + padding: 0 !important; + } + + .mkh-print-sheet { + width: 210mm; + min-height: auto; + margin: 0 !important; + padding: 0 !important; + box-shadow: none !important; + } + + @page { + size: A4; + margin: 12mm; + } +} + +/* ── 补货预警推送设置弹窗 ──────────────────────────── */ +.mkh-alert-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.mkh-alert-row { + display: flex; + align-items: center; + gap: 10px; +} + +.mkh-alert-label { + flex: none; + width: 68px; + color: var(--sycm-text-secondary); +} + +.mkh-alert-meta { + margin-top: 14px; + padding: 10px 12px; + border: 1px solid var(--sycm-border-color); + border-radius: 8px; + background: var(--sycm-bg-subtle); + font-size: 12px; + line-height: 2; + color: var(--sycm-text-secondary); +} + +.mkh-alert-result { + margin-top: 14px; +} + +.mkh-alert-result-body { + font-size: 12px; + line-height: 1.8; +} + +.mkh-alert-preview { + margin-top: 8px; + padding: 10px 12px; + border: 1px solid var(--sycm-border-color); + border-radius: 8px; + background: var(--sycm-bg-container); +} + +.mkh-alert-preview-title { + margin-bottom: 6px; + font-weight: 600; + color: var(--sycm-text-strong); +} + +.mkh-alert-preview pre { + margin: 0 0 6px; + font-family: inherit; + font-size: 12px; + line-height: 1.8; + white-space: pre-wrap; + word-break: break-word; + color: var(--sycm-text-primary); +} + +.mkh-alert-hint { + margin-top: 10px; + font-size: 12px; + line-height: 1.7; + color: var(--sycm-text-tertiary); +} + +/* 推送通道(统一通知中心)折叠区 */ +.mkh-alert-center { + margin-top: 6px; +} + +.mkh-alert-center .ant-collapse-header { + padding: 8px 0 !important; +} + +.mkh-alert-center .ant-collapse-content-box { + padding: 0 0 4px !important; +} + +.mkh-alert-form--inner { + margin-top: 4px; +} diff --git a/frontend/src/pages/ModularStockMonitor/index.jsx b/frontend/src/pages/ModularStockMonitor/index.jsx new file mode 100644 index 0000000..dd3c9a2 --- /dev/null +++ b/frontend/src/pages/ModularStockMonitor/index.jsx @@ -0,0 +1,614 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, Button, Card, Col, Dropdown, Image, Input, Modal, Row, Select, + Space, Spin, Statistic, Switch, Table, Tabs, Tag, Tooltip, Typography, message, +} from 'antd'; +import { DownloadOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons'; +import { SycmPage } from '../../components/Common'; +import { getModularStockList, getModularStockFilters, getPurchaseInTransitList, syncModularStock } from '../../api/modularStockMonitor'; +import { exportTableFile } from '../../utils/tableExport'; +import './style.css'; + +const { Title, Text } = Typography; + +const WARNING_COLOR = { 缺货: 'red', 预警: 'orange', 正常: 'green' }; + +// 表头带口径说明,避免把「真实值 0」误读成「没数据」 +const TipTitle = ({ title, tip }) => ( + + {title} + +); + +const number = (value, digits = 2) => value === null || value === undefined || value === '' + ? '--' : Number(value).toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits }); +const int = (value) => number(value, 0); +const text = (value) => (value === null || value === undefined || value === '' ? '--' : value); + +const toOptions = (list = []) => (Array.isArray(list) ? list : []) + .filter(Boolean) + .map((value) => ({ value, label: value })); + +const EMPTY_FILTERS = { keyword: '', category: '', warningLevel: '', mkhStatus: '' }; + +const AccessoryStockPanel = () => { + const [filters, setFilters] = useState(EMPTY_FILTERS); + const [onlyWarning, setOnlyWarning] = useState(false); + const [filterOptions, setFilterOptions] = useState({ categories: [], warningLevels: [] }); + const [data, setData] = useState({ rows: [], total: 0, page: 1, pageSize: 50, summary: {}, monitorDate: null }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [syncing, setSyncing] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [sorter, setSorter] = useState({ field: 'realtimeStock', order: 'DESC' }); + const tableWrapRef = useRef(null); + const [bodyHeight, setBodyHeight] = useState(420); + + // 采购在途明细弹窗状态 + const [transitVisible, setTransitVisible] = useState(false); + const [transitData, setTransitData] = useState({ rows: [], total: 0 }); + const [transitLoading, setTransitLoading] = useState(false); + const [transitPage, setTransitPage] = useState(1); + const [transitPageSize, setTransitPageSize] = useState(50); + const [transitKeyword, setTransitKeyword] = useState(''); + const [transitSorter, setTransitSorter] = useState({ field: 'transitQty', order: 'DESC' }); + + // 表体高度按「表格顶部 → 内容区底部」实测计算,替代纯 CSS calc: + // 不同显示器缩放(125%/150%)下 100vh 偏小,写死的 calc 会把表格压到只剩一两行 + useEffect(() => { + const el = tableWrapRef.current; + if (!el) return undefined; + + // 分页器 + 卡片内边距 + 横向滚动条余量 + const RESERVE_BELOW = 104; + + const measure = () => { + const scroller = el.closest('.planning-subsystem-content'); + const elRect = el.getBoundingClientRect(); + let available; + if (scroller) { + const scrollerRect = scroller.getBoundingClientRect(); + // 加回 scrollTop,抵消滚动对 getBoundingClientRect 的影响 + available = scrollerRect.bottom - elRect.top - RESERVE_BELOW + scroller.scrollTop; + } else { + available = window.innerHeight - elRect.top - RESERVE_BELOW; + } + const next = Math.max(240, Math.round(available)); + setBodyHeight((prev) => (Math.abs(prev - next) > 1 ? next : prev)); + }; + + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + if (el.parentElement) ro.observe(el.parentElement); + window.addEventListener('resize', measure); + return () => { + ro.disconnect(); + window.removeEventListener('resize', measure); + }; + }, []); + + useEffect(() => { + let cancelled = false; + getModularStockFilters() + .then((result) => { + const payload = result?.data || {}; + if (cancelled) return; + setFilterOptions({ + categories: payload.categories || [], + warningLevels: payload.warningLevels || ['缺货', '预警', '正常'], + mkhStatuses: payload.mkhStatuses || ['已配置MKH', '未配置MKH'], + }); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, []); + + const query = useMemo(() => { + const params = { + page, + pageSize, + sortField: sorter.field, + sortOrder: sorter.order, + }; + Object.entries(filters).forEach(([key, value]) => { + if (value) params[key] = value; + }); + if (onlyWarning) params.onlyWarning = '1'; + return params; + }, [page, pageSize, sorter, filters, onlyWarning]); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await getModularStockList(query); + setData(result?.data || { rows: [], total: 0, summary: {} }); + } catch (requestError) { + setError(requestError?.response?.data?.error || requestError?.message || '加载配件库存监控数据失败'); + } finally { + setLoading(false); + } + }, [query]); + + useEffect(() => { load(); }, [load]); + + const changeFilter = (key) => (value) => { + setFilters((old) => ({ ...old, [key]: value || '' })); + setPage(1); + }; + const resetFilters = () => { + setFilters(EMPTY_FILTERS); + setOnlyWarning(false); + setPage(1); + }; + + // 点击顶部统计卡片 → 自动设置对应筛选条件并滚动到表格明细 + const applySummaryFilter = (type) => () => { + const next = { ...EMPTY_FILTERS }; + let warning = false; + if (type === 'outOfStock') { + next.warningLevel = '缺货'; + warning = true; + } else if (type === 'lowStockDays') { + next.warningLevel = '预警'; + warning = true; + } else if (type === 'configured') { + next.mkhStatus = '已配置MKH'; + } else if (type === 'unconfigured') { + next.mkhStatus = '未配置MKH'; + } + setFilters(next); + setOnlyWarning(warning); + setPage(1); + setTimeout(() => { + tableWrapRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 0); + }; + + // 打开采购在途明细弹窗 + const openPurchaseTransit = () => { + setTransitVisible(true); + setTransitPage(1); + setTransitKeyword(''); + setTransitSorter({ field: 'transitQty', order: 'DESC' }); + loadPurchaseTransit(1, '', { field: 'transitQty', order: 'DESC' }); + }; + + const loadPurchaseTransit = useCallback(async (p = transitPage, kw = transitKeyword, sort = transitSorter) => { + setTransitLoading(true); + try { + const result = await getPurchaseInTransitList({ + page: p, + pageSize: transitPageSize, + keyword: kw, + sortField: sort.field, + sortOrder: sort.order, + }); + setTransitData(result?.data || { rows: [], total: 0 }); + } catch (err) { + message.error(err?.response?.data?.error || err?.message || '加载采购在途明细失败'); + } finally { + setTransitLoading(false); + } + }, [transitPage, transitPageSize, transitKeyword, transitSorter]); + + const columns = useMemo(() => [ + { + title: '图片', + dataIndex: 'imageUrl', + width: 56, + fixed: 'left', + exportable: false, + render: (value) => (value + ? + : --), + }, + { + title: '预警', + dataIndex: 'warningLevel', + width: 76, + sorter: true, + render: (value) => (value + ? {value} + : --), + }, + { + title: , + dataIndex: 'mkhStatus', + width: 110, + sorter: true, + render: (value) => { + const v = value || '未配置MKH'; + return v === '已配置MKH' + ? {v} + : {v}; + }, + }, + { + title: '商品编码', + dataIndex: 'skuId', + width: 176, + fixed: 'left', + sorter: true, + render: (value) => {text(value)}, + }, + { + // 必须显式给 width:antd 在 scroll.x 场景下会把无宽度列压成 0 宽(此前该列不可见的原因) + title: '商品名称', + dataIndex: 'productName', + width: 220, + ellipsis: true, + sorter: true, + render: (value) => {text(value)}, + }, + { + title: , + dataIndex: 'inQtyToday', + width: 116, + align: 'right', + render: (value) => (value === null || value === undefined + ? -- + : int(value)), + }, + { + title: , + dataIndex: 'outQtyToday', + width: 116, + align: 'right', + render: (value) => (value === null || value === undefined + ? -- + : int(value)), + }, + { + title: , + dataIndex: 'realtimeStock', + width: 96, + align: 'right', + sorter: true, + render: int, + }, + { + title: , + dataIndex: 'purchaseTransit', + width: 120, + align: 'right', + render: (value) => (value === null || value === undefined || Number(value) === 0 + ? -- + : int(value)), + }, + { + title: , + dataIndex: 'location', + width: 88, + sorter: true, + render: (value) => (value + ? {value} + : + 未维护 + ), + }, + { + title: '近15天日均发货量', + dataIndex: 'avgShip15d', + width: 138, + align: 'right', + sorter: true, + render: (value) => number(value), + }, + { + title: '库存可用天数', + dataIndex: 'stockDays', + width: 116, + align: 'right', + sorter: true, + render: (value) => number(value, 1), + }, + { + title: '所在仓库', + dataIndex: 'warehouse', + width: 200, + ellipsis: true, + sorter: true, + render: (value) => {text(value)}, + }, + ], []); + + const exportColumns = useMemo( + () => columns.filter((column) => column.exportable !== false), + [columns] + ); + + const handleExport = async (format) => { + if (!data.rows.length) { + message.warning('暂无数据可导出'); + return; + } + try { + await exportTableFile( + format, + `配件库存监控_${data.monitorDate || '最新'}`, + exportColumns, + data.rows, + '配件库存监控' + ); + message.success(`已导出 ${data.rows.length} 条数据(${format.toUpperCase()})`); + } catch (exportError) { + message.error(exportError?.message || '导出失败'); + } + }; + + // 手动同步:后端重新取最新监控日(清服务端缓存)后再拉一次列表, + // 拿到的是副本库当前最新快照;可随时点击,不受 24 点定时任务限制 + const handleSync = async () => { + setSyncing(true); + try { + await syncModularStock(); + await load(); + message.success('已同步最新数据'); + } catch (syncError) { + message.error(syncError?.response?.data?.error || syncError?.message || '同步失败'); + } finally { + setSyncing(false); + } + }; + + const summary = data.summary || {}; + + return ( + <> + + + } + placeholder="商品编码 / 名称" + value={filters.keyword} + onChange={(e) => changeFilter('keyword')(e.target.value)} + style={{ width: 220 }} + /> + +
{ + setPage(pagination.current || 1); + setPageSize(pagination.pageSize || 50); + if (nextSorter?.field) { + setSorter({ + field: nextSorter.field, + order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC', + }); + } + }} + pagination={{ + current: data.page || page, + pageSize: data.pageSize || pageSize, + total: data.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + scroll={{ x: 1508, y: bodyHeight }} + /> + + + + + + 采购在途明细 + + 共 {transitData.total || 0} 条采购单 / 在途 {summary.totalPurchaseTransit || 0} 件 + + + )} + open={transitVisible} + onCancel={() => setTransitVisible(false)} + width={960} + footer={null} + destroyOnHidden + > + + { setTransitKeyword(value); setTransitPage(1); loadPurchaseTransit(1, value, transitSorter); }} + style={{ width: 260 }} + /> +
`${row.skuId}-${row.poiId || row.poId || Math.random()}`} + size="small" + loading={transitLoading} + columns={[ + { + title: '商品编码', dataIndex: 'skuId', width: 176, render: (v) => {text(v)}, + }, + { + title: '商品名称', dataIndex: 'productName', width: 240, ellipsis: true, render: (v) => {text(v)}, + }, + { title: '采购数量', dataIndex: 'purchaseQty', width: 96, align: 'right', render: int, sorter: true }, + { title: '已入库', dataIndex: 'inQty', width: 84, align: 'right', render: int, sorter: true }, + { + title: '在途数量', dataIndex: 'transitQty', width: 96, align: 'right', sorter: true, + render: (value) => {int(value)}, + }, + { + title: '计划到货日期', dataIndex: 'deliveryDate', width: 120, align: 'center', sorter: true, + render: (value) => text(value), + }, + { title: '采购单号', dataIndex: 'poId', width: 116, render: text }, + ]} + dataSource={transitData.rows || []} + pagination={{ + current: transitPage, + pageSize: transitPageSize, + total: transitData.total || 0, + showSizeChanger: true, + pageSizeOptions: [50, 100, 200], + showTotal: (total) => `共 ${total} 条`, + }} + onChange={(pagination, _filters, nextSorter) => { + setTransitPage(pagination.current || 1); + setTransitPageSize(pagination.pageSize || 50); + const nextSort = nextSorter?.field + ? { field: nextSorter.field, order: nextSorter.order === 'ascend' ? 'ASC' : 'DESC' } + : { field: 'transitQty', order: 'DESC' }; + setTransitSorter(nextSort); + loadPurchaseTransit(pagination.current || 1, transitKeyword, nextSort); + }} + scroll={{ x: 860, y: 360 }} + /> + + + + ); +}; + +const ModularStockMonitor = () => ( + +
+
+ 模块化库存监控 + + 配件库存监控:仅统计「简尚-崔桥工厂半成品包装项目仓」下的 SKU 实时库存、近 15 天日均发货量与可用天数 + + + 数据口径:实时库存取 jst_inventoryInfo(wms_co_id 15718196);近 15 天日均发货量、 + 库存可用天数、安全天数取 pm_stockMonitor 最新快照(按完整 SKU 编码关联,794 个 SKU 全覆盖); + 当日入库取采购到货、当日出库取近一日发货量,均以监控日为基准; + 仓位取聚水潭「箱及仓位库存」(jst_packInventoryInfo)的真货位,同商品多箱时显示库存最大的主货位,查不到才显示「未维护」; + 图片取自「产品资料系统数据库」商品档案(业务库图片字段均为空)。 + 注:日均发货量 2026-09-17 起由近 7 天改为近 15 天(业务原要求 30 天,但业务库无 30 天日销,最长只到 15 天); + 库存可用天数仍沿用监控表 jsUseDays(源系统口径),故两者并非同一时间窗。 + 状态列:基于聚水潭 SKU 级 BOM 维护(rpt.jst_skuBomItem)判断该商品是否作为「配件商品编码」被某 MKH 成品 BOM 引用——已配置MKH = 出现在某 MKH 成品的 BOM 配件清单中;未配置MKH = 未出现。 + +
+
+ , + }]} + /> +
+); + +export default ModularStockMonitor; diff --git a/frontend/src/pages/ModularStockMonitor/style.css b/frontend/src/pages/ModularStockMonitor/style.css new file mode 100644 index 0000000..6068a4b --- /dev/null +++ b/frontend/src/pages/ModularStockMonitor/style.css @@ -0,0 +1,166 @@ +.msm-page { + container-name: modular-stock-monitor; + container-type: inline-size; + display: flex; + min-height: calc(100% - 46px); + flex-direction: column; + gap: var(--sycm-page-section-gap); + background: var(--sycm-bg-page-soft); +} + +.msm-header { + display: flex; + min-height: 84px; + align-items: center; + justify-content: space-between; + gap: var(--sycm-space-lg); + margin: calc(var(--sycm-page-padding-y) * -1) calc(var(--sycm-page-padding-x) * -1) 0; + padding: 14px calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.msm-header__heading { min-width: 0; } +.msm-header h3 { + margin: 0; + color: var(--sycm-text-strong); + font-size: var(--sycm-font-size-page); + font-weight: var(--sycm-font-weight-semibold); + line-height: var(--sycm-line-height-tight); +} +.msm-header__heading .ant-typography { + display: block; + margin-top: 6px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} + +.msm-tabs .ant-tabs-nav { + margin: 0; + padding: 0 calc(var(--sycm-page-padding-x) + 2px); + border-bottom: 1px solid var(--sycm-border-base); + background: var(--sycm-bg-container); +} +.msm-tabs .ant-tabs-tab { + margin: 0; + padding: 12px 4px; + font-size: var(--sycm-font-size-title); +} +.msm-tabs .ant-tabs-content-holder, +.msm-tabs .ant-tabs-content, +.msm-tabs .ant-tabs-tabpane { min-height: 100%; } +.msm-tabs .ant-tabs-tabpane { + display: flex; + flex-direction: column; + gap: var(--sycm-page-section-gap); + padding-top: var(--sycm-page-section-gap); +} + +.msm-filter-card, +.msm-panel, +.msm-summary-card { + border: 1px solid var(--sycm-border-card) !important; + border-radius: var(--sycm-radius-lg) !important; + box-shadow: var(--sycm-shadow-card) !important; +} +.msm-filter-card { margin-bottom: 0; } +.msm-filter-card .ant-card-body { padding: 14px 16px; } +.msm-filter-card .ant-space { width: 100%; } +.msm-filter-card .ant-space-item { max-width: 100%; } +.msm-filter-card .ant-select, +.msm-filter-card .ant-input-affix-wrapper { max-width: 100%; } + +.msm-summary-row { margin-top: -4px; } +.msm-summary-row .ant-col { display: flex; } +.msm-summary-card { + position: relative; + width: 100%; + min-height: 98px; + overflow: hidden; +} +.msm-summary-card::before { + position: absolute; + inset: 0 auto 0 0; + width: 3px; + background: var(--sycm-color-primary); + content: ''; +} +.msm-summary-card.is-red::before { background: var(--sycm-color-danger); } +.msm-summary-card.is-orange::before { background: var(--sycm-color-orange); } +.msm-summary-card.is-green::before { background: var(--sycm-color-success); } +.msm-summary-card.is-blue::before { background: var(--sycm-color-primary); } +.msm-summary-card .ant-card-body { + display: flex; + min-height: 98px; + align-items: center; + padding: 16px 18px; +} +.msm-summary-card .ant-statistic { width: 100%; } +.msm-summary-card .ant-statistic-title { + margin-bottom: 8px; + color: var(--sycm-text-secondary); + font-size: var(--sycm-font-size-caption); +} +.msm-summary-card .ant-statistic-content { + color: var(--sycm-text-strong); + font-family: var(--sycm-font-family-number); + font-size: var(--sycm-font-size-metric); + line-height: 1.1; +} +.msm-summary-card .ant-statistic-content-suffix { font-size: var(--sycm-font-size-caption); } + +.msm-summary-card.is-clickable { + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} +.msm-summary-card.is-clickable:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.msm-panel { margin-bottom: 0; overflow: hidden; } +.msm-panel .ant-card-head { + min-height: 48px; + padding: 0 16px; + border-bottom-color: var(--sycm-border-light); +} +.msm-panel .ant-card-head-title { font-size: var(--sycm-font-size-title); } +.msm-panel .ant-card-body { padding: 0 16px 16px; } +.msm-panel .ant-table { font-size: var(--sycm-font-size-caption); } +.msm-panel .ant-table-wrapper .ant-table-container { border-radius: var(--sycm-radius-md); } +.msm-panel .ant-table-placeholder .ant-table-cell { height: 180px; } +.msm-panel__date { font-size: var(--sycm-font-size-caption); } + +.msm-muted { color: var(--sycm-text-tertiary); } +/* 表头口径提示:虚线下划线 + help 光标 */ +.msm-th-tip { + border-bottom: 1px dashed var(--sycm-border-strong, var(--sycm-text-tertiary)); + cursor: help; +} +.msm-note { + display: block; + max-width: 1180px; + margin-top: 4px; + font-size: 12px; + line-height: 1.6; +} +.msm-sku { + font-family: var(--sycm-font-family-number); + font-weight: var(--sycm-font-weight-semibold); +} + +@container modular-stock-monitor (max-width: 900px) { + .msm-filter-card .ant-space-item { flex: 1 1 180px; } +} + +@media (max-width: 767px) { + .msm-header { + min-height: 72px; + align-items: flex-start; + flex-direction: column; + gap: var(--sycm-space-sm); + padding-block: 12px; + } + .msm-filter-card .ant-select, + .msm-filter-card .ant-input-affix-wrapper { width: 100%; } + .msm-panel .ant-card-body { padding-inline: 10px; } +} diff --git a/frontend/src/pages/Settings/DatabaseSettings.jsx b/frontend/src/pages/Settings/DatabaseSettings.jsx index aedb882..6a7d50f 100644 --- a/frontend/src/pages/Settings/DatabaseSettings.jsx +++ b/frontend/src/pages/Settings/DatabaseSettings.jsx @@ -41,11 +41,13 @@ const { Text } = Typography; const DRIVER_OPTIONS = [ { value: 'mysql', label: 'MySQL / MariaDB' }, { value: 'mssql', label: 'SQL Server (MSSQL)' }, + { value: 'postgres', label: 'PostgreSQL' }, ]; const DEFAULT_PORTS = { mysql: 3306, mssql: 1433, + postgres: 5432, }; const CORE_CONFIG_NAMES = new Set([ @@ -59,6 +61,7 @@ const CONFIG_LABELS = { cloud_database_public: '公网业务数据库', planning_database: '企划数据库', jiansun_database: '简尚业务数据库', + product_information_database: '产品资料系统数据库', }; const DatabaseSettings = () => { diff --git a/frontend/src/router/index.jsx b/frontend/src/router/index.jsx index 2761c18..6cbb290 100644 --- a/frontend/src/router/index.jsx +++ b/frontend/src/router/index.jsx @@ -1,149 +1,164 @@ -import React, { lazy, Suspense } from 'react'; -import { createBrowserRouter, Navigate } from 'react-router-dom'; -import BasicLayout from '../layouts/BasicLayout'; -import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton'; - -const Login = lazy(() => import('../pages/Login')); -const Install = lazy(() => import('../pages/Install')); -const Setup = lazy(() => import('../pages/Setup')); -const Settings = lazy(() => import('../pages/Settings')); -const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers')); -const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks')); -const MarketingEvents = lazy(() => import('../pages/MarketingEvents')); -const ProductInformation = lazy(() => import('../pages/ProductInformation')); -const PromotionModule = lazy(() => import('../pages/PromotionModule')); -const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder')); -const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview')); -const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis')); -const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard')); -const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); -const ModularStockMonitoring = lazy(() => import('../pages/ModularStockMonitoring')); - -const lazyLoadingElement = ; - -const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => { - const LazyView = lazyView; - - return ( - - - - ); -}; - -const HOME_URL = '/planning/dev-style-preview'; - -const router = createBrowserRouter([ - { - path: '/login', - element: renderLazyComponent(Login, {}, ), - }, - { - path: '/callback', - element: renderLazyComponent(Login, {}, ), - }, - { - path: '/setup', - element: renderLazyComponent(Setup), - }, - { - path: '/install', - element: renderLazyComponent(Install), - }, - { - path: '/', - element: , - children: [ - { - index: true, - element: , - }, - { - path: 'dashboard', - element: , - }, - { - path: 'channel', - element: , - }, - { - path: 'planning', - element: , - }, - { - path: 'planning/operation/marketing-events', - element: , - }, - { - path: 'planning/operation/marketing-events/activity-products', - element: renderLazyComponent(MarketingEvents), - }, - { - path: 'planning/operation/marketing-tools', - element: , - }, - { - path: 'planning/operation/product-information', - element: renderLazyComponent(ProductInformation), - }, - { - path: 'planning/operation/product-analysis', - element: renderLazyComponent(ProductAnalysis), - }, - { - path: 'planning/operation/promotion-module', - element: renderLazyComponent(PromotionModule), - }, - { - path: 'planning/operation/ops-b-incentive', - element: renderLazyComponent(OpsBIncentiveBoard), - }, - { - path: 'planning/operation/product-monitoring', - element: renderLazyComponent(ProductMonitoring), - }, - { - path: 'planning/operation/modular-stock-monitoring', - element: renderLazyComponent(ModularStockMonitoring), - }, - { path: 'planning/operation/store-checkup', element: }, - { path: 'planning/operation/review-analysis', element: }, - { path: 'planning/operation/customer-service-checkup', element: }, - { path: 'planning/operation/pricing-calculator', element: }, - { path: 'planning/operation/desktop-client-download', element: }, - { path: 'planning/operation/ai-buyer-show', element: }, - { path: 'planning/operation/ai-seeding-video', element: }, - { path: 'settings/desktop-client-releases', element: }, - { - path: 'planning/operation/:featureKey', - element: renderLazyComponent(OperationPlaceholder), - }, - { - path: 'planning/upload', - element: , - }, - { - path: 'planning/dev-style-preview', - element: renderLazyComponent(DevelopmentStylePreview), - }, - { - path: 'settings', - element: renderLazyComponent(Settings), - }, - { - path: 'settings/online-desktop-users', - element: renderLazyComponent(OnlineDesktopUsers), - }, - { - path: 'settings/desktop-execution-tasks', - element: renderLazyComponent(DesktopExecutionTasks), - }, - { - path: '*', - element: , - }, - ], - }, -]); - -export default router; +import React, { lazy, Suspense } from 'react'; +import { createBrowserRouter, Navigate } from 'react-router-dom'; +import BasicLayout from '../layouts/BasicLayout'; +import { AuthPageLoadingFallback, PageLoadingFallback } from '../components/AppLoadingSkeleton'; + +const Login = lazy(() => import('../pages/Login')); +const Install = lazy(() => import('../pages/Install')); +const Setup = lazy(() => import('../pages/Setup')); +const Settings = lazy(() => import('../pages/Settings')); +const OnlineDesktopUsers = lazy(() => import('../pages/OnlineDesktopUsers')); +const DesktopExecutionTasks = lazy(() => import('../pages/DesktopExecutionTasks')); +const MarketingEvents = lazy(() => import('../pages/MarketingEvents')); +const ProductInformation = lazy(() => import('../pages/ProductInformation')); +const PromotionModule = lazy(() => import('../pages/PromotionModule')); +const OperationPlaceholder = lazy(() => import('../pages/OperationPlaceholder')); +const DevelopmentStylePreview = lazy(() => import('../pages/DevelopmentStylePreview')); +const ProductAnalysis = lazy(() => import('../pages/ProductAnalysis')); +const OpsBIncentiveBoard = lazy(() => import('../pages/PlanningUpload/OpsBIncentiveBoard')); +const ProductMonitoring = lazy(() => import('../pages/ProductMonitoring')); +const ModularStockMonitor = lazy(() => import('../pages/ModularStockMonitor')); +const ModularStockMonitoring = lazy(() => import('../pages/ModularStockMonitoring')); +const MkhBomData = lazy(() => import('../pages/MkhBomData')); +const DailyInventory = lazy(() => import('../pages/DailyInventory')); + +const lazyLoadingElement = ; + +const renderLazyComponent = (lazyView, props = {}, fallback = lazyLoadingElement) => { + const LazyView = lazyView; + + return ( + + + + ); +}; + +const HOME_URL = '/planning/dev-style-preview'; + +const router = createBrowserRouter([ + { + path: '/login', + element: renderLazyComponent(Login, {}, ), + }, + { + path: '/callback', + element: renderLazyComponent(Login, {}, ), + }, + { + path: '/setup', + element: renderLazyComponent(Setup), + }, + { + path: '/install', + element: renderLazyComponent(Install), + }, + { + path: '/', + element: , + children: [ + { + index: true, + element: , + }, + { + path: 'dashboard', + element: , + }, + { + path: 'channel', + element: , + }, + { + path: 'planning', + element: , + }, + { + path: 'planning/operation/marketing-events', + element: , + }, + { + path: 'planning/operation/marketing-events/activity-products', + element: renderLazyComponent(MarketingEvents), + }, + { + path: 'planning/operation/marketing-tools', + element: , + }, + { + path: 'planning/operation/product-information', + element: renderLazyComponent(ProductInformation), + }, + { + path: 'planning/operation/product-analysis', + element: renderLazyComponent(ProductAnalysis), + }, + { + path: 'planning/operation/promotion-module', + element: renderLazyComponent(PromotionModule), + }, + { + path: 'planning/operation/ops-b-incentive', + element: renderLazyComponent(OpsBIncentiveBoard), + }, + { + path: 'planning/operation/product-monitoring', + element: renderLazyComponent(ProductMonitoring), + }, + { + path: 'planning/operation/modular-stock-monitor', + element: renderLazyComponent(ModularStockMonitor), + }, + { + path: 'planning/operation/modular-stock-monitoring', + element: renderLazyComponent(ModularStockMonitoring), + }, + { + path: 'planning/operation/mkh-bom-data', + element: renderLazyComponent(MkhBomData), + }, + { + path: 'planning/operation/daily-inventory', + element: renderLazyComponent(DailyInventory), + }, + { path: 'planning/operation/store-checkup', element: }, + { path: 'planning/operation/review-analysis', element: }, + { path: 'planning/operation/customer-service-checkup', element: }, + { path: 'planning/operation/pricing-calculator', element: }, + { path: 'planning/operation/desktop-client-download', element: }, + { path: 'planning/operation/ai-buyer-show', element: }, + { path: 'planning/operation/ai-seeding-video', element: }, + { path: 'settings/desktop-client-releases', element: }, + { + path: 'planning/operation/:featureKey', + element: renderLazyComponent(OperationPlaceholder), + }, + { + path: 'planning/upload', + element: , + }, + { + path: 'planning/dev-style-preview', + element: renderLazyComponent(DevelopmentStylePreview), + }, + { + path: 'settings', + element: renderLazyComponent(Settings), + }, + { + path: 'settings/online-desktop-users', + element: renderLazyComponent(OnlineDesktopUsers), + }, + { + path: 'settings/desktop-execution-tasks', + element: renderLazyComponent(DesktopExecutionTasks), + }, + { + path: '*', + element: , + }, + ], + }, +]); + +export default router; diff --git a/frontend/src/utils/mkhFreshContract.js b/frontend/src/utils/mkhFreshContract.js new file mode 100644 index 0000000..2ba828d --- /dev/null +++ b/frontend/src/utils/mkhFreshContract.js @@ -0,0 +1,15 @@ +export const MKH_FRESH_DATA_CONTRACT_VERSION = 1; + +const OUTDATED_BACKEND_MESSAGE = '本地后端版本过旧或更新后未重启,已停止展示可能过期的库存。' + + '请停止当前 npm run remote:dev,拉取最新 main 后重新启动。'; + +export function requireMkhFreshContract(snapshot, { requireApplied = false } = {}) { + const contractVersion = Number(snapshot?.freshDataContractVersion) || 0; + if (contractVersion < MKH_FRESH_DATA_CONTRACT_VERSION) { + throw new Error(OUTDATED_BACKEND_MESSAGE); + } + if (requireApplied && snapshot?.freshDataApplied !== true) { + throw new Error('后端未确认已重读最新运营数据,已停止展示可能过期的库存,请重试。'); + } + return snapshot; +} diff --git a/frontend/src/utils/requestGate.js b/frontend/src/utils/requestGate.js new file mode 100644 index 0000000..a33ec9b --- /dev/null +++ b/frontend/src/utils/requestGate.js @@ -0,0 +1,17 @@ +export function createRequestGate() { + let generation = 0; + + return { + begin() { + generation += 1; + return generation; + }, + invalidate() { + generation += 1; + return generation; + }, + isCurrent(requestGeneration) { + return requestGeneration === generation; + }, + }; +} diff --git a/frontend/test/mkhFreshContract.test.mjs b/frontend/test/mkhFreshContract.test.mjs new file mode 100644 index 0000000..261f79d --- /dev/null +++ b/frontend/test/mkhFreshContract.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { requireMkhFreshContract } from '../src/utils/mkhFreshContract.js'; + +test('MKH fresh contract accepts a backend-confirmed live refresh', () => { + const snapshot = { freshDataContractVersion: 1, freshDataApplied: true }; + assert.equal(requireMkhFreshContract(snapshot, { requireApplied: true }), snapshot); +}); + +test('MKH fresh contract rejects an outdated local backend instead of showing stale stock', () => { + assert.throws( + () => requireMkhFreshContract({ rows: [{ stock: 4 }] }, { requireApplied: true }), + /本地后端版本过旧/ + ); +}); + +test('MKH fresh contract requires explicit refresh completion for fresh requests', () => { + assert.throws( + () => requireMkhFreshContract({ freshDataContractVersion: 1 }, { requireApplied: true }), + /未确认已重读/ + ); +}); diff --git a/frontend/test/requestGate.test.mjs b/frontend/test/requestGate.test.mjs new file mode 100644 index 0000000..f4770b6 --- /dev/null +++ b/frontend/test/requestGate.test.mjs @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { createRequestGate } from '../src/utils/requestGate.js'; + +test('only the newest request may update the page', () => { + const gate = createRequestGate(); + const oldRequest = gate.begin(); + const newRequest = gate.begin(); + + assert.equal(gate.isCurrent(oldRequest), false); + assert.equal(gate.isCurrent(newRequest), true); +}); + +test('sync invalidates every request that started before cache clearing', () => { + const gate = createRequestGate(); + const beforeSync = gate.begin(); + + gate.invalidate(); + + assert.equal(gate.isCurrent(beforeSync), false); + const freshSnapshot = gate.begin(); + assert.equal(gate.isCurrent(freshSnapshot), true); +}); diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8cdfea4..3f7dd9f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -11,15 +11,21 @@ export default defineConfig({ cssCodeSplit: false, modulePreload: false, rollupOptions: { + onwarn(warning, warn) { + // Manually splitting packages that depend on each other can create a circular chunk graph. + // That graph built successfully in the past but crashed before React mounted, leaving only + // the static skeleton on screen. Treat it as a release-blocking error from now on. + if (warning.code === 'CIRCULAR_CHUNK') { + throw new Error(`Circular production chunk: ${warning.message}`) + } + warn(warning) + }, output: { entryFileNames: 'assets/[name]-[hash].js', chunkFileNames(chunkInfo) { if (chunkInfo.name === 'app-shell') return 'assets/app-shell-[hash].js' if (chunkInfo.name === 'app-api') return 'assets/app-api-[hash].js' - if (chunkInfo.name === 'react-vendor') return 'assets/react-vendor-[hash].js' - if (chunkInfo.name === 'antd-vendor') return 'assets/antd-vendor-[hash].js' - if (chunkInfo.name === 'charts-vendor') return 'assets/charts-vendor-[hash].js' - if (chunkInfo.name === 'data-vendor') return 'assets/data-vendor-[hash].js' + if (chunkInfo.name === 'vendor') return 'assets/vendor-[hash].js' return 'assets/[name]-[hash].js' }, assetFileNames(assetInfo) { @@ -32,41 +38,8 @@ export default defineConfig({ const normalizedId = id.replace(/\\/g, '/') if (normalizedId.includes('node_modules') || normalizedId.includes('/.vite/deps/')) { - if ( - normalizedId.includes('/react/') || - normalizedId.includes('react-dom') || - normalizedId.includes('react-router') || - normalizedId.includes('@remix-run/router') || - normalizedId.includes('react_jsx-runtime') || - normalizedId.includes('scheduler') - ) { - return 'react-vendor' - } - - if ( - normalizedId.includes('antd') || - normalizedId.includes('@ant-design') || - normalizedId.includes('@ant-design/pro-components') || - normalizedId.includes('/rc-') - ) { - return 'antd-vendor' - } - - if ( - normalizedId.includes('echarts') || - normalizedId.includes('zrender') || - normalizedId.includes('recharts') - ) { - return 'charts-vendor' - } - - if ( - normalizedId.includes('axios') || - normalizedId.includes('@cubejs-client') - ) { - return 'data-vendor' - } - + // Keep mutually dependent third-party packages in one chunk. React, Ant Design, + // charts and Cube clients share runtime helpers and cannot be split safely by path. return 'vendor' } diff --git a/scripts/start-remote.mjs b/scripts/start-remote.mjs index 7d9662d..b6ebf3a 100644 --- a/scripts/start-remote.mjs +++ b/scripts/start-remote.mjs @@ -11,13 +11,57 @@ const ACCESS_DIR = path.join(ROOT_DIR, '.remote-access'); const CONNECTION_PATH = path.join(ACCESS_DIR, 'connection.json'); const RESTART_MARKER_PATH = path.join(ACCESS_DIR, 'restart-requested.json'); +/** + * 解析后端实际监听端口。 + * 优先级:CANGKU_BACKEND_PORT 环境变量 > backend/.env 的 PORT > 默认 4001。 + * 前端 dev server 的 /api 代理必须指向同一端口,否则请求会被转发到空端口。 + */ +function resolveBackendPort() { + const fromEnv = Number(process.env.CANGKU_BACKEND_PORT); + if (Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv < 65536) { + return fromEnv; + } + + const envPath = path.join(ROOT_DIR, 'backend', '.env'); + if (fs.existsSync(envPath)) { + const matched = fs.readFileSync(envPath, 'utf8').match(/^PORT=(\d+)\s*$/mu); + const fromFile = Number(matched?.[1]); + if (Number.isInteger(fromFile) && fromFile > 0 && fromFile < 65536) { + return fromFile; + } + } + + return 4001; +} + +/** + * 隧道重连退避:1s → 2s → 4s … 封顶 30s。 + * 首次失败也立即重试(等价 autossh 的 AUTOSSH_GATETIME=0),避免网络尚未就绪时白等。 + */ +const TUNNEL_RETRY_BASE_MS = 1000; +const TUNNEL_RETRY_MAX_MS = 30000; +const TUNNEL_RETRY_EXPONENT_CAP = 10; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function computeTunnelRetryDelay(attempt) { + const exponent = Math.min(attempt, TUNNEL_RETRY_EXPONENT_CAP); + return Math.min(TUNNEL_RETRY_BASE_MS * 2 ** exponent, TUNNEL_RETRY_MAX_MS); +} + export function buildSshArgs(config) { return [ '-N', '-T', '-o', 'BatchMode=yes', + // 远端仍可能主动 RST(中间设备/服务端空闲超时),本地探活只能更快发现,不能阻止; + // 真正的根治是下面的退避重连循环。 '-o', 'ExitOnForwardFailure=yes', - '-o', 'ServerAliveInterval=30', + '-o', 'ServerAliveInterval=15', '-o', 'ServerAliveCountMax=3', + '-o', 'TCPKeepAlive=yes', + '-o', 'ConnectTimeout=10', '-o', 'StrictHostKeyChecking=yes', '-o', `UserKnownHostsFile=${path.join(ACCESS_DIR, 'known_hosts')}`, '-o', `CertificateFile=${path.join(ACCESS_DIR, 'id_ed25519-cert.pub')}`, @@ -89,32 +133,88 @@ export async function main(mode = process.argv[2] || 'tunnel') { process.once(signal, stopChildren); } + let tunnelRetryAttempt = 0; + let resolveTunnelReady = null; + const tunnelReady = new Promise((resolve) => { + resolveTunnelReady = resolve; + }); + + /** + * 启动一条 SSH 隧道并等它监听就绪。 + * 返回 { tunnel, exitPromise };隧道结束由 exitPromise 上报退出码,交给 + * superviseTunnel 决定重连 —— 这里**不再**牵连后端/前端。 + */ const startTunnel = async () => { const { value: config } = loadConnectionConfig(ROOT_DIR); const tunnel = spawn('ssh', buildSshArgs(config), { cwd: ROOT_DIR, stdio: 'inherit' }); children.add(tunnel); - let ready = false; - const earlyExit = new Promise((resolve, reject) => { + + const exitPromise = new Promise((resolve) => { tunnel.once('exit', (code) => { children.delete(tunnel); - if (!ready) { - reject(new Error(`SSH 隧道在建立前退出(${code ?? 'unknown'})`)); - } else if (!shuttingDown) { - console.error(`SSH 隧道已退出(${code ?? 'unknown'})`); - stopChildren(); - process.exitCode = code || 1; - } + resolve(code); }); }); - try { - await Promise.race([waitForPort(Number(config.localDatabasePort)), earlyExit]); - ready = true; - } catch (error) { + + // 两个分支都 resolve,避免 Promise.race 落败方变成 unhandled rejection + const outcome = await Promise.race([ + waitForPort(Number(config.localDatabasePort)).then(() => ({ ready: true })), + exitPromise.then((code) => ({ ready: false, code })), + ]); + + if (!outcome.ready) { if (!tunnel.killed) tunnel.kill('SIGTERM'); - throw error; + throw new Error(`SSH 隧道在建立前退出(${outcome.code ?? 'unknown'})`); } + console.log(`系统数据库隧道已建立:127.0.0.1:${config.localDatabasePort}/${config.databaseName}`); - return tunnel; + return { tunnel, exitPromise }; + }; + + /** + * 隧道自愈循环(Windows 版 autossh 的语义)。 + * + * 隧道断开只重连隧道本身,后端/前端进程保持不动。这是原先缺失的容错: + * 旧实现在隧道 exit 时直接 stopChildren(),把整个开发栈一起杀掉,于是每一次 + * 远端 RST 都升级成一次「网站打不开」,还得靠外部守护清残留、整树重启。 + * + * 建立失败走指数退避(1s→30s);建立成功后重置退避,断开则固定 1s 后重连。 + */ + const superviseTunnel = async () => { + for (;;) { + if (shuttingDown) return; + + let session; + try { + session = await startTunnel(); + tunnelRetryAttempt = 0; + if (resolveTunnelReady) { + resolveTunnelReady(); + resolveTunnelReady = null; + } + } catch (error) { + if (shuttingDown) return; + const delay = computeTunnelRetryDelay(tunnelRetryAttempt); + tunnelRetryAttempt += 1; + console.error(`SSH 隧道建立失败(${error.message}),${delay}ms 后重试`); + await sleep(delay); + continue; + } + + const code = await session.exitPromise; + if (shuttingDown) return; + console.error(`SSH 隧道已退出(${code ?? 'unknown'}),正在重连…(后端/前端不受影响)`); + await sleep(TUNNEL_RETRY_BASE_MS); + } + }; + + // 守护循环本身理论上不抛错,兜底仍保留:真异常时按老路径收尾,避免留下半死进程 + const startTunnelSupervisor = () => { + superviseTunnel().catch((error) => { + console.error(`隧道守护异常终止:${error.message}`); + stopChildren(); + process.exitCode = 1; + }); }; const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; @@ -124,12 +224,14 @@ export async function main(mode = process.argv[2] || 'tunnel') { CANGKU_REMOTE_SUPERVISOR: 'true', }; if (kind === 'frontend' && !childEnvironment.VITE_API_PROXY_TARGET) { - childEnvironment.VITE_API_PROXY_TARGET = `http://127.0.0.1:${process.env.CANGKU_BACKEND_PORT || 4000}`; + childEnvironment.VITE_API_PROXY_TARGET = `http://localhost:${resolveBackendPort()}`; } const child = spawn(npmCommand, args, { cwd: ROOT_DIR, stdio: 'inherit', env: childEnvironment, + // Windows 下 .cmd 不是可执行文件,必须经 shell 启动,否则 spawn 直接抛 EINVAL + shell: process.platform === 'win32', }); children.add(child); child.once('exit', (code) => { @@ -147,7 +249,8 @@ export async function main(mode = process.argv[2] || 'tunnel') { const hasConnection = fs.existsSync(CONNECTION_PATH); if (hasConnection) { if (fs.existsSync(RESTART_MARKER_PATH)) fs.unlinkSync(RESTART_MARKER_PATH); - await startTunnel(); + startTunnelSupervisor(); + await tunnelReady; } else if (mode === 'tunnel') { throw new Error('尚未配置远程系统数据库,请先运行应用并在首次启动弹窗中输入请求地址和 API Key'); } @@ -170,7 +273,8 @@ export async function main(mode = process.argv[2] || 'tunnel') { await waitForExit(backend); restartingBackend = false; fs.unlinkSync(RESTART_MARKER_PATH); - await startTunnel(); + startTunnelSupervisor(); + await tunnelReady; backend = startApplication( ['--prefix', 'backend', 'run', mode === 'dev' ? 'dev' : 'start'], 'backend' diff --git a/test/remote-access.test.mjs b/test/remote-access.test.mjs index 380cde0..0e447f9 100644 --- a/test/remote-access.test.mjs +++ b/test/remote-access.test.mjs @@ -1,7 +1,8 @@ import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { getKnownHostsLine, mergeEnv, validateEnrollmentResponse } from '../scripts/remote-access-common.mjs'; -import { buildSshArgs } from '../scripts/start-remote.mjs'; +import { buildSshArgs, computeTunnelRetryDelay } from '../scripts/start-remote.mjs'; const enrollment = { installation_id: 'dev-123', @@ -69,3 +70,61 @@ test('pins standard and non-standard SSH host targets', () => { assert.equal(getKnownHostsLine('example.test', 22, 'ssh-ed25519 KEY'), 'example.test ssh-ed25519 KEY\n'); assert.equal(getKnownHostsLine('example.test', 2222, 'ssh-ed25519 KEY'), '[example.test]:2222 ssh-ed25519 KEY\n'); }); + +// ───────────────────────────────────────────────────────────────────────────── +// 隧道自愈(根治远端 RST 导致「网站打不开」) +// +// 旧行为:隧道进程 exit → stopChildren(),把后端 + 前端一起杀掉,于是每一次远端 +// RST 都升级成一次整栈不可用,还得靠外部守护清残留、整树重启。 +// 新行为:隧道 exit → 只重连隧道本身,应用进程不受影响。 +// ───────────────────────────────────────────────────────────────────────────── + +const tunnelConfig = { + localDatabasePort: 13307, + ssh: { + host: 'example.test', + port: 50232, + user: 'cangku-tunnel', + remoteDatabaseHost: '127.0.0.1', + remoteDatabasePort: 3307, + }, +}; + +test('tunnel keepalive: 更快发现死连接,且不会在 connect 阶段无限等待', () => { + const flags = new Set(buildSshArgs(tunnelConfig)); + // ExitOnForwardFailure 缺失会出现最阴险的故障:进程活着、端口却没绑上(「静默成功」) + assert.ok(flags.has('ExitOnForwardFailure=yes'), '必须保留 ExitOnForwardFailure=yes'); + assert.ok(flags.has('ServerAliveInterval=15'), '探活间隔应为 15s,以便更快发现被 RST 的连接'); + assert.ok(flags.has('ServerAliveCountMax=3')); + assert.ok(flags.has('TCPKeepAlive=yes'), '需要内核层保活兜底'); + assert.ok(flags.has('ConnectTimeout=10'), '连不上时应快速失败,交给退避重连而不是干等'); +}); + +test('tunnel retry backoff: 1s 起指数增长,封顶 30s 且不会溢出', () => { + assert.equal(computeTunnelRetryDelay(0), 1000); + assert.equal(computeTunnelRetryDelay(1), 2000); + assert.equal(computeTunnelRetryDelay(2), 4000); + assert.equal(computeTunnelRetryDelay(4), 16000); + assert.equal(computeTunnelRetryDelay(5), 30000); + // 长期不可用时 attempt 会一路增长,必须封顶而不是算成 Infinity / 0 + assert.equal(computeTunnelRetryDelay(200), 30000); + assert.ok(Number.isFinite(computeTunnelRetryDelay(2000))); +}); + +test('防腐化:隧道自愈循环不得停止后端/前端进程', async () => { + const source = await readFile(new URL('../scripts/start-remote.mjs', import.meta.url), 'utf8'); + const start = source.indexOf('const superviseTunnel = async () => {'); + const end = source.indexOf('const startTunnelSupervisor = () => {'); + assert.ok(start > 0 && end > start, '未定位到 superviseTunnel 实现'); + + const body = source.slice(start, end); + assert.equal( + body.includes('stopChildren()'), + false, + '隧道重连不得牵连后端/前端 —— 否则又回到「隧道一断,整个开发栈陪葬」' + ); + assert.ok( + body.includes('await sleep('), + '隧道退出后必须先等待再重连,避免 RST 时疯狂重连打爆对端' + ); +}); diff --git a/仓库提交与合并规范.md b/仓库提交与合并规范.md index 9ef69c5..ad4fe68 100644 --- a/仓库提交与合并规范.md +++ b/仓库提交与合并规范.md @@ -51,6 +51,21 @@ git status --short - 数据库迁移使用不重复的名称,兼容已有数据,不能修改已经执行的历史迁移来代替新增迁移。 - 不提交 `.env`、私钥、令牌、真实业务数据、数据库备份、`node_modules` 或无关构建产物。示例配置只使用占位值。 +### 3.1 数据表、索引与映射负载要求 + +开发者及 AI 新建或修改数据表、第三方数据映射、查询接口、报表或同步任务时,必须把数据库负载作为功能验收条件,不得等到上线后再补索引或限流。 + +1. **按真实访问路径设计索引。** 根据 `WHERE`、`JOIN`、`ORDER BY`、分页、去重和幂等键设计单列或联合索引,明确联合索引列顺序和最左匹配规则。索引列的类型、长度、字符集和排序规则必须与关联列一致。不要添加没有查询依据的重复索引,也不能以索引过多换取“可能更快”。 +2. **映射关系必须稳定且可幂等。** 第三方主键、业务编码与系统主键应使用明确的标准化字段和唯一约束,需要多字段定位时建立与匹配顺序一致的联合唯一索引。禁止每次请求通过模糊匹配、字符串函数或全表扫描重建映射。 +3. **提交前验证执行计划。** 在隔离的测试环境使用 `EXPLAIN`;数据库支持且确认语句安全时可使用 `EXPLAIN ANALYZE`。检查实际命中索引、预估/实际扫描行数、回表、排序和临时表情况。不得在生产库上执行高负载的 `EXPLAIN ANALYZE` 或压测。 +4. **查询必须有界。** 使用限制列的字段选择和有上限的分页,大数据量优先使用基于稳定索引键的游标分页。避免 `SELECT *`、N+1 查询、无上限 `IN`、前置通配符模糊查询、对索引列包裹函数,以及没有范围条件的更新或删除。 +5. **同步任务必须分批、增量和可恢复。** 通过时间水位、版本号或稳定主键做增量读取,用唯一约束与 upsert 保证幂等;设置可控的批量、并发数、查询超时、重试退避和单实例锁。禁止每次启动全量扫描、无限并发、紧循环轮询或重复后台任务。 +6. **保护第三方云数据库。** 业务云库查询必须只取必要列和必要范围,并在系统库持久化必要的结果或增量水位,避免页面请求反复扫描远程大表。未获明确授权不得在第三方库执行 DDL、建索引或写入。 +7. **迁移必须考虑锁表和回退。** 针对大表新增索引、变更列类型或回填数据前,评估表规模、锁等待、磁盘增长和业务窗口;采用可分步的兼容迁移,并说明回退时是否会丢数据。 +8. **PR 必须附上数据库证据。** 说明表规模假设、索引定义及对应查询、执行计划摘要、扫描行数或耗时对比、同步批量/并发/超时参数、迁移与回退方式。无法获得有代表性数据时要标明限制,不得虚构性能结论。 + +只有功能结果正确且负载证据可接受,数据表和查询变更才算完成。无法证明索引有效、可能对中央系统库或业务云库造成高负载时,保持 PR 待审核,不得部署。 + 提交前逐项检查,并显式暂存文件;文件中混有不同任务时使用 `git add -p` 选择自己的改动。不要盲目使用 `git add .` 或 `git add -A`: ```bash @@ -153,12 +168,12 @@ npm --prefix frontend run check 合并要求: -1. 至少一名非提交者的维护者或开发者审核最终差异;AI 自我复查可以辅助,但不代替独立审核。提交 AI 不得用另一个会话或共享账号冒充独立审核者。 +1. 默认由至少一名非提交者的维护者或开发者审核最终差异;AI 自我复查可以辅助,但不代替独立审核。仓库所有者或管理员对明确的 PR 作出书面合并指令时,该授权可以代替独立审核,但必须在 PR 中记录授权人、授权范围和当时的最终提交 SHA。提交 AI 不得伪造授权、冒用其他账号或把模糊的日常开发指令解释为合并授权。 2. 审核需关注是否撤销他人逻辑、丢失配置/依赖、改变接口与权限、破坏已上线功能,不能只看有无冲突或测试绿灯。 -3. 审核和检查必须对应最终待合并提交;后续新提交或冲突解决后需要重新确认,不能沿用失效的批准。 -4. 合并前再次确认最新 `main` 已被纳入任务分支,并验证组合后的行为。其他 PR 先合并导致主分支前进时,更新本 PR、重新检查并确认审核后再合并。 +3. 审核、所有者/管理员授权和检查必须对应最终待合并提交;后续新增业务代码、依赖变更或冲突解决后需要重新确认,不能沿用失效的批准。授权后仅补充不改变行为的合并记录或文档时,应在 PR 中说明并重新检查最终差异。 +4. 合并前再次确认最新 `main` 已被纳入任务分支,并验证组合后的行为。其他 PR 先合并导致主分支前进时,更新本 PR、重新检查并确认审核或所有者/管理员授权仍适用于最终提交后再合并。 5. 由维护者通过仓库 PR 合并功能依次处理;同时有多个待合并 PR 时串行合并。有合并队列时使用针对最新组合提交的检查,避免两份旧验证同时放行。 -6. 未解决冲突、必需检查失败、尚未审核或同步状态不清楚时不得合并。缺少独立审核者时保持 PR 待审核,不自行批准。 +6. 未解决冲突、必需检查失败、同步状态不清楚,或既无独立审核也无所有者/管理员明确授权时不得合并。授权例外不允许绕过失败检查、未解决冲突、数据库风险证据或分支保护,也不允许直接推送 `main`。 7. 合并后核对主分支结果。删除任务分支前确认已合并且无人继续使用,不删除其他开发者的分支。 ## 7. 发现误合并或代码被覆盖 @@ -172,8 +187,10 @@ npm --prefix frontend run check - [ ] 已阅读本规范;当前仓库、目录、账号、任务分支和基线正确。 - [ ] 没有覆盖、清理、暂存或提交他人的未提交修改。 - [ ] 提交仅包含本任务内容,没有秘密信息、真实业务数据或无关产物。 +- [ ] 涉及数据表、索引、映射、查询或同步时,已验证执行计划、数据量边界和负载控制,并在 PR 记录真实证据。 - [ ] 已同步最新主分支及远端任务分支,未强推、未改写已发布历史。 - [ ] 已逐项审核最终差异;冲突处理保留双方有效行为,疑点已确认。 +- [ ] 已取得独立审核,或已在 PR 中记录仓库所有者/管理员针对最终提交的明确合并授权。 - [ ] 已完成受影响的验证,记录准确结果和提交 SHA。 - [ ] PR 已说明范围、兼容性、验证、风险和回退方法。 - [ ] 已明确报告当前状态:本地提交 / 已推送 / PR 待审核 / 已合并;没有把推送成功说成合并或部署成功。 @@ -182,6 +199,6 @@ npm --prefix frontend run check 本文件规定协作流程,`AGENTS.md` 提供 AI 读取入口;它们不是权限控制,不能保证每个 AI 工具自动读取或遵守。使用不自动读取这些文件的工具时,开发者必须主动提供规则。 -仓库管理员应在服务端落实以下保护,并以实际设置为准:保护 `main`、禁止直接及强制推送和删除、要求 PR 与独立审核、使过期审核失效、绑定真实运行的必需检查、限制合并权限、按个人发放最小权限凭据。没有真正运行的检查不能仅凭同名状态作为合并依据。 +仓库管理员应在服务端落实以下保护,并以实际设置为准:保护 `main`、禁止直接及强制推送和删除、要求通过 PR 合并、使过期审核失效、绑定真实运行的必需检查、限制合并权限、按个人发放最小权限凭据。团队可默认要求独立审核;需要允许所有者/管理员授权例外时,应确保只有真实所有者或管理员能够执行并保留可审计记录。没有真正运行的检查不能仅凭同名状态作为合并依据。 **添加本文件不等于上述保护已经启用。** 此文档变更不修改仓库权限、分支保护、CI 或生产部署;如需启用服务端限制,应由管理员单独配置并验证。 diff --git a/服务器部署规范.md b/服务器部署规范.md index cd29c08..bca2759 100644 --- a/服务器部署规范.md +++ b/服务器部署规范.md @@ -7,7 +7,8 @@ - 生产服务器项目根目录:`/opt/cangku` - 服务器代码目录:`/opt/cangku/app` - 生产编排文件:`/opt/cangku/app/deploy/production/docker-compose.yml` -- 对外站点及注册接口:`https://ck.yydd.ai:8443` +- 对外主站:`https://ck.yydd.ai`(标准 HTTPS 端口 `443`) +- 兼容访问入口及注册接口:`https://ck.yydd.ai:8443` - 公网 TLS Redis:`ck.yydd.ai:6380` - 服务器 SSH 密钥目录位于开发者桌面:`~/Desktop/cangku-156.225.27.52-ssh-key` - 当前私钥文件:`~/Desktop/cangku-156.225.27.52-ssh-key/cangku-prod-root-ed25519` @@ -59,8 +60,22 @@ git diff --stat origin/main...HEAD 2. 本地已有修改先按 `仓库提交与合并规范.md` 提交到唯一任务分支,再同步最新 `origin/main`。 3. 如果主仓库已有更新,先合并到任务分支,逐处处理冲突,重新检查最终差异并运行受影响测试。 4. 禁止使用强制推送、`git reset --hard`、批量选择 `ours/theirs` 或复制旧目录覆盖新代码来解决冲突。 -5. 将任务分支推送到主仓库,创建 PR,经过审核和验证后合并到 `main`。这里的“先推送主仓库”指推送任务分支并通过 PR 合并,禁止直接推送 `main`。 -6. PR 合并后再次执行 `git fetch origin`,记录准备部署的 `origin/main` 完整 SHA。部署 SHA 必须与审核通过的最终提交一致。 +5. 将任务分支推送到主仓库并创建 PR;经过独立审核,或取得仓库所有者/管理员针对该 PR 最终提交的明确授权,并完成验证后合并到 `main`。这里的“先推送主仓库”指推送任务分支并通过 PR 合并,禁止直接推送 `main`。 +6. PR 合并后再次执行 `git fetch origin`,记录准备部署的 `origin/main` 完整 SHA。部署 SHA 必须与审核或所有者/管理员授权覆盖的最终提交一致。 + +如果本次变更涉及数据表、索引、查询、数据映射或同步任务,部署者还必须确认 PR 已按《仓库提交与合并规范》记录索引与执行计划证据、分批/并发/超时边界、大表锁影响和回退方式。证据缺失、依赖全表扫描、可能对系统库或第三方云库造成不可控负载时,不得部署。 + +### 3.1 前端发布必须阻断循环分块 + +本项目曾因 Vite/Rollup 把 React、Ant Design、图表库和数据客户端按包路径拆成相互依赖的多个 chunk,生产构建虽然成功,浏览器却在 React 挂载前报错,页面永久停留在静态骨架屏。`curl` 返回 `200`、后端 `/health` 正常和静态资源可下载都无法发现这一故障。 + +前端相关变更必须遵守以下要求: + +1. 保留 `frontend/vite.config.js` 中对 `CIRCULAR_CHUNK` 的构建阻断。不得为了让构建“通过”而删除、降级或忽略该检查。 +2. 第三方依赖默认保留在同一个 `vendor` chunk。不得仅按目录名把 React、Ant Design、图表库或 Cube/数据客户端强行拆成独立 vendor chunk;确需重新分包时,必须证明 chunk 依赖图无环,并完成本节和第 6 节的浏览器运行时验证。 +3. 出现 `Circular chunk`、`Cannot read properties of undefined`、`Cannot access ... before initialization` 等构建或浏览器错误时,停止发布并修正模块依赖或分包策略。禁止把它当成普通警告继续部署。 +4. 单个 vendor 文件较大的提示不等于可以直接按包名拆分。先使用路由级动态导入、页面级懒加载或经过验证的稳定边界减小首屏体积。 +5. 推送前至少执行 `npm --prefix frontend run check`。构建日志必须没有循环 chunk 警告;测试成功不能替代真实生产构建验证。 有冲突时,AI 必须阅读共同基线和双方改动,保留双方仍然有效的行为。无法确定业务取舍时,停止该冲突的处理并请负责人决定。冲突解决后的代码必须重新测试、重新审核,不能沿用冲突前的结果。 @@ -110,15 +125,14 @@ test "$(git rev-parse "origin/main^{commit}")" = "$DEPLOY_SHA" || { exit 1 } -git switch main +git checkout main git pull --ff-only origin main test "$(git rev-parse HEAD)" = "$DEPLOY_SHA" || { echo '服务器 HEAD 与待部署 SHA 不一致,停止部署' exit 1 } -npm --prefix frontend ci -npm --prefix frontend run build +bash deploy/production/build-frontend.sh if grep -q '^SOURCE_GIT_SHA=' /opt/cangku/shared/backend.env; then sed -i "s/^SOURCE_GIT_SHA=.*/SOURCE_GIT_SHA=$DEPLOY_SHA/" /opt/cangku/shared/backend.env else @@ -143,8 +157,10 @@ for attempt in $(seq 1 60); do sleep 2 done +curl -fsS https://ck.yydd.ai/ >/dev/null +curl -fsS https://ck.yydd.ai/health curl -fsS https://ck.yydd.ai:8443/ >/dev/null -curl -fsS https://ck.yydd.ai:8443/health +curl -fsS https://ck.yydd.ai:8443/health >/dev/null docker compose -f deploy/production/docker-compose.yml ps git status --short git rev-parse HEAD @@ -157,12 +173,25 @@ REMOTE ## 6. 部署完成标准 +### 6.1 必须验证浏览器真实运行,不能只检查 HTTP 200 + +前端构建和容器启动后,必须使用一个不复用旧站点缓存和旧登录状态的全新浏览器上下文访问主站 `https://ck.yydd.ai/`。可以使用临时 Chrome 用户目录、全新的无痕窗口,或在开发者工具中清空站点数据后硬刷新。验证过程必须同时满足: + +1. 页面离开启动骨架屏,显示完整登录页或登录后的业务页面。 +2. 浏览器 Console 没有未捕获的 `TypeError`、`ReferenceError`、模块初始化错误或意外的资源加载错误。登录页进行会话探测时,鉴权接口按设计返回 `401` 属于正常结果,但必须确认请求目标和响应含义确实是“尚未登录”。 +3. Network 中入口 HTML 和当前 HTML 引用的 JS/CSS 必须成功,关键接口响应必须符合当前登录状态与业务预期;不得只验证首页 HTML 返回 `200`。 +4. 当前 HTML 引用的资源文件名来自本次构建,页面展示的版本信息或接口 `/health` 中的 `SOURCE_GIT_SHA` 与待部署 SHA 一致。 +5. 至少完成一个关键用户路径;涉及数据页面时,要确认加载能结束并显示数据、空状态或明确错误,不能无限显示 loading/skeleton。 + +如果全新浏览器上下文正常,而原有标签页仍停在旧骨架屏,先关闭旧标签并进行硬刷新或清除该站点缓存。不得通过回滚代码、固定旧资源文件名或长期关闭缓存来掩盖客户端旧资源。若全新上下文也失败,保留 Console、Network、容器日志和当前 SHA,在部署锁内停止报告完成并修复后重新发布。 + 部署者和 AI 只有在以下项目全部确认后才能报告完成: - 服务器 `HEAD` 等于预先记录且已合并的 `origin/main` SHA。 - `git status --short` 没有输出。 - MySQL、Redis、Redis TLS 网关、后端和注册服务容器处于正常运行状态。 -- `https://ck.yydd.ai:8443/` 返回成功,`/health` 显示系统数据库、第三方数据库和 Redis 连接成功。 +- `https://ck.yydd.ai/` 与 `https://ck.yydd.ai:8443/` 返回成功,`/health` 显示系统数据库、第三方数据库和 Redis 连接成功。 +- 使用全新浏览器上下文完成第 6.1 节检查;页面已离开骨架屏,Console 无致命运行时错误,关键数据页面不会无限加载。 - `ck.yydd.ai:6380` 的 TLS 证书校验通过;原始 Redis 端口没有对公网开放。 - 新版本的关键用户路径已验证,没有覆盖其他开发者已经上线的功能。 - 最终报告包含任务分支、PR、部署 SHA、实际检查结果和未解决问题。