// 只读断言:直接加载现有组件方法,禁止发出真实业务请求。 const assert = require('assert/strict'); const fs = require('fs'); const path = require('path'); const vm = require('vm'); const root = path.resolve(__dirname, '..'); const folder = path.join(root, 'src/views/warehouseManagement/index'); const compiler = require(path.join(root, 'node_modules/vue-template-compiler')); function load(file, bindings = {}) { let source = fs.readFileSync(path.join(folder, file), 'utf8'); if (file.endsWith('.vue')) source = compiler.parseComponent(source).script.content; source = source.replace(/^import .*;$/gm, '').replace(/export default/, 'module.exports =').replace(/export (function|const)/g, '$1'); const context = { module: { exports: {} }, ...bindings }; vm.runInNewContext(source, context, { filename: file }); return { value: context.module.exports, context }; } const { context: utils } = load('dashboard-utils.js'); const { monthRange, cleanQuery, displayNumber, findPortalEntry, formatPpChange, fillStagnantDays } = utils; // const 导出只落在 vm 上下文的词法作用域里、不会成为上下文属性,所以用 runInContext 取回常量, // 再作为绑定传给组件脚本(组件里的 import 被 load() 剥掉了)。 const utilConst = (name) => vm.runInContext(name, utils); const STAGNANT_DEFAULT_DAYS = utilConst('STAGNANT_DEFAULT_DAYS'); assert.equal(STAGNANT_DEFAULT_DAYS, 180, '呆滞阈值默认回落值必须是 180 天'); assert.equal(utilConst('STAGNANT_DAYS_PLACEHOLDER'), '{stagnantDays}', '口径条占位符必须与 dashboard-screens.js 的文案一致'); // 百分点差值:带符号 + 固定 2 位小数 + pp 单位,0 视为持平。 // 这条坏了会把「占比抬升」显示成没有单位的小数,用户无法判断是 pp 还是 %。 assert.equal(formatPpChange(0.5), '+0.50pp', '上升必须带 + 号、2 位小数与 pp 单位'); assert.equal(formatPpChange(-1.2), '-1.20pp', '下降必须带 - 号,不能用 0 顶替'); assert.equal(formatPpChange(0), '持平', '差值为 0 只显示持平,不显示 +0.00pp'); assert.equal(formatPpChange(null), '—', '空值返回占位符,调用方不得把它当 0 展示'); // 阈值占位符替换:有值用值,缺失回落 180;不含占位符的文案原样返回(其它屏必须一字不改)。 assert.equal(fillStagnantDays('库龄超过 {stagnantDays} 天', 90), '库龄超过 90 天'); assert.equal(fillStagnantDays('库龄超过 {stagnantDays} 天', null), '库龄超过 180 天', '阈值缺失必须回落默认 180 天'); assert.equal(fillStagnantDays('库龄超过 {stagnantDays} 天', 0), '库龄超过 180 天', '非法阈值(0)也要回落默认值'); assert.equal(fillStagnantDays('无占位符文案', 90), '无占位符文案', '不含占位符的屏幕文案必须原样返回'); assert.equal(monthRange(new Date(2024, 1, 15)).endTime, '2024-02-29 23:59:59'); assert.equal(monthRange(new Date(2026, 11, 31)).startTime, '2026-12-01 00:00:00'); assert.equal(displayNumber(null), '—'); assert.equal(displayNumber(''), '—'); assert.equal(displayNumber(0), '0'); assert.equal(cleanQuery({ id: '9223372036854775806', status: 0, scope: false, empty: '', missing: null }).id, '9223372036854775806'); assert.equal(cleanQuery({ status: 0 }).status, '0'); assert.equal(cleanQuery({ scope: false }).scope, 'false'); const menus = [{ path: '/page-wt', children: [{ path: '/bpm', children: [{ path: 'todo', component: 'bpm/todo/index' }] }] }]; assert.equal(findPortalEntry(menus, n => /bpm\/todo/.test(n.component || '')), '/page-wt/bpm/todo'); assert.equal(findPortalEntry([{ path: '/page-eom', children: [{ path: '/purchasingManage/purchaseNeedManage', name: '采购' }] }], n => n.name === '采购'), '/page-eom/purchasingManage/purchaseNeedManage'); assert.equal(findPortalEntry([{ path: 'https://example.com', name: '外部' }], () => true), ''); assert.equal(findPortalEntry(menus, n => n.name === '无权限菜单'), ''); let calls = 0; const { context: screens } = load('dashboard-screens.js'); const { value: page } = load('dashboard.vue', { HomeOverview: {}, DashboardDetail: {}, DashboardActions: {}, ...utils, screens: screens.screens, dashboardAction: async () => { calls++; return [{ key: 'a', success: true }, { key: 'b', success: false }]; } }); for (const entry of ['index.vue', 'common.vue', 'yuxin.vue']) { const source = fs.readFileSync(path.join(folder, entry), 'utf8'); assert.match(source, /from ['"]\.\/dashboard\.vue['"]/); assert.doesNotMatch(source, /from ['"]\.\/index\.vue['"]/); } const open = { filters: { unit: '吨', categoryId: '1', stockBatchId: '2' }, view: 'home', route(view, filters) { this.target = view; this.nextFilters = filters; } }; page.methods.open.call(open, 'todo'); assert.equal(open.nextFilters.type, 'inventory'); page.methods.open.call(open, 'ioapp'); assert.equal(open.nextFilters.status, '0'); assert.equal(open.nextFilters.unit, ''); assert.equal(open.nextFilters.categoryId, undefined); const receipt = { receiveQueue: [{ id: '1' }, { id: '2' }], preparedReceipts: [], receiving: false, $route: { query: { inId: '1' } }, nextReceipt() {} }; page.methods.prepareReceipt.call(receipt, { sourceBizNo: 'R1' }); page.methods.prepareReceipt.call(receipt, { sourceBizNo: 'R1' }); assert.equal(receipt.preparedReceipts.length, 1, '旧表单重复事件不能准备到下一张申请'); // 计算属性在真实 Vue 实例里通过 this 互相访问;这里用 defineProperty 复现同一解析方式, // 否则 kpis 里读取 this.stockWeekOverWeek / this.stockValueWeekOverWeek 会拿到 undefined。 function componentState(component, overrides = {}) { const state = { ...overrides }; for (const [key, getter] of Object.entries(component.computed || {})) { Object.defineProperty(state, key, { configurable: true, get: () => getter.call(state) }); } return state; } const { value: overview } = load('components/HomeOverview.vue', { DashboardChart: {}, ...utils, STAGNANT_DEFAULT_DAYS }); const kpis = overview.computed.kpis.call(componentState(overview, { summary: {}, unit: '吨' })); assert.equal(kpis.find(item => item.key === 'stock').target, 'stockLedger'); assert.equal(kpis.find(item => item.key === 'value').target, 'stockLedger'); // 库存金额为空值时只显示成本覆盖原因,不能把缺价显示成 0 元。 const valueEmpty = kpis.find(item => item.key === 'value'); assert.equal(valueEmpty.value, undefined); assert.equal(valueEmpty.delta, '库存金额待成本数据核实'); // 有完整金额和可比快照时,角标为带符号百分比,并带出同一基准日期。 const valueReady = overview.computed.kpis.call(componentState(overview, { summary: { inventoryValue: 12345.67, inventoryValueChangeRate: 8.5, comparisonBaseAt: '2026-10-19 23:59:00' }, unit: '吨' })).find(item => item.key === 'value'); assert.equal(valueReady.value, 12345.67); assert.match(valueReady.delta, /较上周 \+8\.50%/); assert.match(valueReady.delta, /2026-10-19 日末/); // 缺价批次导致金额为空时,原样展示服务端覆盖原因,不能用 0% 或部分金额兜底。 const valueUnknown = overview.computed.kpis.call(componentState(overview, { summary: { inventoryValue: null, valuationReason: '已计价 3/70 个批次,存在缺价批次' }, unit: '' })).find(item => item.key === 'value'); assert.equal(valueUnknown.delta, '已计价 3/70 个批次,存在缺价批次'); const todos = overview.computed.todoCards.call({ summary: {} }); // 待办条下钻目标:入/出库都必须落到 V13 出入库申请,各自带自己的 direction。 // 这两条坏了会把待出库单送进入总库申请(inapp),卡片数字与下钻列表立刻对不上。 const todoOf = (key) => todos.find(item => item.key === key); assert.equal(todoOf('in').target, 'ioapp', '待入库单必须下钻 V13 出入库申请(入库页签),落到其它入口会让卡片数字与列表对不上'); assert.equal(todoOf('out').target, 'ioapp', '待出库单必须下钻 V13 出入库申请(出库页签),指向 inapp 会让卡片数字与列表对不上'); assert.equal((todoOf('in').filters || {}).direction, 'in', '待入库单必须显式带 direction=in,不能依赖 open() 的兜底'); assert.equal((todoOf('out').filters || {}).direction, 'out', '待出库单必须下钻出库页签(direction=out),否则卡片数字与下钻列表对不上'); // 库存周转卡片:目标/预警天数、状态、较上月差值全部由后端下发,前端只做映射展示。 const turnoverKpi = (summary, unit = '天') => overview.computed.kpis.call(componentState(overview, { summary, unit })).find(item => item.key === 'turnover'); const turnoverBase = { turnoverDays: 52.5, turnoverTargetDays: 45, turnoverWarnDays: 60, turnoverAsOf: '2026-10-19' }; // 状态四态:颜色与文案只认后端的 达标/超目标/偏慢,null 走中性色并留空文案。 // 这条坏了说明前端又在自算阈值,仓库配置的目标/预警天数会失效、健康度会误报。 assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标' }).color, '#3BD48A', '达标必须取后端状态给绿色,前端自算阈值会误报健康度'); assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标' }).status, '达标', '达标文案必须来自后端 turnoverStatus'); assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '超目标' }).color, '#F0A03A', '超目标必须取橙色,否则看不出已过目标值'); assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '偏慢' }).color, '#F25E5E', '偏慢必须取红色,否则周转偏慢会被当成正常'); assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '偏慢' }).status, '周转偏慢', '偏慢文案沿用“周转偏慢”的既有措辞'); const turnoverUnknown = turnoverKpi({ ...turnoverBase, turnoverStatus: null }); assert.equal(turnoverUnknown.color, '#9FB8D4', '状态缺失必须回落中性色,不能凭空判定健康度'); assert.equal(turnoverUnknown.status, '', '状态缺失时状态文案留空,不得由前端补造'); // 较上月四态:负值(周转加快)绿、正值红、0 持平、null 只给原因。 // 这条坏了会把「上月不可比」显示成 0 天,等于伪造环比数据。 const turnoverDown = turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverChange: -3.2 }); assert.match(turnoverDown.delta, /较上月 -3\.2 天/, '周转天数下降要显示“较上月 -3.2 天”'); assert.equal(turnoverDown.deltaTone, 'good', '差值下降是向好,必须走绿色语义'); const turnoverUp = turnoverKpi({ ...turnoverBase, turnoverStatus: '偏慢', turnoverChange: 8 }); assert.equal(turnoverUp.deltaTone, 'bad', '差值上升是变慢,必须走红色语义'); assert.match(turnoverUp.delta, /较上月 \+8 天/, '周转天数上升要显示带正号的差值'); const turnoverFlat = turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverChange: 0 }); assert.equal(turnoverFlat.delta, '较上月持平', '差值 0 只显示持平,不显示 0 天'); assert.equal(turnoverFlat.deltaTone, 'dim', '持平不带健康度色调'); const turnoverNoChange = turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverChange: null, turnoverChangeReason: '上月无日末快照' }); assert.equal(turnoverNoChange.delta, '上月无日末快照', '不可比时必须原样展示后端原因'); assert.doesNotMatch(turnoverNoChange.delta, /0 天/, '不可比绝不显示成 0 天'); // 后端字段未上线的过渡态:卡片必须能渲染,用中性色与空状态兜底,页面不报错。 const turnoverLegacy = turnoverKpi({}); assert.equal(turnoverLegacy.color, '#9FB8D4', '后端字段缺失时用中性色,不能让卡片崩掉'); assert.equal(turnoverLegacy.status, '', '后端字段缺失时状态留空'); assert.equal(turnoverLegacy.delta, '', '没有 turnoverChange 时不显示环比,更不能显示 0'); assert.equal(turnoverLegacy.value, undefined, '周转天数为空时交给 displayKpi 显示占位符'); // T+1 标注:日末快照日期来自 turnoverAsOf,格式不可信时退回「截至昨日」。 // 这条坏了用户会把 T+1 的旧值当成实时值看。 assert.match(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标' }).subtotal.text, /截至 2026-10-19 日末/, '必须用 turnoverAsOf 标注 T+1 日末快照日期'); assert.match(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverAsOf: '2026/10/19' }).subtotal.text, /截至昨日/, 'asOf 格式不符时退回“截至昨日”'); assert.match(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverAsOf: null }).subtotal.text, /截至昨日/, 'asOf 为空时退回“截至昨日”'); // 防回归:周转阈值判定只能在后端,源码里不得再出现 turnoverDays 的比较运算。 // 这条坏了意味着有人把 45/60 写回前端,仓库配置的目标/预警天数立刻失效。 const overviewSource = fs.readFileSync(path.join(folder, 'components/HomeOverview.vue'), 'utf8'); assert.doesNotMatch(overviewSource, /turnoverDays\s*<=/, '周转阈值判定必须留在后端,前端写回 <= 会让仓库配置失效'); assert.doesNotMatch(overviewSource, /turnoverDays\s*>=/, '周转阈值判定必须留在后端,前端写回 >= 会让仓库配置失效'); assert.doesNotMatch(overviewSource, /turnover(Days|TargetDays|WarnDays)\s*[<>]/, '周转/目标/预警天数不得在前端做大小比较,阈值由后端唯一判定'); // 呆滞品占比卡片:分级、阈值、环比差值全部由后端下发,前端只做映射。 // 分级必须只认后端 stagnantLevel:前端一旦重算 8%/10%,仓库配置的天数阈值与 +0.5pp 入口级立刻失效。 const stagnantKpi = (summary) => overview.computed.kpis.call(componentState(overview, { summary, unit: '%' })).find(item => item.key === 'stagnant'); // 后端契约:stagnantBatchCount=呆滞批次数(分子 X),stagnantKnownBatchCount=可核实批次数(分母 Y), // stagnantUnknownBatchCount=日期未知批次数;总批次=known+unknown,只用于文案,不参与比值。 // 这里刻意让「分子 3 / 分母 70 / 日期未知 5」三者互不相同,任何把分子当分母或拿总数顶替的写法都会露馅。 const stagnantBase = { stagnantRate: 4.29, stagnantDays: 180, stagnantBatchCount: 3, stagnantKnownBatchCount: 70, stagnantUnknownBatchCount: 5 }; assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal' }).color, '#3BD48A', 'normal 必须映射绿色'); assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal' }).status, '正常', 'normal 文案为“正常”'); assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'near' }).color, '#F0A03A', 'near 必须映射橙色'); assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'near' }).status, '接近阈值', 'near 文案为“接近阈值”'); assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'alert' }).color, '#F25E5E', 'alert 必须映射红色'); assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'alert' }).status, '呆滞预警', 'alert 文案为“呆滞预警”'); // 红优先:占比 <10% 但后端因环比入口级判 alert 时,前端不得用 8%/10% 把红色改回橙色。 assert.equal(stagnantKpi({ ...stagnantBase, stagnantRate: 8.2, stagnantLevel: 'alert', stagnantChange: 0.6 }).color, '#F25E5E', '分级以后端为准,alert 不得被前端重算成橙色'); // +0.5pp 入口级:占比仅 8.2%(未到 10%),后端因环比抬升 >= +0.5pp 判 near,前端必须照映射走橙色与“接近阈值”。 // 这条坏了意味着环比入口级在后端生效、前端却按 8%/10% 把它显示成绿色「正常」。 const stagnantEntry = stagnantKpi({ ...stagnantBase, stagnantRate: 8.2, stagnantLevel: 'near', stagnantChange: 0.5, stagnantBaseDate: '2026-09-30' }); assert.equal(stagnantEntry.color, '#F0A03A', '+0.5pp 入口级必须走橙色,不得由前端按 8% 自行判绿'); assert.equal(stagnantEntry.status, '接近阈值', '+0.5pp 入口级文案为“接近阈值”'); assert.match(stagnantEntry.delta, /较上月 \+0\.50pp/, '入口级差值必须原样展示 +0.50pp'); const stagnantUnknown = stagnantKpi({ ...stagnantBase, stagnantLevel: null }); assert.equal(stagnantUnknown.color, '#9FB8D4', '分级缺失必须回落中性色,不能凭空判定健康度'); assert.equal(stagnantUnknown.status, '', '分级缺失时状态文案留空,不得由前端补造'); // 环比单位 pp:有值带符号 + pp 单位 + 基准日末;上升(恶化)走 bad、下降走 good、0 持平,与同屏周转卡一致。 const stagnantUp = stagnantKpi({ ...stagnantBase, stagnantLevel: 'alert', stagnantChange: 0.6, stagnantBaseDate: '2026-09-30' }); assert.match(stagnantUp.delta, /较上月 \+0\.60pp/, '环比必须带正号与 pp 单位'); assert.match(stagnantUp.delta, /2026-09-30 日末/, '环比必须标注上月最后一个完整日结快照日期'); assert.equal(stagnantUp.deltaTone, 'bad', '呆滞占比上升是恶化,与同屏周转卡统一走 bad(红)'); const stagnantDown = stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: -1.2, stagnantBaseDate: '2026-09-30' }); assert.match(stagnantDown.delta, /较上月 -1\.20pp/, '下降必须带负号,不能取绝对值'); assert.equal(stagnantDown.deltaTone, 'good', '占比下降是好转,走绿色语义'); const stagnantFlat = stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: 0, stagnantBaseDate: '2026-09-30' }); assert.equal(stagnantFlat.delta, '较上月持平(基准 2026-09-30 日末)', '差值为 0 只显示持平,不显示 +0.00pp'); assert.equal(stagnantFlat.deltaTone, 'dim', '持平不带健康度色调'); // 环比不可比:只给原因,绝不显示 0 或 +0.00pp;原因按 环比原因 → 口径原因 → 数据待核实 逐级回落。 const stagnantNoChange = stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: null, stagnantChangeReason: '上月无完整日结快照' }); assert.equal(stagnantNoChange.delta, '上月无完整日结快照', '不可比时必须原样展示后端环比原因'); assert.doesNotMatch(stagnantNoChange.delta, /0\.00pp|\+0/, '不可比绝不显示 +0.00pp'); assert.equal(stagnantNoChange.delta === '0' || /\+?0(\.0+)?pp/.test(stagnantNoChange.delta), false, '不可比时不得以 0 或 +0.00pp 顶替'); assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'near', stagnantChange: null }).deltaTone, 'dim', '环比缺失时色调必须中性,不得暗示上升或下降'); assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: null, stagnantReason: '多仓汇总按默认 180 天' }).delta, '多仓汇总按默认 180 天', '环比原因缺失时回落到口径原因'); assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: null }).delta, '数据待核实', '两个原因都缺失时只显示“数据待核实”,不得显示 0'); // 分子/分母不可颠倒:占比复核必须用 呆滞批次数 ÷ 可核实批次数(3÷70=4.29%), // 错用 3÷75 或 70÷75 说明有人把 stagnantBatchCount 当分母、或拿总批次顶替可核实分母。 const stagnantRatio = stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal' }).subtotal.title; assert.match(stagnantRatio, /呆滞 3 批次 ÷ 可核实 70 批次 = 4\.29%/, 'X/Y 必须用 known 作分母:3÷70=4.29%,不是 3÷75 或 70÷75'); assert.doesNotMatch(stagnantRatio, /= 4%/, '不得把 3÷75 的近似值当成复核比值'); assert.doesNotMatch(stagnantRatio, /= 93\.33%/, '不得把 70÷75 当成呆滞占比'); // 分母缺失(Y 缺失)时比值不可计算,只标注信息缺失,绝不拿总批次顶替分母。 const stagnantNoDenominator = stagnantKpi({ stagnantBatchCount: 3, stagnantUnknownBatchCount: 5, stagnantLevel: 'normal' }); assert.match(stagnantNoDenominator.info, /可核实批次数待核实/, '分母缺失必须标注待核实,不能拿 total 冒充可核实分母'); assert.match(stagnantNoDenominator.subtotal.title, /比值不可计算/, '分母缺失时不得给出任何比值'); assert.equal(stagnantNoDenominator.progress, null, '占比缺失时进度条必须为 null,不得用近似值顶替'); // 阈值与可核实分母:info 文案取后端 stagnantDays;缺失时天数位仍是 180 天,但括号注明是默认阈值。 assert.match(stagnantKpi({ ...stagnantBase, stagnantDays: 90, stagnantLevel: 'near' }).info, /库龄 > 90 天且期间无任何出库\/领用动态的批次数 ÷ 可核实批次数/, 'info 阈值必须取后端 stagnantDays'); assert.match(stagnantKpi({ ...stagnantBase, stagnantDays: 90, stagnantLevel: 'near' }).info, /可核实 70\/75 批次/, 'info 必须给出可核实 X/Y 批次(X=可核实,Y=可核实+日期未知)'); assert.match(stagnantKpi({ ...stagnantBase, stagnantDays: null, stagnantLevel: 'near' }).info, /库龄 > 180 天(默认阈值,仓库未配置)且期间无任何出库\/领用动态的批次数/, '阈值缺失时天数位仍是 180 天,来源写在括号里'); assert.doesNotMatch(stagnantKpi({ ...stagnantBase, stagnantDays: null, stagnantLevel: 'near' }).info, /按默认 180 天/, '不得把“按默认”塞进天数位置,形成半截句子'); assert.doesNotMatch(stagnantKpi({ ...stagnantBase, stagnantDays: null, stagnantLevel: 'near' }).info, /\{stagnantDays\}/, 'info 不得漏出占位符字面量'); // 后端字段未上线的过渡态:卡片能渲染、中性色、状态留空、环比为空、不出现 NaN。 const stagnantLegacy = stagnantKpi({}); assert.equal(stagnantLegacy.color, '#9FB8D4', '后端字段缺失时用中性色,不能让卡片崩掉'); assert.equal(stagnantLegacy.status, '', '后端字段缺失时状态留空'); assert.equal(stagnantLegacy.delta, '数据待核实', '后端字段缺失时环比只给“数据待核实”'); assert.equal(stagnantLegacy.progress, null, '占比为空时进度条不渲染,不能用 0 冒充'); assert.match(stagnantLegacy.info, /可核实批次数待核实/, '分母字段缺失时标注待核实'); // 防回归:8%/10% 判级必须留在后端,HomeOverview 不得再对 stagnantRate 做大小比较。 assert.doesNotMatch(overviewSource, /stagnantRate\s*[<>]/, '呆滞分级必须留在后端,前端写回 8%/10% 会让仓库阈值与 +0.5pp 入口级失效'); assert.match(overviewSource, /STAGNANT_LEVEL_STYLE/, '分级映射表必须存在,否则状态与颜色无从映射'); // 分子/分母守卫(源码级):stagnantBatchCount 是分子,不得出现在除号右侧; // 也不得用 known+batchCount 之类的兜底表达式把分子拼进分母。行为级证据是上面的 3÷70=4.29% 断言。 assert.doesNotMatch(overviewSource, /\/\s*s?\.?\s*stagnantBatchCount/, 'stagnantBatchCount 是分子,不得出现在除号右侧当分母'); // 允许 s. 前缀,否则 /s*stagnantBatchCount/ 匹配不到 s.stagnantBatchCount,守卫会空转。 assert.doesNotMatch(overviewSource, /stagnantKnownBatchCount\s*\+\s*(?:s\.)?stagnantBatchCount/, '不得用 known+batchCount 之类表达式掩盖分子/分母错位'); assert.doesNotMatch(overviewSource, /stagnantBatchCount\s*\+\s*(?:s\.)?stagnantUnknownBatchCount/, '总批次只能由 known+unknown 得出,分子不得参与'); assert.match(overviewSource, /stagnantKnownBatchCount/, '可核实批次数必须作为分母被显式读取'); // 口径条占位符:dead 屏把阈值写成 {stagnantDays},由 detail.vue 用同一份 home-summary 阈值替换。 const screenDefs = vm.runInContext('screens', screens); assert.match(screenDefs.dead.subtitle, /\{stagnantDays\}/, 'dead 屏副标题必须使用阈值占位符,不能写死 180 天'); assert.match(screenDefs.dead.definition, /\{stagnantDays\}/, 'dead 屏口径说明必须使用阈值占位符'); assert.doesNotMatch(screenDefs.dead.subtitle, /180/, 'dead 屏文案不得再写死 180 天'); const { value: detail } = load('detail.vue', { ...utils, screens: screenDefs }); const deadReady = detail.computed.current.call({ view: 'dead', stagnantDays: 90 }); assert.match(deadReady.subtitle, /库龄超过 90 天/, '口径条阈值必须来自 stagnantDays'); assert.doesNotMatch(deadReady.subtitle + deadReady.definition, /\{stagnantDays\}/, '渲染结果不得漏出占位符字面量'); const deadFallback = detail.computed.current.call({ view: 'dead', stagnantDays: null }); assert.match(deadFallback.subtitle, /库龄超过 180 天/, '阈值缺失必须回落 180 天'); // 其它屏不含占位符:渲染结果必须与 screens 原文逐字一致,一个字符都不能变。 for (const key of Object.keys(screenDefs)) { if (key === 'dead') continue; const current = detail.computed.current.call({ view: key, stagnantDays: 90 }); assert.equal(current.subtitle, screenDefs[key].subtitle, key + ' 屏副标题不得被阈值替换改动'); assert.equal(current.definition, screenDefs[key].definition, key + ' 屏口径说明不得被阈值替换改动'); } // dashboard.vue 透传:优先用已请求到的 home-summary.stagnantDays,缺失/非法传 null 交给 detail.vue 回落。 // 字段名与后端契约一致(stagnantDays),避免排查时被误读成另一个概念。 assert.equal(page.computed.stagnantDays.call({ summary: { stagnantDays: 90 } }), 90, '必须把后端阈值透传给口径条'); assert.equal(page.computed.stagnantDays.call({ summary: {} }), null, '阈值缺失要传 null,不能伪装成 180'); assert.equal(page.computed.stagnantDays.call({ summary: { stagnantDays: 'abc' } }), null, '阈值非法要传 null'); assert.equal(page.computed.stagnantDays.call({}), null, 'summary 未加载时也要安全返回 null'); // 命名守卫:源码与脚本里都不得残留 staleDays,防止两个概念混用。 assert.doesNotMatch(fs.readFileSync(path.join(folder, 'detail.vue'), 'utf8'), /staleDays|stale-days/, 'detail.vue 的 prop 必须叫 stagnantDays'); assert.doesNotMatch(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'), /staleDays|stale-days/, 'dashboard.vue 的透传字段必须叫 stagnantDays'); // 原有系统入口:库存台账直接切路由;“呆滞”等首页视图走内部 route,不经过旧入口映射。 const legacy = { filters: { unit: '吨' }, $router: { push(location) { this.location = location; return Promise.resolve(); } }, route(view, filters) { this.view = view; this.filters = filters; } }; page.methods.open.call(legacy, 'stockLedger'); assert.equal(legacy.$router.location.path, '/warehouseManagement/stockLedger'); page.methods.open.call(legacy, 'dead'); assert.equal(legacy.view, 'dead'); assert.equal(legacy.filters.unit, '吨'); let navigation = 0; let navigationEvent = ''; let navigationTarget = ''; overview.methods.categorySelect.call({ $emit(event, target) { navigation++; navigationEvent = event; navigationTarget = target; } }, { id: 'other' }); assert.equal(navigation, 1, '分类图点击进入库存台账'); assert.equal(navigationEvent, 'open'); assert.equal(navigationTarget, 'stockLedger', '“其他”聚合分组也只进入台账,不伪造具体物料'); // D2 分类分布下钻:非「其他」行必须把分类层级 id 带进台账;「其他」是前端 TOP8 的聚合兜底行 // (id 固定为字符串 'other'),只开台账不带分类,绝不伪造具体物料(与上方既有断言同一条口径)。 const drillCalls = []; const drillEmitter = { unit: '个', $emit(event, target, extra) { drillCalls.push({ event, target, extra }); } }; overview.methods.categorySelect.call(drillEmitter, { id: '1072414', name: '原料', quantity: 120, ratio: 12.5 }); assert.equal(drillCalls.length, 1, '分类图点击只发一次 open'); assert.equal(drillCalls[0].target, 'stockLedger'); assert.equal(drillCalls[0].extra.categoryLevelId, '1072414', '非「其他」行必须带 categoryLevelId 下钻'); assert.deepEqual(Object.keys(drillCalls[0].extra), ['categoryLevelId'], '下钻只传分类层级 id,不携带展示字段'); drillCalls.length = 0; overview.methods.categorySelect.call(drillEmitter, { id: 'other', name: '其他', quantity: 30, ratio: 5 }); assert.equal(drillCalls.length, 1, '「其他」也必须能点开台账'); assert.equal(drillCalls[0].extra, undefined, '「其他」是聚合兜底行,不得带 categoryLevelId 下钻'); drillCalls.length = 0; overview.methods.categorySelect.call(drillEmitter); assert.equal(drillCalls[0].extra, undefined, '没有行数据时退回不过滤,不得伪造 id 也不得抛错'); // dashboard.open('stockLedger', extra):extra 必须经 cleanQuery 合并进 route query, // 否则首屏下钻参数在跳转瞬间就被丢掉,台账只会打开全量列表。 const ledgerState = { filters: { unit: '吨' }, $router: { push(location) { this.location = location; return Promise.resolve(); } }, $message: { error() {} } }; page.methods.open.call(ledgerState, 'stockLedger', { categoryLevelId: '1072414' }); assert.equal(ledgerState.$router.location.path, '/warehouseManagement/stockLedger', '下钻仍复用既有台账路由,path 不得改'); assert.equal(ledgerState.$router.location.query.categoryLevelId, '1072414', 'extra 必须合并进 route query'); assert.deepEqual(Object.keys(ledgerState.$router.location.query), ['categoryLevelId'], '只带下钻参数,不把首页筛选整包塞进台账 query'); const ledgerEmpty = { filters: {}, $router: { push(location) { this.location = location; return Promise.resolve(); } }, $message: { error() {} } }; page.methods.open.call(ledgerEmpty, 'stockLedger'); // 注意 cleanQuery 在 vm 沙箱里执行,返回的是另一个 realm 的对象,deepStrictEqual 会因原型不同而报错, // 所以这里比对键数量而不是对象本身。 assert.equal(Object.keys(ledgerEmpty.$router.location.query).length, 0, '没有 extra 时 query 为空对象,等价于旧的「只跳转不过滤」'); // V2 台账读取下钻参数(静态源码断言):台账页必须自己消费 query.categoryLevelId、递归找树节点并选中, // 找不到时降级为不过滤。三段任一缺失,首页下钻都会静默失效,而静态断言在没有后端数据的环境里也能守住这条链路。 const ledgerSource = fs.readFileSync(path.join(root, 'src/views/warehouseManagement/stockLedger/index.vue'), 'utf8'); assert.match(ledgerSource, /categoryLevelId/, '台账页必须读取下钻参数 categoryLevelId'); assert.match(ledgerSource, /findTreeNode\s*\(/, '台账页必须递归查找分类树节点'); assert.match(ledgerSource, /setCurrentKey\(/, '台账页必须调用 treeRef.setCurrentKey 选中节点'); assert.match(ledgerSource, /\$set\(\s*this\.current\s*,\s*'data'/, 'current 必须补成 { id, data } 供 item-list 读取'); assert.match(ledgerSource, /\[wms-stock-ledger\]/, '台账页日志前缀必须是 [wms-stock-ledger]'); assert.doesNotMatch(ledgerSource, /\[wms-home\]/, '台账页不在首页模块,不得复用 [wms-home] 前缀'); // 分类图 tooltip(静态源码断言):名称/数量/占比 + 下钻提示,单位必须来自 props.unit。 const chartSource = fs.readFileSync(path.join(folder, 'components/DashboardChart.vue'), 'utf8'); assert.match(chartSource, /占比:/, '分类图 tooltip 必须显示占比'); assert.match(chartSource, /点击进入台账/, '分类图 tooltip 必须提示可点击下钻'); assert.match(chartSource, /this\.unit \? ' ' \+ this\.unit : ''/, '单位必须取自 props.unit,为空时不加单位后缀'); console.log('D2 分类分布下钻断言通过:非「其他」行带 categoryLevelId、other 行不带、route query 传递、V2 台账读取与递归查找、分类图 tooltip 单位取 props.unit。'); // 质检通过率卡片:阈值分级(98/95 边界逐位一致)、较上月 pp 上下标识、让步接收小字与空值语义。 // 后端契约:qualityInspectedCount 可能为 0 而 qualityPassRate 为 null,此时必须显示「—」,绝不能渲染成 0%。 const qualityKpi = (summary) => overview.computed.kpis.call(componentState(overview, { summary, unit: '%' })).find(item => item.key === 'quality'); const qualityBase = { qualityPassRate: 98, qualityInspectedCount: 20, qualityPassedCount: 18, qualityConcessionCount: 2, qualityChange: 1.2 }; assert.equal(qualityKpi({ ...qualityBase }).color, '#3BD48A', '98 必须归绿(>=98 达标,边界值不得滑到橙)'); assert.equal(qualityKpi({ ...qualityBase }).status, '达标', '98 状态文案为「达标」'); assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 97.9 }).color, '#F0A03A', '97.9 必须归橙(接近阈值)'); assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 97.9 }).status, '接近阈值', '97.9 状态文案为「接近阈值」'); assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 95 }).color, '#F0A03A', '95 必须归橙(>=95,边界值不得滑到红)'); assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 95 }).status, '接近阈值', '95 状态文案为「接近阈值」'); assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 94.9 }).color, '#F25E5E', '94.9 必须归红(低于目标)'); assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 94.9 }).status, '低于目标', '94.9 状态文案为「低于目标」'); // 无已检批次:通过率为 null 时中性色、状态留空、进度条不渲染,值交给 displayKpi 显示「—」。 const qualityEmpty = qualityKpi({ qualityPassRate: null, qualityInspectedCount: 0, qualityPassedCount: 0, qualityConcessionCount: 0, qualityChange: null, qualityChangeReason: '上月无已检批次' }); assert.equal(qualityEmpty.color, '#9FB8D4', '通过率为 null 必须中性色,不得显示成不达标'); assert.equal(qualityEmpty.status, '', '通过率为 null 时状态文案留空,不得由前端补造'); assert.equal(qualityEmpty.value, null, '通过率为 null 时值为 null,显示「—」而不是 0%'); assert.equal(qualityEmpty.progress, null, '通过率为 null 时进度条不渲染,不能用 0 冒充'); // 较上月 pp:上升为好(good 绿 ▲)、下降为坏(bad 红 ▼)、0 持平(dim)、null 只留占位符与原因。 const qualityUp = qualityKpi({ ...qualityBase, qualityChange: 1.2 }); assert.match(qualityUp.delta, /\+1\.20pp/, '较上月必须复用 formatPpChange 的 pp 文本,不得拼成 %'); assert.match(qualityUp.delta, /▲/, '通过率上升必须有 ▲ 标识'); assert.equal(qualityUp.deltaTone, 'good', '通过率上升是好转,必须与周转/呆滞同一套 good(绿)语义'); const qualityDown = qualityKpi({ ...qualityBase, qualityPassRate: 96, qualityChange: -1.2 }); assert.match(qualityDown.delta, /-1\.20pp/, '下降必须带负号,不能取绝对值'); assert.match(qualityDown.delta, /▼/, '通过率下降必须有 ▼ 标识'); assert.equal(qualityDown.deltaTone, 'bad', '通过率下降是恶化,必须走 bad(红)语义'); const qualityFlat = qualityKpi({ ...qualityBase, qualityChange: 0 }); assert.match(qualityFlat.delta, /持平/, '差值为 0 只显示持平'); assert.doesNotMatch(qualityFlat.delta, /0\.00pp/, '持平不得拼成 +0.00pp'); assert.equal(qualityFlat.deltaTone, 'dim', '持平不带健康度色调'); assert.equal(qualityEmpty.delta, '—', '不可比时显示占位符,绝不显示 0 或「持平」'); assert.equal(qualityEmpty.deltaTone, 'dim', '不可比时不带健康度色调'); assert.match(qualityEmpty.deltaTitle, /上月无已检批次/, '不可比原因必须写进 title'); // 后端字段未上线的过渡态:卡片能渲染、中性色、状态留空、原因缺失时 title 回退「较上月不可比」。 const qualityLegacy = qualityKpi({}); assert.equal(qualityLegacy.color, '#9FB8D4', '后端字段缺失时用中性色,不能让卡片崩掉'); assert.equal(qualityLegacy.status, '', '后端字段缺失时状态留空'); assert.equal(qualityLegacy.delta, '—', '后端字段缺失时较上月只给占位符'); assert.match(qualityLegacy.deltaTitle, /较上月不可比/, '原因缺失时 title 回退「较上月不可比」'); assert.equal(qualityLegacy.subtotal, null, '让步与已检都缺失时整行小字不渲染'); // 让步接收批次单独成列:> 0 才出现,0 或缺失时不出现(不制造「让步接收 0 批」噪音)。 assert.match(qualityKpi({ ...qualityBase, qualityConcessionCount: 2 }).subtotal.text, /让步接收 2 批 · 进分母不计合格/, '让步接收 >0 必须单列批次数'); assert.match(qualityKpi({ ...qualityBase, qualityConcessionCount: 2 }).subtotal.text, /已检 20 批 \/ 合格 18 批/, '小字必须附分子分母便于核对'); assert.doesNotMatch(qualityKpi({ ...qualityBase, qualityConcessionCount: 0 }).subtotal.text, /让步接收/, '让步接收为 0 时不得出现「让步接收 0 批」'); assert.doesNotMatch(qualityKpi({ ...qualityBase, qualityConcessionCount: null }).subtotal.text, /让步接收/, '让步接收缺失时不得出现该段'); assert.doesNotMatch(qualityKpi({ ...qualityBase }).subtotal.title, /undefined|NaN/, '小字 title 不得漏出 undefined/NaN'); // 明细页 KPI 条:qualityChange 走 formatPpChange(自带符号与 pp),单位小字因此不再追加。 const metricLabelsForTest = vm.runInContext('metricLabels', screens); assert.equal(metricLabelsForTest.qualityChange[0], '较上月', '明细页必须给 qualityChange 显示名'); assert.equal(metricLabelsForTest.qualityChange[1], '', 'qualityChange 单位位留空(pp 已由 formatPpChange 拼进数值)'); assert.equal(detail.methods.metricValue.call({ summary: { qualityChange: 1.2 } }, 'qualityChange'), '+1.20pp', '明细页较上月必须带符号与 pp'); assert.equal(detail.methods.metricValue.call({ summary: { qualityChange: -1.2 } }, 'qualityChange'), '-1.20pp', '明细页下降必须带负号'); assert.equal(detail.methods.metricValue.call({ summary: { qualityChange: 0 } }, 'qualityChange'), '持平', '明细页差值为 0 显示持平'); assert.equal(detail.methods.metricValue.call({ summary: { qualityChange: null } }, 'qualityChange'), '—', '明细页不可比显示占位符'); // V10 口径文案与后端口径逐条对齐:分母只含已检,未检/无需质检/来源不可追溯都不进分母,较上月单位 pp。 assert.match(screenDefs.qc.definition, /检验完成日期落在本月/, '口径必须写明检验完成日期落在本月'); assert.match(screenDefs.qc.definition, /让步接收进分母不计合格/, '口径必须写明让步接收进分母不计合格'); assert.match(screenDefs.qc.definition, /无质检要求与来源不可追溯的批次不计入分母/, '口径必须写明未检与不可追溯批次不进分母'); assert.match(screenDefs.qc.definition, /pp/, '较上月单位必须是 pp,不能写成 %'); // 精度措辞必须与 formatPpChange 的固定两位小数一致,否则口径条承诺的位数与卡片实际显示打架。 assert.match(screenDefs.qc.definition, /较上月保留两位小数,单位 pp/, '较上月精度措辞必须与 formatPpChange 的两位小数一致'); assert.doesNotMatch(screenDefs.qc.definition, /至多一位/, '口径条不得再写「至多一位」,卡片实际固定两位小数'); assert.equal(screenDefs.qc.metrics.indexOf('qualityChange'), screenDefs.qc.metrics.indexOf('qualityPassRate') + 1, 'qualityChange 必须紧跟 qualityPassRate'); // qc 明细页时间窗由后端固定(queryHomeDetail 对 qc 强制 YearMonth.now(),传 startTime/endTime 无效), // 因此不得再渲染可编辑的开始/结束日期:改了不生效的筛选比没有筛选更糟。以下为源码级守卫。 const detailSource = fs.readFileSync(path.join(folder, 'detail.vue'), 'utf8'); assert.doesNotMatch(detailSource, /current\.period && view !== 'flow'/, 'qc 必须与其它 period 视图分开,日期筛选不得再对 qc 渲染'); assert.match(detailSource, /v-if="current\.period && !\['flow', 'qc'\]\.includes\(view\)"/, '开始/结束日期条件必须显式排除 qc,其它 period 视图保持原样'); assert.match(detailSource, /v-if="view === 'qc'"[\s\S]{0,200}检验完成日期/, 'qc 必须给出只读的时间窗口径提示(说明检验完成日期落在本月)'); assert.match(detailSource, /本页时间窗由后端固定,不接受自定义区间/, '只读提示的 title 必须说明时间窗由后端固定、不接受自定义区间'); assert.doesNotMatch(detailSource, /v-if="view === 'qc'"[^>]*>0 时出现、明细页 pp 展示与 V10 口径文案。'); // ===== D1「出入库动态图」:近 30 天滚动窗口、缺数据 null、无作业日、连续同号风险、点击下钻 ===== assert.equal(utilConst('TREND_WINDOW_DAYS'), 30, 'D1 窗口固定为近 30 天滚动(含今日)'); assert.equal(utilConst('TREND_RISK_MIN_DAYS'), 2, '连续同号 >=2 天才提示风险'); // 窗口:30 天、升序、含今日,跨月跨年都连续且不重复。 const tradeWindow = utils.trendWindowDates(new Date(2026, 9, 30)); assert.equal(tradeWindow.length, 30, 'D1 窗口必须恰好 30 天(含今日)'); assert.equal(tradeWindow[0], '2026-10-01', '窗口第一天=今日往前 29 天'); assert.equal(tradeWindow[29], '2026-10-30', '窗口最后一天必须是今日'); assert.equal(JSON.stringify([...tradeWindow].sort()), JSON.stringify(tradeWindow), '窗口必须按日期升序'); assert.equal(new Set(tradeWindow).size, 30, '窗口不得出现重复日期'); const yearWindow = utils.trendWindowDates(new Date(2026, 0, 5)); assert.equal(yearWindow[0], '2025-12-07', '跨年窗口必须正确回退到上一年'); assert.equal(yearWindow[29], '2026-01-05', '跨年窗口最后一天=今日'); // 缺数据必须补 null:后端只返回有作业的日期,缺日绝不能用 0 冒充。 const gapWindow = utils.buildTrendWindow([{ date: '2026-10-30', inbound: 12, outbound: 5 }], new Date(2026, 9, 30)); assert.equal(gapWindow.length, 30); assert.equal(gapWindow[29].inbound, 12); assert.equal(gapWindow[29].net, 7, '单日净差=入库−出库'); assert.equal(gapWindow[28].inbound, null, '缺数据日必须补 null,不能用 0 冒充'); assert.equal(gapWindow[28].outbound, null, '缺数据日必须补 null,不能用 0 冒充'); assert.equal(gapWindow[28].net, null, '无作业日没有净差,必须是 null 而不是 0'); assert.equal(gapWindow[28].idle, true, '两侧都无数据=无作业日'); assert.equal(gapWindow[29].idle, false, '单侧有数据不算无作业日'); // 无作业判定:两侧皆空=true;任一方向有数据(含真实的 0)=false。 assert.equal(utils.isIdleTrendDay({}), true, '两侧都无数据=无作业日'); assert.equal(utils.isIdleTrendDay({ inbound: null, outbound: null }), true); assert.equal(utils.isIdleTrendDay({ inbound: 0, outbound: null }), false, '入库 0 是真实数据,不能当无作业日'); assert.equal(utils.isIdleTrendDay({ inbound: null, outbound: 8 }), false, '只有出库也算有作业'); assert.equal(utils.isIdleTrendDay({ inbound: '3', outbound: '1' }), false, '字符串数字按有数据解析'); // 净差方向固定「入库 − 出库」,能取负值。 assert.equal(utils.trendNetOf(10, 4), 6); assert.equal(utils.trendNetOf(4, 10), -6, '净差必须能取负值,不能取绝对值'); assert.equal(utils.trendNetOf(null, 6), -6, '只有出库时净差为负'); assert.equal(utils.trendNetOf(null, null), null, '两侧都无数据时净差为 null'); // 连续同号段:末尾无作业日跳过、0 与中间空日打断、符号翻转只算最近一段。 const streakOf = (nets) => utils.trendStreak(nets.map((net, index) => ({ date: '2026-10-' + String(index + 1).padStart(2, '0'), net }))); // vm 里的数组/对象来自另一个 realm,原型不同,deepEqual 会假失败,这里按 JSON 逐字比较。 assert.equal(JSON.stringify(streakOf([1, 2, 3])), JSON.stringify({ direction: 1, days: 3, start: '2026-10-01', end: '2026-10-03' }), '连续为正必须累计 3 天并给出起止日'); assert.equal(JSON.stringify(streakOf([-1, -2, -3])), JSON.stringify({ direction: -1, days: 3, start: '2026-10-01', end: '2026-10-03' }), '连续为负同样累计 3 天'); assert.equal(streakOf([1, -2, -3]).days, 2, '符号翻转只算窗口末尾那一段'); assert.equal(streakOf([1, -2, -3]).direction, -1); assert.equal(streakOf([5, 4, 0]).days, 0, '末尾持平的 0 不构成同号段'); assert.equal(streakOf([5, 4, null]).days, 2, '末尾无作业日跳过,从最近一个有数据的日子起算'); assert.equal(streakOf([5, null, 4]).days, 1, '中间的无作业日打断连续段,不能跨空日断言'); assert.equal(streakOf([0, 0, 0]).days, 0, '连续持平不构成同号段'); assert.equal(streakOf([null, null]).days, 0, '整个窗口无作业时没有连续段'); assert.equal(streakOf([1]).days, 1, '单日同号只有 1 天'); // 风险文案方向:连续为正=积压风险,连续为负=缺货风险,不足 2 天不提示。 const riskUp = utils.trendRisk(streakOf([1, 2, 3])); assert.match(riskUp.text, /积压风险/, '连续为正必须提示积压风险'); assert.doesNotMatch(riskUp.text, /缺货/, '积压风险不得写成缺货风险'); assert.match(riskUp.text, /连续 3 天/, '风险文案必须写明连续天数'); assert.equal(riskUp.tone, 'warn', '积压风险走橙色语义'); assert.match(riskUp.title, /2026-10-01 ~ 2026-10-03/, '风险 title 必须给连续段起止日'); const riskDown = utils.trendRisk(streakOf([-1, -2])); assert.match(riskDown.text, /缺货风险/, '连续为负必须提示缺货风险'); assert.doesNotMatch(riskDown.text, /积压/, '缺货风险不得写成积压风险'); assert.equal(riskDown.tone, 'danger', '缺货风险走红色语义'); assert.equal(utils.trendRisk(streakOf([1])), null, '连续 1 天不提示风险'); assert.equal(utils.trendRisk(streakOf([0, 0])), null, '连续持平不提示风险'); assert.equal(utils.trendRisk(streakOf([null, null])), null, '无作业日不提示风险'); // 组件层:趋势计算属性固定输出 30 行,连续 3 天净入库必须出积压风险提示,无流水时保留空态。 const windowDates = utils.trendWindowDates(); const trendFixture = [ { date: windowDates[27], inbound: 10, outbound: 2 }, { date: windowDates[28], inbound: 8, outbound: 1 }, { date: windowDates[29], inbound: 5, outbound: 0 } ]; const trendState = componentState(overview, { summary: { trend: trendFixture }, unit: '个' }); const trendRows = trendState.trend; assert.equal(trendRows.length, 30, '趋势计算属性必须固定输出 30 行'); assert.equal(trendRows[29].date, windowDates[29], '窗口最后一天必须是今日'); assert.equal(trendRows[29].net, 5, '净差=当日入库 5 − 当日出库 0'); assert.equal(trendRows[0].inbound, null, '缺数据日必须是 null 而不是 0'); assert.equal(trendRows[0].idle, true, '缺数据日必须标记为无作业日'); assert.match(trendState.trendRiskHint.text, /积压风险/, '连续 3 天净入库必须提示积压风险'); assert.equal(trendState.trendRiskHint.tone, 'warn'); assert.equal(overview.computed.trend.call(componentState(overview, { summary: {} })).length, 0, '没有任何流水时保留空态,不能硬凑 30 行空柱'); assert.match(overviewSource, /近 30 天滚动窗口/, '副标必须明确写「近 30 天滚动窗口」'); assert.match(overviewSource, /trendWindow\.start[\s\S]{0,40}trendWindow\.end/, '副标必须给出窗口起止日期'); // 点击柱体(含无作业日)→ 打开 V4 出入库动态并带入该日整日区间。 const drill = { $emit(event, target, payload) { this.event = event; this.target = target; this.payload = payload; } }; overview.methods.trendSelect.call(drill, trendRows[0]); assert.equal(drill.target, 'io', '点击柱体必须打开 V4 出入库动态'); assert.equal(drill.payload.startTime, trendRows[0].date + ' 00:00:00', '必须带入该日 00:00:00'); assert.equal(drill.payload.endTime, trendRows[0].date + ' 23:59:59', '必须带入该日 23:59:59'); assert.equal(trendRows[0].idle, true, '无作业日同样必须能按该日下钻(列表为空态)'); // 图表 option:净趋势紫色虚线 + 右轴单日净差、无作业日 markArea 底纹、tooltip 明写「该日无作业」。 let chartOption = null; let trendClickHandler = null; let pixelIndex = 0; const chartStub = { setOption(option) { chartOption = option; }, on() {}, getZr() { return { on(name, handler) { if (name === 'click') trendClickHandler = handler; } }; }, containPixel() { return true; }, convertFromPixel() { return [pixelIndex, 1]; }, resize() {}, dispose() {} }; const { value: chart } = load('components/DashboardChart.vue', { echarts: { init: () => chartStub, graphic: { LinearGradient: function LinearGradient() {} } }, isIdleTrendDay: utils.isIdleTrendDay, trendQuantity: utils.trendQuantity, agingColorOf: utils.agingColorOf, formatAgingRatio: utils.formatAgingRatio, isAgingUnknown: utils.isAgingUnknown }); // 真实实例上 methods 混入 this;这里同样摊进去,render 里才能调用 this.trendOption。 const chartState = { ...chart.methods, chart: chartStub, kind: 'trend', unit: '个', rows: trendRows, events: [], $emit(event, payload) { this.events.push([event, payload]); } }; chart.methods.render.call(chartState); assert.ok(chartOption, 'render 必须把 option 交给 echarts'); assert.equal(Array.isArray(chartOption.yAxis), true, 'D1 必须是双 Y 轴(数量 + 单日净差)'); assert.equal(chartOption.yAxis.length, 2, 'D1 必须恰好两个 Y 轴'); assert.match(String(chartOption.yAxis[0].name), /数量/, '左轴必须是数量轴'); assert.match(String(chartOption.yAxis[1].name), /单日净差/, '右轴必须明确命名为单日净差'); const netSeries = chartOption.series.filter((series) => series.name === '净趋势')[0]; assert.equal(netSeries.yAxisIndex, 1, '净趋势必须绑右轴(净差刻度不能与数量刻度共用)'); assert.equal(netSeries.lineStyle.type, 'dashed', '净趋势必须是虚线'); assert.equal(netSeries.lineStyle.color, '#9B7DF7', '净趋势必须是紫色 #9B7DF7'); assert.equal(netSeries.data[0], null, '无作业日的净趋势必须是 null(断线),不能画成 0'); const barSeries = chartOption.series.filter((series) => series.name === '入库量')[0]; assert.equal(barSeries.data[0], null, '无作业日不画柱:入库必须是 null 而不是 0'); assert.equal(barSeries.markArea.data.length, 27, '前 27 天无作业必须各有一块 markArea 底纹'); assert.match(String(barSeries.markArea.itemStyle.color), /rgba\(159,184,212/, '无作业日底纹必须是浅灰'); const idleTip = chartOption.tooltip.formatter([{ dataIndex: 0 }]); assert.match(idleTip, /该日无作业/, '无作业日 hover 必须提示「该日无作业」'); // 空日系列值全是 null,ECharts 可能只给 axisValue 不给 dataIndex,这里必须仍能认出无作业日。 assert.match(chartOption.tooltip.formatter([{ axisValue: trendRows[0].date }]), /该日无作业/, '缺 dataIndex 时也必须靠日期认出无作业日'); assert.doesNotMatch(idleTip, /入库量/, '无作业日不显示入库/出库行,避免读成 0'); const workTip = chartOption.tooltip.formatter([{ dataIndex: 29 }]); assert.match(workTip, /入库量:5/, '有作业日必须给出入库量'); assert.match(workTip, /单日净差:\+5/, '有作业日必须给出带符号的单日净差'); // 无作业占位系列(防回归):轴触发 tooltip 会丢掉该 dataIndex 上取值为 null 的系列 // (echarts axisPointer/axisTrigger.js 的 buildPayloadsBySeries 对 null/NaN 直接 return), // 空日若没有任何非 null 数据点,formatter 只会收到空数组,「该日无作业」永远出不来。 const placeholderSeries = chartOption.series.filter((series) => series.name === '无作业占位')[0]; assert.ok(placeholderSeries, '必须存在无作业占位系列,否则空日没有可命中的 dataIndex,tooltip 不会出现'); assert.equal(placeholderSeries.type, 'bar', '占位系列用柱体:数量轴基线本就是 0,占位 0 不改变刻度'); assert.equal(placeholderSeries.yAxisIndex, 0, '占位系列必须绑左轴(数量轴),不得污染右侧净差轴'); assert.equal(placeholderSeries.silent, true, '占位系列必须 silent,不能拦截 zr 点击下钻'); assert.equal(placeholderSeries.itemStyle.opacity, 0, '占位系列必须完全透明,不能画出多余的柱体'); trendRows.forEach((row, index) => { const hitCount = chartOption.series.filter((series) => series.data[index] != null).length; if (row.idle) assert.ok(hitCount > 0, '无作业日在 dataIndex ' + index + ' 没有任何非 null 数据点:空日没有可命中的 dataIndex,tooltip 不会出现'); assert.equal(placeholderSeries.data[index], row.idle ? 0 : null, '占位值必须只出现在无作业日(第 ' + index + ' 天:' + row.date + '),正常日给 0 会让 tooltip 多出一行'); if (!row.idle) assert.equal(chartOption.series.filter((series) => series.name !== '无作业占位').some((series) => series.data[index] != null), true, '有作业日必须由真实系列提供数据点'); }); // 占位 0 绝不能出现在提示里:空日仍只返回「该日无作业」一行。 const placeholderTip = chartOption.tooltip.formatter([{ dataIndex: 0, seriesName: '无作业占位', value: 0 }]); assert.match(placeholderTip, /该日无作业/, '占位系列命中时也必须提示「该日无作业」'); assert.doesNotMatch(placeholderTip, /入库量|出库量|单日净差/, '占位 0 不得被渲染成入库/出库/净差行'); assert.doesNotMatch(placeholderTip, /:0(?:[^0-9]|$)/, '占位值 0 不得出现在 tooltip 文本里'); // 点击:zr 点击 → 类别下标 → $emit,无作业日没有柱体也能命中。 chart.methods.bindTrendClick.call(chartState); assert.equal(typeof trendClickHandler, 'function', '趋势图必须注册 zr 点击,无作业日没有柱体也能下钻'); trendClickHandler({ offsetX: 10, offsetY: 10 }); assert.equal(chartState.events.length, 1); assert.equal(chartState.events[0][0], 'select'); assert.equal(chartState.events[0][1].idle, true, 'zr 点击必须选中该无作业日'); pixelIndex = 29; trendClickHandler({ offsetX: 10, offsetY: 10 }); assert.equal(chartState.events[1][1].date, windowDates[29], 'zr 点击必须命中被点的那个日期'); console.log('D1 出入库动态断言通过:近 30 天滚动窗口(含今日、升序、跨年)、缺数据补 null 非 0、无作业判定(两侧皆空)、连续同号段(0 与空日打断)、积压/缺货风险方向、双 Y 轴 + #9B7DF7 虚线净趋势 + markArea 底纹 + 「该日无作业」tooltip、柱体与无作业日点击带日期跳 V4。'); // 日志:窗口构建与下钻都必须留 [wms-home] 痕迹,且只记关键字段与耗时、不打印整包响应。 // homeLog 绑定在 dashboard-utils 的上下文里,所以用带 recording console 的上下文重载一次再断言。 const logs = []; const { context: utilsLogged } = load('dashboard-utils.js', { console: { log: (...args) => logs.push(args), warn: (...args) => logs.push(args) } }); const { value: overviewLogged } = load('components/HomeOverview.vue', { DashboardChart: {}, ...utilsLogged, STAGNANT_DEFAULT_DAYS }); const loggedState = componentState(overviewLogged, { summary: { trend: trendFixture }, unit: '个' }); void loggedState.trend; assert.equal(logs.length, 1, '构建 30 天窗口必须打一条 [wms-home] 日志'); assert.equal(logs[0][0], '[wms-home]', '日志前缀必须是 [wms-home]'); assert.match(logs[0][1], /D1 出入库动态窗口/, '日志必须标明是 D1 窗口构建'); assert.equal(logs[0][2].windowDays, 30, '日志必须记录窗口天数'); assert.equal(logs[0][2].sourceRows, 3, '日志必须记录后端来源行数'); assert.equal(logs[0][2].idleDays, 27, '日志必须记录无作业天数'); assert.equal(typeof logs[0][2].costMs, 'number', '日志必须记录构建耗时'); void loggedState.trend; assert.equal(logs.length, 1, '窗口未变化时不得重复刷日志'); overviewLogged.methods.trendSelect.call({ $emit() {} }, trendRows[0]); assert.equal(logs.length, 2, '下钻必须留日志'); assert.match(logs[1][1], /D1 出入库动态下钻/); assert.equal(logs[1][2].date, trendRows[0].date, '下钻日志必须记录日期'); assert.equal(logs[1][2].idle, true, '下钻日志必须记录是否无作业日'); // ===== E1「库龄结构」:批次库龄分段、四段占比、平均库龄较上月、>180 联动呆滞 ===== // 分段 key / label / ratio 全部由后端下发,前端只分拣、配色与格式化: // 一旦这里开始复算占比或重算库龄,图上百分比就会与后端 summary 分叉。 assert.equal(utilConst('AGING_STAGNANT_SEGMENT_DAYS'), 180, '>180 段的库龄边界必须与呆滞默认阈值同界(180 天)'); // ratio 为 null=后端判定「总量非正不可算」,必须给占位符;渲染成 0% 会被读成「这段真的没有份额」。 assert.equal(utils.formatAgingRatio(42.1), '42.1%', '占比必须原样格式化后端 ratio'); assert.equal(utils.formatAgingRatio('12'), '12%'); assert.equal(utils.formatAgingRatio(null), '—', 'ratio 为 null 必须给占位符,不能用 0% 冒充'); assert.equal(utils.formatAgingRatio(''), '—'); // 精度必须与后端一致(后端 ratio 是两位小数):前端若截到 1 位,四段各自四舍五入后 // 页面上可见的合计会变成 99.9%/100.1%(33.33/33.33/33.34 → 33.3/33.3/33.3=99.9), // 与「四段占比合计 100%」的口径自相矛盾。 assert.equal(utils.formatAgingRatio(33.34), '33.34%', '占比必须保留后端两位小数,否则四段可见合计不为 100%'); assert.equal(utils.formatAgingRatio(33.33), '33.33%'); assert.equal(utils.formatAgingRatio(0.01), '0.01%', '最小占比不得被截断成 0%'); // 下面 33.33/33.33/33.34 这一组是「后端做最大余数配平后的期望形态」,不是前端自己配平出来的数: // 后端保证 unknown=0 时四段合计恰为 100.00,前端只负责逐段原样显示这两位小数。 const thirdSplit = [33.33, 33.33, 33.34].map((value) => utils.formatAgingRatio(value).replace('%', '')); assert.equal(thirdSplit.join('|'), '33.33|33.33|33.34', '后端配平后的两位小数必须原样展示,不得二次取整'); assert.equal(Math.round(thirdSplit.reduce((sum, value) => sum + Number(value), 0) * 100) / 100, 100, '后端配平后的四段可见合计必须正好 100,不能因前端截断变成 99.9'); // 走页面同一条路径(buildAgingSegments → ratioText)再验一次:占比条逐段保留两位小数,真实 0 仍显示 0%。 const preciseBuilt = utils.buildAgingSegments([ { key: '0-30', label: '0-30天', quantity: 1, ratio: 33.33 }, { key: '31-90', label: '31-90天', quantity: 1, ratio: 33.33 }, { key: '91-180', label: '91-180天', quantity: 1, ratio: 33.34 }, { key: '180+', label: '>180天', quantity: 0, ratio: 0 } ]); assert.equal(preciseBuilt.segments.map((item) => item.ratioText).join('|'), '33.33%|33.33%|33.34%|0%', '占比条必须逐段保留后端两位小数(真实 0 仍显示 0%)'); assert.equal(preciseBuilt.ratioSum, 100, '后端配平后四段合计必须正好 100'); // 前端不参与配平、也不抹平差异:后端若给出 99.99,页面与日志都照实反映 99.99(差异留给后端修), // 一旦这里被改成 100,说明有人把「配平」搬进了前端,占比复算这条红线就被破了。 const unbalancedBuilt = utils.buildAgingSegments([ { key: '0-30', label: '0-30天', quantity: 1, ratio: 33.33 }, { key: '31-90', label: '31-90天', quantity: 1, ratio: 33.33 }, { key: '91-180', label: '91-180天', quantity: 1, ratio: 33.33 }, { key: '180+', label: '>180天', quantity: 0, ratio: 0 } ]); assert.equal(unbalancedBuilt.ratioSum, 99.99, '前端不得把后端给的合计配平成 100'); assert.equal(unbalancedBuilt.segments.map((item) => item.ratioText).join('|'), '33.33%|33.33%|33.33%|0%', '前端逐段原样展示后端 ratio,不做配平'); assert.equal(utils.formatAverageAgeDays(52.5), '52.5 天', '平均库龄保留后端给出的一位小数'); assert.equal(utils.formatAverageAgeDays(null), '—', '平均库龄不可算时必须给占位符,不能显示 0 天'); // 段边界:180 天属于 91-180 段,只有 >180 才是呆滞候选;日期待核实(null/空)不染红也不给处置入口。 assert.equal(utils.isStagnantAge(180), false, '180 天属于 91-180 段,不得提前染红'); assert.equal(utils.isStagnantAge(181), true, '>180 天才是与呆滞联动的红色段'); assert.equal(utils.isStagnantAge(null), false, '日期待核实不参与红色强调'); assert.equal(utils.isStagnantAge(''), false); // 分拣:后端行序被打乱也要按固定 key 顺序出图例;unknown 单独一行且不参与四段占比。 // 刻意让数量份额与后端 ratio 不一致(180+ 数量 12/105≈11.4% 但 ratio=10),任何前端复算都会露馅。 const agingFixture = [ { key: '180+', label: '>180天', quantity: 12, ratio: 10 }, { key: '0-30', label: '0-30天', quantity: 42, ratio: 42.1 }, { key: 'unknown', label: '日期待核实', quantity: 5, ratio: 4.8 }, { key: '91-180', label: '91-180天', quantity: 20, ratio: 22.9 }, { key: '31-90', label: '31-90天', quantity: 26, ratio: 25 } ]; const agingBuilt = utils.buildAgingSegments(agingFixture); assert.equal(agingBuilt.segments.map((item) => item.key).join(','), '0-30,31-90,91-180,180+', '四段必须按固定 key 顺序排列,不随后端返回顺序漂移'); assert.equal(agingBuilt.segments[3].color, '#F25E5E', '>180 段必须固定红色(与呆滞 alert 同色阶)'); assert.equal(agingBuilt.segments[3].stagnant, true, '>180 段必须标记为呆滞联动段'); assert.equal(agingBuilt.segments[3].label, '>180天', 'label 必须用后端原文'); assert.equal(agingBuilt.unknown.color, '#7E93AD', 'unknown 段必须是中性灰,不属于四段色阶'); assert.equal(agingBuilt.unknown.ratioText, '不参与四段占比', 'unknown 必须明说不参与四段占比'); assert.equal(agingBuilt.ratioSum, 100, '四段占比合计必须是后端给的 100%'); assert.equal(agingBuilt.hasUnknown, true); assert.equal(agingBuilt.rowCount, 5, '库龄结构固定 5 行(四段 + 日期待核实)'); assert.equal(utils.buildAgingSegments([{ key: 'expired', quantity: 1 }]).unexpectedKeys.join(','), 'expired', '未识别分段必须被记录,不能静默当成一段'); const utilsSource = fs.readFileSync(path.join(folder, 'dashboard-utils.js'), 'utf8'); assert.doesNotMatch(utilsSource, /quantity\s*\/\s*total/, '库龄占比不得在前端用数量÷总量复算'); // 首页卡片:四段占比逐项展示后端 ratio;>180 段红显;平均库龄与较上月角标按天数差着色。 const agingState = componentState(overview, { summary: { aging: agingFixture, averageAgeDays: 52.5, averageAgeChange: -4 }, unit: '个' }); assert.equal(agingState.agingRatio.length, 4, '占比条只列四段'); assert.equal(agingState.agingRatio.map((item) => item.ratioText).join('|'), '42.1%|25%|22.9%|10%', '占比必须原样展示后端 ratio,不得按数量复算(数量份额与 ratio 刻意不一致)'); assert.equal(agingState.agingRatio.filter((item) => item.stagnant).length, 1, '四段里只有 >180 段带红色强调'); assert.equal(agingState.agingUnknown.ratioText, '不参与四段占比', 'unknown 不混进四段占比'); assert.equal(agingState.averageAge.text, '52.5 天', '副标必须展示后端平均库龄'); assert.equal(agingState.averageAgeChange.text, '较上月 -4 天', '较上月必须给天数差,负数带负号'); assert.equal(agingState.averageAgeChange.tone, 'good', '平均库龄下降=库存变“新”,必须走绿色(向好)'); const agingUp = componentState(overview, { summary: { aging: agingFixture, averageAgeDays: 60, averageAgeChange: 3.5 } }); assert.match(agingUp.averageAgeChange.text, /较上月 \+3\.5 天/, '上升必须带正号'); assert.equal(agingUp.averageAgeChange.tone, 'bad', '平均库龄上升=变“老”,必须走红色'); const agingFlat = componentState(overview, { summary: { aging: agingFixture, averageAgeDays: 60, averageAgeChange: 0 } }); assert.equal(agingFlat.averageAgeChange.text, '较上月持平', '差值为 0 只显示持平,不显示 0 天'); assert.equal(agingFlat.averageAgeChange.tone, 'dim', '持平不带健康度色调'); // 无基准:不渲染角标数值,只给后端原因;平均库龄不可算时同样给占位符,绝不显示 0 天。 const agingNoBase = componentState(overview, { summary: { aging: agingFixture, averageAgeDays: null, averageAgeChange: null, averageAgeChangeReason: '上月无日末快照' } }); assert.equal(agingNoBase.averageAgeChange.text, '', '无基准时不得渲染角标数值'); assert.match(agingNoBase.averageAgeChange.title, /上月无日末快照/, '不可比时必须原样展示后端原因'); assert.equal(agingNoBase.averageAge.text, '—', '平均库龄不可算显示占位符'); assert.doesNotMatch(JSON.stringify(agingNoBase.averageAgeChange), /0 天/, '无基准绝不显示成 0 天'); assert.doesNotMatch(agingNoBase.averageAge.title, /undefined/, '原因缺失时不得漏出 undefined'); // 日志:库龄结构解析只打一条汇总(五行数量、四段占比合计、平均库龄、是否含 unknown),数值不变不重复刷。 logs.length = 0; const agingLogState = componentState(overviewLogged, { summary: { aging: agingFixture, averageAgeDays: 52.5, averageAgeChange: -4 }, unit: '个' }); void agingLogState.agingRatio; assert.equal(logs.length, 1, '库龄结构解析必须只打一条汇总日志'); assert.equal(logs[0][0], '[wms-home]', '日志前缀必须是 [wms-home]'); assert.match(logs[0][1], /E1 库龄结构/, '日志必须标明是 E1 库龄结构'); assert.equal(logs[0][2].rows, 5, '汇总日志必须记录五行数量'); assert.equal(logs[0][2].ratioSum, 100, '汇总日志必须记录四段占比合计'); assert.equal(logs[0][2].averageAgeDays, 52.5, '汇总日志必须记录平均库龄'); assert.equal(logs[0][2].hasUnknown, true, '汇总日志必须记录是否含 unknown'); assert.equal('segments' in logs[0][2], false, '汇总日志不得逐行打印分段明细'); void agingLogState.agingRatio; void agingLogState.agingUnknown; assert.equal(logs.length, 1, '数值未变时不得重复刷库龄日志'); // 图例文案必须来自后端 label(含「>180天」),占比与下钻 key 一并带出。 logs.length = 0; const ageDrill = { $emit(event, target2, payload) { this.event = event; this.target2 = target2; this.payload = payload; } }; overviewLogged.methods.ageSelect.call(ageDrill, agingFixture[0]); assert.equal(ageDrill.target2, 'age', '点击分段必须进 V6 批次库龄明细'); assert.equal(ageDrill.payload.age, '180+', '下钻必须带后端分段 key'); assert.equal(logs.length, 1, '下钻必须留一条 [wms-home] 日志'); assert.match(logs[0][1], /E1 库龄结构下钻/, '下钻日志必须标明是 E1 库龄结构'); assert.equal(logs[0][2].key, '180+'); // 库龄环形图:分段配色(>180 红、unknown 灰)、后端 label、unknown 不给占比数字、点击带 key。 let agingOption = null; let agingClick = null; const agingStub = { setOption(option) { agingOption = option; }, on(name, handler) { if (name === 'click') agingClick = handler; }, getZr() { return { on() {} }; }, resize() {}, dispose() {} }; const { value: agingChart } = load('components/DashboardChart.vue', { echarts: { init: () => agingStub, graphic: { LinearGradient: function LinearGradient() {} } }, isIdleTrendDay: utils.isIdleTrendDay, trendQuantity: utils.trendQuantity, agingColorOf: utils.agingColorOf, formatAgingRatio: utils.formatAgingRatio, isAgingUnknown: utils.isAgingUnknown }); const agingChartState = { ...agingChart.methods, chart: agingStub, kind: 'aging', unit: '个', rows: agingFixture, events: [], $emit(event, payload) { this.events.push([event, payload]); } }; agingChart.methods.render.call(agingChartState); assert.ok(agingOption, '库龄图 render 必须把 option 交给 echarts'); assert.equal(agingOption.series[0].type, 'pie', '库龄结构必须是环形图'); // 配色按分段 key 断言(扇形顺序=后端行序,用于保持 dataIndex 与下钻 row 一一对应,故不能按下标硬编颜色)。 const colorByLabel = {}; agingOption.series[0].data.forEach((item) => { colorByLabel[item.name] = item.itemStyle.color; }); assert.equal(colorByLabel['>180天'], '#F25E5E', '>180 段必须红色(与呆滞 alert 同色阶)'); assert.equal(colorByLabel['91-180天'], '#F0A03A', '91-180 段用顺序色'); assert.equal(colorByLabel['31-90天'], '#2E8BE8', '31-90 段用顺序色'); assert.equal(colorByLabel['0-30天'], '#38C8F5', '0-30 段用顺序色'); assert.equal(colorByLabel['日期待核实'], '#7E93AD', 'unknown 段中性灰,不进四段色阶'); assert.equal(new Set(['0-30天', '31-90天', '91-180天', '>180天'].map((label) => colorByLabel[label])).size, 4, '四段颜色必须互不相同(顺序色阶)'); assert.equal(agingOption.legend.data.join('|'), '>180天|0-30天|日期待核实|91-180天|31-90天', '图例必须用后端 label(含「>180天」)'); const agingTipHot = agingOption.tooltip.formatter({ dataIndex: 0 }); assert.match(agingTipHot, /占比:10%/, '>180 段占比必须原样取后端 ratio'); assert.doesNotMatch(agingTipHot, /11\.4/, '占比不得按数量复算成 11.4%'); assert.match(agingTipHot, /点击查看该段明细/); const agingTipUnknown = agingOption.tooltip.formatter({ dataIndex: 2 }); assert.match(agingTipUnknown, /不参与 0-30 \/ 31-90 \/ 91-180 \/ >180 四段占比/, 'unknown 段 tooltip 必须说明不参与四段占比'); assert.doesNotMatch(agingTipUnknown, /占比:\d/, 'unknown 段 tooltip 不得给出任何占比数字'); agingChart.methods.bindItemClick.call(agingChartState); agingClick({ dataIndex: 0 }); assert.equal(agingChartState.events.length, 1, '点击分段必须发一次 select'); assert.equal(agingChartState.events[0][0], 'select'); assert.equal(agingChartState.events[0][1].key, '180+', '点击 >180 段必须带 key=180+ 下钻'); agingClick({ dataIndex: 2 }); assert.equal(agingChartState.events[1][1].key, 'unknown', '点击日期待核实段同样要能下钻'); // V6 明细:首次入库日期区分「按上线日起算」与「日期待核实」,最后入库日期与负责人照常展示,空值一律「—」。 const detailCell = (row, key) => detail.methods.cell.call({}, row, key); assert.equal(detailCell({ firstInboundTime: null, ageSource: 'warehouse' }, 'firstInboundTime'), '按上线日起算', '期初数据必须显示「按上线日起算」,不能混成「日期待核实」'); assert.equal(detailCell({ firstInboundTime: null, ageSource: null }, 'firstInboundTime'), '日期待核实', '真实缺失才写「日期待核实」'); assert.equal(detailCell({ firstInboundTime: '2026-01-05 10:00:00', ageSource: 'batch' }, 'firstInboundTime'), '2026-01-05 10:00:00', '有真实首次入库日期时原样展示'); assert.equal(detailCell({ lastInboundTime: null }, 'lastInboundTime'), '—', '最后入库日期缺失必须给占位符,不能用 0 或当天顶替'); assert.equal(detailCell({ lastInboundTime: '2026-09-01 08:00:00' }, 'lastInboundTime'), '2026-09-01 08:00:00'); assert.equal(detailCell({ ownerName: '' }, 'ownerName'), '—', '负责人为空必须给占位符'); assert.equal(detailCell({ ownerName: '张三' }, 'ownerName'), '张三'); // >180 行:红色强调与「建呆滞处置单」入口判定;180 天本身属于 91-180 段,不进红线。 assert.equal(detail.methods.agingStagnant.call({}, { ageDays: 200 }), true, '>180 天行必须触发红色与处置入口'); assert.equal(detail.methods.agingStagnant.call({}, { ageDays: 180 }), false, '180 天属于 91-180 段,不得给呆滞处置入口'); assert.equal(detail.methods.agingStagnant.call({}, {}), false, '日期待核实不染红也不给入口'); // 单元格提示:最后入库日期必须写明「补库不刷新库龄」。 assert.match(detail.methods.cellTitle.call({}, { lastInboundTime: '2026-09-01' }, 'lastInboundTime'), /补库不刷新库龄/); assert.match(detail.methods.cellTitle.call({}, { firstInboundTime: null, ageSource: 'warehouse' }, 'firstInboundTime'), /上线日起算/); // 未来日期的首次入库不参与起算(后端仍给首入库日期 + ageSource=warehouse):列里照实显示日期,提示必须指明按上线日算。 // ageSource='warehouse' 且首次入库日期有值,后端有两种来源:来源不可追溯(provenance_status<>1)、 // 首次入库日期晚于今天。title 必须同时覆盖两者,只写「晚于今天」会把不可追溯误说成日期在未来。 const futureTitle = detail.methods.cellTitle.call({}, { firstInboundTime: '2099-01-01 00:00:00', ageSource: 'warehouse' }, 'firstInboundTime'); assert.match(futureTitle, /来源不可追溯或晚于今天/, '提示必须同时覆盖「来源不可追溯」与「晚于今天」两种 ageSource=warehouse 来源'); assert.match(futureTitle, /上线日(库龄起算日)起算/, '提示必须说明库龄按仓库配置的库龄起算日起算'); const untraceableTitle = detail.methods.cellTitle.call({}, { firstInboundTime: '2020-01-01 08:00:00', ageSource: 'warehouse' }, 'firstInboundTime'); assert.equal(untraceableTitle, futureTitle, '过去日期但来源不可追溯时,title 必须与未来日期同一条口径,不得退回「按首次入库日期起算」'); assert.match(detail.methods.cellTitle.call({}, { firstInboundTime: '2026-01-05 10:00:00', ageSource: 'batch' }, 'firstInboundTime'), /调拨、拆合包不重置库龄/, '真实首次入库日期必须说明库龄起算口径'); assert.equal(detail.methods.cellTitle.call({}, { categoryName: 'X' }, 'categoryName'), '', '非库龄列不制造空的悬浮提示'); // 处置入口复用 dead 视图既有动作:business('dead', row) 打开同一套 disposal 弹层,整行原样传下去。 const disposeRow = { categoryId: '1072414', warehouseId: '9', batchNo: 'B1', systemBatchNo: 'S1', ageDays: 200 }; const disposeEmitter = { $emit(event, target2, payload) { this.event = event; this.target2 = target2; this.payload = payload; } }; const { value: detailLogged } = load('detail.vue', { ...utilsLogged, screens: screenDefs }); logs.length = 0; detailLogged.methods.disposeAge.call(disposeEmitter, disposeRow); assert.equal(disposeEmitter.target2, 'dead', '建呆滞处置单必须复用 dead 视图的既有处置动作,不新造页面'); assert.equal(disposeEmitter.payload, disposeRow, '必须把整行原样传给处置弹层,物料/仓库/批次参数不能丢'); assert.equal(logs.length, 1, '处置入口必须留一条 [wms-home] 日志'); assert.equal(logs[0][0], '[wms-home]'); assert.match(logs[0][1], /E1 库龄明细建呆滞处置单/); // V6 列与口径条:补「最后入库」「负责人」,口径写明起算规则与 >180 联动。 const ageColumns = screenDefs.age.columns.map((column) => column.key); assert.ok(ageColumns.indexOf('lastInboundTime') !== -1, 'V6 明细必须含「最后入库」列'); assert.ok(ageColumns.indexOf('ownerName') !== -1, 'V6 明细必须含「负责人」列'); assert.ok(ageColumns.indexOf('firstInboundTime') !== -1 && ageColumns.indexOf('firstInboundTime') < ageColumns.indexOf('lastInboundTime'), '首次入库必须排在最后入库之前'); assert.ok(ageColumns.indexOf('lastInboundTime') < ageColumns.indexOf('lastActivityTime'), '最后入库必须排在「最近实际消耗」之前,避免两个日期混淆'); assert.match(screenDefs.age.definition, /库龄=当前日期−首次入库日期/, '口径必须写明库龄起算规则'); assert.match(screenDefs.age.definition, /上线日起算/, '口径必须写明期初按上线日起算'); assert.match(screenDefs.age.definition, /补库不刷新库龄/, '口径必须写明最后入库日期不刷新库龄'); assert.match(screenDefs.age.definition, /四段占比合计 100%/, '口径必须写明四段占比合计 100%'); assert.match(screenDefs.age.definition, />180 天与呆滞口径联动/, '口径必须写明 >180 与呆滞联动'); assert.match(screenDefs.age.definition, /负责人取仓库负责人/, '口径必须写明负责人取仓库负责人'); // 源码级守卫:红显类名、处置入口、库龄筛选文案(含分段边界)都必须落在模板里。 assert.match(detailSource, /aging-overdue/, '>180 天行的库龄单元格必须有红色类名'); assert.match(detailSource, /建呆滞处置单/, 'V6 明细必须给「建呆滞处置单」入口'); assert.match(detailSource, /按上线日起算/, '明细单元格必须能展示「按上线日起算」'); assert.match(detailSource, /0-30 天(≤30 天)/, '库龄筛选文案必须与后端分段一致并写明边界'); assert.match(detailSource, />180 天(与呆滞口径同界)/, '>180 段文案必须显式写明与呆滞同界'); assert.doesNotMatch(detailSource, /\[wms-home\]/, '日志前缀只在 dashboard-utils 里定义,组件内不得硬写'); assert.match(overviewSource, /buildAgingSegments/, '首页库龄结构必须走后端字段分拣,不得前端自算分段'); assert.match(overviewSource, /averageAgeChangeReason/, '较上月不可比时必须能展示后端原因'); assert.match(chartSource, /agingColorOf\(row\)/, '库龄分段颜色必须由 agingColorOf 统一给出(四段顺序色 + >180 红 + unknown 灰)'); assert.match(chartSource, /不参与 0-30 \/ 31-90 \/ 91-180 \/ >180 四段占比/, '库龄图 tooltip 必须说明 unknown 不参与四段占比'); // 模板编译守卫:本次改动全部落在模板里(占比条、角标、红显、处置入口、明细列), // 模板语法错误只会在浏览器里表现为空屏/错位,静态字符串断言抓不到,这里直接让 vue-template-compiler 编译一遍。 for (const file of ['components/HomeOverview.vue', 'components/DashboardChart.vue', 'detail.vue']) { const sfc = compiler.parseComponent(fs.readFileSync(path.join(folder, file), 'utf8')); const compiled = compiler.compile(sfc.template.content); assert.equal((compiled.errors || []).length, 0, file + ' 模板必须编译通过:' + (compiled.errors || []).join(';')); } console.log('E1 库龄结构断言通过:四段占比只展示后端 ratio(合计 100%)、>180 段固定红(与呆滞同色阶)、unknown 中性灰且不参与占比、平均库龄较上月负数绿/正数红/无基准只给原因、平均库龄与 ratio 空值不渲染成 0、V6 明细补最后入库与负责人、按上线日起算与日期待核实可区分、>180 行红色且可建呆滞处置单、日志前缀与去重。'); // ===== F1「安全库存预警」断言块 ===== // 口径来源:docs/仓储首页看板口径.md 的 A1 节 + WmsHomeMapper.xml 的 alertRows。 // 这里只读既有实现(纯函数 + 模板源码 + 后端 SQL 静态文本),不发任何真实请求。 const { alertStatusLabel, alertColorOf, alertGapAmountText, alertGapText, formatAlertAmount, alertGapAmountReason, buildAlertRows, buildAlertSummary } = utilsLogged; const ALERT_TOP_LIMIT = utilConst('ALERT_TOP_LIMIT'); // TOP 上限是卡片与「全部 N 项」的分界线:改成 6 会让首页多出一行、与设计稿的 5 行节奏错位。 assert.equal(ALERT_TOP_LIMIT, 5, '首页预警卡 TOP 上限必须是 5(缺口金额倒序前 5 条)'); // 三色兜底色只允许这四种:normal 行按契约不给 color,造一个颜色出来等于凭空表达健康度。 assert.equal(utilConst('ALERT_FALLBACK_COLORS').critical, '#F25E5E', '缺料兜底色必须是红'); assert.equal(utilConst('ALERT_FALLBACK_COLORS').warning, '#F0A03A', '偏低兜底色必须是橙'); assert.equal(utilConst('ALERT_FALLBACK_COLORS').excess, '#9B7DF7', '超储兜底色必须是紫'); assert.equal(utilConst('ALERT_FALLBACK_COLORS').unknown, '#7E93AD', '待核实兜底色必须是灰'); console.log('F1-(0) 颜色与标签兜底表就位'); // —— (1) 纯函数:标签 / 配色 / 缺口文案 / TOP 截断 / 汇总条 —— // statusLabel 优先:文案由后端下发,前端拿自己的映射覆盖会把后端改的措辞吃掉。 assert.equal(alertStatusLabel({ status: 'critical', statusLabel: '缺料(后端文案)' }), '缺料(后端文案)', 'statusLabel 由后端下发时必须优先'); assert.equal(alertStatusLabel({ status: 'critical' }), '缺料', 'statusLabel 缺失时按 status 兜底为「缺料」'); assert.equal(alertStatusLabel({ status: 'warning' }), '偏低', 'statusLabel 缺失时 warning 兜底为「偏低」'); assert.equal(alertStatusLabel({ status: 'excess' }), '超储', 'statusLabel 缺失时 excess 兜底为「超储」'); assert.equal(alertStatusLabel({ status: '未知值' }), '待核实', '未识别状态一律按「待核实」降级,绝不猜成已知状态'); // color 优先:颜色跟着后端判定走,改系数后颜色自动跟着变;只在后端没给时按状态兜底(并 warn 一次)。 assert.equal(alertColorOf({ status: 'warning', color: '#123456' }), '#123456', '后端下发的 color 必须优先'); assert.equal(alertColorOf({ status: 'critical' }), '#F25E5E', '缺色时 critical 按状态兜底为红'); assert.equal(alertColorOf({ status: 'excess' }), '#9B7DF7', '缺色时 excess 按状态兜底为紫'); assert.equal(alertColorOf({ status: 'unknown' }), '#7E93AD', '缺色时 unknown 按状态兜底为灰'); assert.equal(alertColorOf({ status: 'normal' }), null, 'normal 行不表达健康度,不得造一个兜底色出来'); // 缺口金额:null 是「缺价、算不出来」,绝不是 0;0 是真实零,要显示两位小数。 assert.equal(alertGapAmountText({ gapAmount: null }), '金额待补价', '缺价行必须显示「金额待补价」,用 0 或空串冒充都会误导补货排序'); assert.equal(alertGapAmountText({ gapAmount: 0 }), '0.00 元', '缺口金额为真实 0 时必须显示 0.00 元,不能走缺价分支'); assert.equal(formatAlertAmount(null), '—', '金额缺失在底层格式化层给占位符'); assert.equal(formatAlertAmount(0), '0.00', '真实 0 在底层格式化层照实显示两位小数'); assert.match(alertGapAmountReason({}), /移动加权不含税单价不可用/, '缺价原因缺失时必须给事实说明,不得编造单价来源'); assert.match(alertGapText({ gapQuantity: 3, unit: '个', gapAmount: null }), /金额待补价/, '行文案缺价时必须含「金额待补价」'); assert.equal(alertGapText({ gapQuantity: 3, unit: '个', gapAmount: null }), '缺口 3 个 / 金额待补价', '缺口文案=缺口量+单位+金额,缺价走待补价'); assert.equal(alertGapText({ gapQuantity: null }), '缺口 — / 金额待补价', '缺口量缺失显示「—」,不按 0 计'); // TOP5 截断:7 行进、5 行出,并给出 overflow 供「全部 N 项」说明;顺序完全沿用后端,前端不重排。 const alertRowsInput = Array.from({ length: 7 }, (_, index) => ({ id: String(index + 1), name: '物料' + (index + 1), unit: '个', status: 'critical', statusLabel: '缺料', color: '#F25E5E', gapQuantity: index + 1, gapAmount: 1000 - index * 100, purchaseRequestCode: null, workOrderCount: index })); const alertTop = buildAlertRows(alertRowsInput, ALERT_TOP_LIMIT); assert.equal(alertTop.rows.length, 5, '首页预警卡只展示前 5 条'); assert.equal(alertTop.total, 7, 'total 必须是后端原始条数,不受截断影响'); assert.equal(alertTop.overflow, 2, '超出 TOP 的条数必须给出 overflow'); assert.equal(alertTop.rows[0].id, '1', '前端不得重排:顺序必须与后端 gapAmount DESC 一致'); assert.equal(alertTop.statusCounts.critical, 7, '状态分拣计数按后端 status 统计,供核对卡片与清单是否同源'); assert.equal(buildAlertRows(null).rows.length, 0, '后端未下发 alerts 时必须空态返回,不能崩'); assert.equal(buildAlertRows([]).overflow, 0, '空列表不得产生 overflow'); // 前端绝不复算阈值:状态是后端短路判定的结果,available detailLogged.computed.rows.call({ view: 'alerts', result: { records: [row] } })[0].alertAction; assert.equal(alertActionFor({ status: 'unknown' }).primary, 'threshold', '待核实行只给「核对阈值」'); assert.equal(alertActionFor({ status: 'unknown' }).primaryLabel, '核对阈值'); assert.notEqual(alertActionFor({ status: 'unknown' }).primary, 'purchase', '待核实行不得给补货入口:阈值不成立,补货只会被后端 hasReplenishmentAlert 拒绝'); assert.equal(alertActionFor({ status: 'excess' }).primary, 'transfer', '超储行的闭环是调拨,不是继续采购'); assert.equal(alertActionFor({ status: 'excess' }).primaryLabel, '发起调拨'); assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).primary, 'purchase-document', '已生成申请的行只给「打开采购需求」'); assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).code, 'CG-2026-0001', '单号必须透传用于行尾标记'); assert.notEqual(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).primary, 'purchase', '有单号时绝不出现「生成采购申请」,否则就是重复下单入口'); assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).document, false, '主按钮已是「打开采购需求」时不得再渲染一次'); assert.equal(alertActionFor({ status: 'critical' }).primary, 'purchase', '缺料行无单号时给「生成采购申请」'); assert.equal(alertActionFor({ status: 'warning' }).primary, 'purchase', '偏低行无单号时同样给「生成采购申请」'); assert.equal(alertActionFor({ status: 'normal' }).primary, '', '正常行不给任何动作,绝不凭空造一个点了就失败的按钮'); assert.equal(alertActionFor({ status: 'excess', purchaseRequestCode: 'CG-2' }).document, true, '超储行若已有申请单号,额外给「打开采购需求」'); assert.equal(alertActionFor({}).primary, '', '状态缺失时按兜底处理,不给动作'); console.log('F1-(2) alertActionOf 五分支通过:unknown→核对阈值(不给补货)、excess→发起调拨、有单号→打开采购需求(绝不出补货)、critical/warning→生成采购申请、normal→无动作'); // —— (3) 首页卡片模板守卫:三色点、状态标签、影响工单、已生成申请、金额待补价 —— assert.match(overviewSource, /:style="\{ background: item\.color, color: item\.color \}"/, '三色状态点必须取 item.color(后端 color,缺失时 alertColorOf 按 status 兜底)'); assert.match(overviewSource, /class="alert-state" :style="\{ color: item\.color \}"[\s\S]{0,40}\{\{ item\.statusLabel \}\}/, '状态中文标签必须与状态点同排且用后端 statusLabel'); assert.match(overviewSource, /影响生产工单/, '汇总条必须出现「影响生产工单」'); assert.match(overviewSource, /已生成申请/, '卡片必须给出「已生成申请」标记,避免同一行被重复下单'); assert.match(overviewSource, /金额待补价/, '卡片必须写明缺价行显示「金额待补价」,不得渲染成 0'); assert.match(overviewSource, /item\.gapText/, '行内必须展示缺口量/缺口金额文案'); assert.match(overviewSource, /alertSummary\.workOrderText/, '汇总条工单数必须消费后端 workOrderText(null 时是「—」)'); assert.match(overviewSource, /level: 'critical'/, '「影响生产工单」下钻必须按 level=critical,与工单数同源'); assert.match(detailSource, /含待检、未放行批次/, '清单页口径条必须写明可用量含待检/未放行批次'); console.log('F1-(3) 卡片模板守卫通过:三色 fallback、状态标签同排、影响生产工单、已生成申请、金额待补价、工单下钻 level=critical'); // —— (4) screens.alerts 口径文案守卫:新口径关键词必须在,旧口径不得复现 —— const alertDefinition = screenDefs.alerts.definition; assert.match(alertDefinition, /含待检/, '预警口径条必须写明可用量含待检批次'); assert.match(alertDefinition, /偏低系数/, '预警口径条必须写明 K1 偏低系数'); assert.match(alertDefinition, /超储兜底/, '预警口径条必须写明 K2 超储兜底系数'); assert.doesNotMatch(alertDefinition, /排除未放行/, '「排除未放行」是 batchRows 的口径,不得再出现在预警口径条里'); assert.match(alertDefinition, /最低库存仅作展示列、不参与触发判定/, '口径条必须写明最低库存不参与触发判定'); assert.match(alertDefinition, /最大库存(允许为空/, '口径条必须写明最大库存可为空'); assert.match(alertDefinition, /缺料[\s\S]{0,40}偏低[\s\S]{0,40}超储/, '口径条必须写明短路顺序 缺料→偏低→超储'); assert.match(alertDefinition, /待核实单列且不计入/, '口径条必须写明待核实单列且不计入预警条数'); console.log('F1-(4) screens.alerts 文案守卫通过:含待检、偏低系数、超储兜底、最低库存不参与判定、最大库存可空、待核实单列,且不再出现「排除未放行」'); // —— (5) 后端 SQL 静态守卫(只读文本;后端仓库不在同级目录时 SKIP 而非 fail)—— const alertMapperPath = path.resolve(root, '..', 'aiot202312/aiot-biz/wms-biz/wms-biz-provider/src/main/resources/mapper/WmsHomeMapper.xml'); if (!fs.existsSync(alertMapperPath)) { console.log('SKIP F1-(5) 后端 SQL 静态守卫:未找到 ' + alertMapperPath + '(后端仓库不在当前工作区同级目录,跳过不 fail)'); } else { const alertMapperXml = fs.readFileSync(alertMapperPath, 'utf8'); const sqlFragment = (id) => { const start = alertMapperXml.indexOf(''); if (start < 0) return null; const end = alertMapperXml.indexOf('', start); return end < 0 ? null : alertMapperXml.slice(start, end); }; const alertFragment = sqlFragment('alertRows'); const batchFragment = sqlFragment('batchRows'); assert.ok(alertFragment, 'alertRows 片段必须存在(预警列表与首页 TOP5 共用)'); assert.ok(batchFragment, 'batchRows 片段必须存在'); // critical 判定行:只能由 secureInventory 判,不得引用 minInventory(最低库存只作展示列)。 const alertCriticalLines = alertFragment.split('\n').filter((line) => /THEN 'critical'/.test(line)); assert.equal(alertCriticalLines.length, 1, 'critical 判定行必须唯一(短路判定里出现两处说明有人改坏了 CASE)'); assert.doesNotMatch(alertCriticalLines[0], /minInventory/, 'critical 判定不得引用 minInventory:最低库存只作展示列、不参与触发'); assert.match(alertCriticalLines[0], /secureInventory/, 'critical 必须由 secureInventory 判定(可用量<安全库存)'); // K1/K2 必须由 Mapper 参数下发,不能写死 1.2 / 3——写死就没法按环境调。 assert.match(alertFragment, /#\{warningRatio\}/, 'warning 判定必须用参数 #{warningRatio}(wms.home.alert.warning-ratio,默认 1.2)'); assert.match(alertFragment, /#\{excessRatio\}/, '超储兜底必须用参数 #{excessRatio}(wms.home.alert.excess-ratio,默认 3)'); assert.match(alertFragment, /COALESCE\(cfg\.maxInventory,cfg\.secureInventory\*#\{excessRatio\}\)/, '超储兜底只应在 maxInventory 为空时生效'); // 排序:缺口金额倒序 + 缺价(NULL)排最后 + 状态严重度 + id 稳定收尾。 assert.match(alertMapperXml, /ORDER BY gapAmount IS NULL,gapAmount DESC/, '预警排序必须 gapAmount IS NULL,gapAmount DESC(缺价行排最后,不依赖 DESC 的隐式 NULL 规则)'); assert.match(alertMapperXml, /FIELD\(status,'critical','warning','excess','normal','unknown'\)/, '预警次级排序必须按状态严重度 critical→warning→excess→normal→unknown'); // 护栏①:batchRows 的 availableQuantity 定义不得改(四个既有 KPI 同时消费它)。 assert.match(batchFragment, /available_count_base-COALESCE\(b\.lock_count_base,0\)/, 'batchRows 的 availableQuantity 必须仍是 available_count_base−锁定量(护栏:改它会静默改掉四个既有消费者的数字)'); assert.equal((batchFragment.match(/AS availableQuantity/g) || []).length, 1, 'batchRows 只能有一个 availableQuantity 定义'); // 护栏②:预警可用量必须走独立 av 子查询(Σ在库量−Σ锁定量,含待检),不得复用 batchRows 的 available_count_base 口径。 assert.match(alertFragment, /SUM\(b\.stock_count_base\)-SUM\(COALESCE\(b\.lock_count_base,0\)\)/, '预警可用量必须是独立 av 子查询的 Σ(stock_count_base)−Σ(lock_count_base)(含待检)'); assert.doesNotMatch(alertFragment, /available_count_base/, '预警可用量不得复用 batchRows 的 available_count_base(那会排除未放行批次,与阈值判定口径冲突)'); // 缺口量 / 缺口金额 / 工单数 / 申请单号的 SQL 语义(缺价为 NULL,不兜底成 0)。 assert.match(alertFragment, /GREATEST\(b0\.secureInventory-b0\.availableQuantity,0\)/, '缺口量必须是 GREATEST(安全库存−可用量,0)'); assert.match(alertFragment, /ar\.gapQuantity\*ar\.costUnitPrice/, '缺口金额必须是 缺口量×移动加权不含税单价'); assert.match(alertFragment, /THEN '缺口物料缺少移动加权不含税单价,缺口金额不可折算'/, '缺价时必须给 gapAmountReason,不得用 0 冒充'); assert.match(alertFragment, /CASE WHEN ar\.status='critical' THEN wo\.workOrderCount END AS workOrderCount/, '行级 workOrderCount 只给 critical 行,非缺料行必须是 NULL 而不是 0'); assert.match(alertFragment, /command_type/, '申请单号必须关联 t_wms_dashboard_command(command_type=PURCHASE)'); assert.match(alertFragment, /CAST\(pc\.business_id AS CHAR\) AS purchaseRequestId/, '申请主键必须 CAST AS CHAR,禁止让大整数进 JS 数字'); // 阈值落库:max_inventory 允许留空 → jdbcType=DECIMAL 无条件写,留空写 NULL。 assert.match(alertMapperXml, /max_inventory=#\{q\.maxInventory,jdbcType=DECIMAL\}/, '阈值 insert 必须显式 jdbcType=DECIMAL(max_inventory 允许留空写 NULL)'); assert.match(alertMapperXml, /max_inventory=#\{q\.maxInventory,jdbcType=DECIMAL\},update_user_id/, '阈值 update 必须显式 jdbcType=DECIMAL(留空写 NULL,不能要求必填)'); console.log('F1-(5) 后端 SQL 静态守卫实际执行通过(未 SKIP):critical 只由 secureInventory 判、无 minInventory、K1/K2 走参数、gapAmount DESC 排序、batchRows 护栏、av 子查询 Σ 口径、缺价 NULL、workOrderCount 仅 critical、business_id CAST CHAR、max_inventory jdbcType=DECIMAL'); } // ===== E3「最近作业动态」断言块 ===== // 口径来源:docs/仓储首页看板口径.md 的 E3 节 + WmsHomeMapper.xml 的 activities 分支。 // 只读既有实现:装载 dashboard-utils.js 调真实纯函数(一律传固定 now,否则断言会跟着跑脚本的当天时钟漂), // 模板/样式只做源码字符串断言。全程不发请求、不写任何文件。 const { formatActivityTime, activityStatusOf, buildActivityFeed, diffActivityIds } = utilsLogged; // const 常量落在 vm 词法作用域、不会成为上下文属性,必须用 utilConst 取(同 STAGNANT_DEFAULT_DAYS / ALERT_TOP_LIMIT)。 // 卡片固定 5 条是视觉契约:改成 6 会让卡片多出一行,与设计稿的 5 行节奏错位。 assert.equal(utilConst('ACTIVITY_LIMIT'), 5, '最近作业动态卡片必须固定展示 5 条'); // 超时口径必须与待办条(overdueInbound/overdueOutbound/overdueApproval)同源 24 小时, // 否则同一屏上「待入库单超时」与「入库流水超时」会用两个阈值各说一套。 assert.equal(utilConst('ACTIVITY_OVERDUE_HOURS'), 24, '待* 超时阈值必须与待办条同口径 24 小时'); // 固定 now=本地时间 2026-10-20 15:30,四段文案与超时判定全部由它推出,脚本哪天跑结果都一样。 const activityNow = new Date(2026, 9, 20, 15, 30, 0); // —— (1) 状态词 → tone 色表:文案与颜色解耦的唯一来源 —— const activityStyles = utilConst('ACTIVITY_STATUS_STYLES'); const activityToneOf = (word) => (activityStyles[word] || {}).tone; const activityStyleTones = Object.keys(activityStyles).map((word) => activityToneOf(word)); const ACTIVITY_TONES = ['done', 'waiting', 'doing', 'danger']; // 表里每个词都必须有合法 tone:漏一个会让模板拼出 'feed-status undefined',那一行状态直接无色。 assert.equal(activityStyleTones.filter((tone) => ACTIVITY_TONES.indexOf(tone) === -1).length, 0, '状态色表里每个状态词都必须映射到 done/waiting/doing/danger 之一(否则模板会拼出无色状态)'); assert.equal(ACTIVITY_TONES.filter((tone) => activityStyleTones.indexOf(tone) === -1).length, 0, '四种 tone 必须都被至少一个状态词使用,否则说明色表被删词'); assert.equal(activityToneOf('已完成'), 'done', '已完成=闭环,必须绿色 done'); assert.equal(activityToneOf('已执行'), 'done', '已执行=闭环(调拨),必须绿色 done'); assert.equal(activityToneOf('待质检'), 'waiting', '待质检=在等质检处理,必须橙色 waiting'); assert.equal(activityToneOf('待审批'), 'waiting', '待审批=在等审批,必须橙色 waiting'); assert.equal(activityToneOf('待执行'), 'waiting', '待执行=还没开始,必须橙色 waiting(超时角标只跟 waiting 走)'); assert.equal(activityToneOf('进行中'), 'doing', '进行中=正在跑,必须青色 doing'); assert.equal(activityToneOf('已驳回'), 'danger', '已驳回=出问题,必须红色 danger'); assert.equal(activityToneOf('异常'), 'danger', '异常=出问题,必须红色 danger'); // —— (2) formatActivityTime 四段:今日 HH:mm / 昨日 / MM-DD / — —— // 今日必须补前导 0(09:05 不能写 9:5):这是「今日 HH:mm」与后端时间串的唯一视觉差别。 assert.equal(formatActivityTime('2026-10-20 09:05:00', activityNow), '今日 09:05', '今日事件必须显示「今日 HH:mm」,小时与分钟都补前导 0'); assert.equal(formatActivityTime('2026-10-20 23:59:00', activityNow), '今日 23:59', '当天 23:59 仍算今日,不得提前算成昨日'); assert.equal(formatActivityTime('2026-10-19 23:59:00', activityNow), '昨日', '昨日事件只显示「昨日」,不显示时刻'); assert.equal(formatActivityTime('2026-10-18 10:00:00', activityNow), '10-18', '更早事件显示 MM-DD'); assert.equal(formatActivityTime('2026-09-05 08:00:00', activityNow), '09-05', '更早事件的月/日也要补前导 0(09-05 不能写 9-5)'); assert.equal(formatActivityTime('', activityNow), '—', '空时间必须给占位符「—」,不能显示成今日'); assert.equal(formatActivityTime(null, activityNow), '—', 'null 时间必须给占位符「—」'); assert.equal(formatActivityTime('不是时间', activityNow), '—', '非法时间串必须给占位符,绝不拿「现在」兜底——那会把一条陈年流水顶到列表最前面'); // —— (3) activityStatusOf:先按 activityType 分流,再谈 status —— const activityStatus = (row) => { const status = activityStatusOf(row); return status.text + '/' + status.tone; }; assert.equal(activityStatus({ activityType: 'inventory', status: 0 }), '待执行/waiting', '盘点 status 0 必须「待执行」橙'); assert.equal(activityStatus({ activityType: 'inventory', status: 1 }), '进行中/doing', '盘点 status 1 必须「进行中」青'); assert.equal(activityStatus({ activityType: 'inventory', status: 2 }), '已完成/done', '盘点 status 2 必须「已完成」绿'); // 本卡最容易被改坏的一条:io 行的 status 恒为 null,而 Number(null) === 0。 // 一旦把 status 判断提到类型分流之前,审核通过的入库单会整片显示成「待执行」并挂上超时角标(假待办)。 assert.equal(activityStatus({ activityType: 'io', status: null }), '已完成/done', 'io 的 status 为 null 时必须「已完成」,绝不能因 Number(null)===0 退化成「待执行」'); assert.equal(activityStatus({ activityType: 'io' }), '已完成/done', 'io 完全没有 status 字段时同样「已完成」'); assert.equal(activityStatus({ activityType: 'io', status: 0 }), '已完成/done', 'io 即使带着 0 也仍是「已完成」(后端只把审核通过的流水放进 activities)'); assert.equal(activityStatus({ activityType: 'transfer', status: null }), '已执行/done', '调拨恒为「已执行」,status 为 null 时不得退化成「待执行」'); assert.equal(activityStatus({ activityType: 'transfer' }), '已执行/done', '调拨缺 status 时同样「已执行」'); // 未识别一律中性灰:造 done 会被读成业务结论,造 waiting 会凭空生成一个待办并跟着挂超时角标。 assert.equal(activityStatus({ activityType: '质检单' }), '状态待核实/neutral', '未识别 activityType 必须回落 neutral,不得猜成 done/waiting'); assert.equal(activityStatus({}), '状态待核实/neutral', '完全没有类型的行必须回落 neutral'); assert.equal(activityStatus({ activityType: 'inventory', status: 9 }), '状态待核实/neutral', '未识别盘点 status 必须回落 neutral,绝不猜成某个已知状态'); assert.equal(activityStatus({ activityType: 'inventory', status: null }), '状态待核实/neutral', '盘点缺 status 时回落 neutral,不得默认成「待执行」'); // —— (4) buildActivityFeed:去重(同单号留最新)→ operateTime 倒序 → 截断 5 条 —— // fixture:8 条入参、含 1 条重复(O6 两条),期望 total=8 / deduped=1 / shown=5 / overflow=2。 // 重复的两条刻意把「旧的一条排在前面」,用来证明去重认的是 operateTime 而不是先到者。 const activityFeedRows = [ { id: 'A1', orderId: 'O1', orderNo: 'RK-001', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-20 14:00:00', itemCount: 3, warehouseName: '一号库' }, { id: 'A2', orderId: 'O2', orderNo: 'CK-002', activityType: 'io', direction: 2, status: null, operateTime: '2026-10-20 12:00:00', itemCount: 12, warehouseName: '二号库' }, { id: 'A3', orderId: 'O3', orderNo: 'DB-003', activityType: 'transfer', status: null, operateTime: '2026-10-20 10:00:00', itemCount: 2, warehouseName: '三号库' }, { id: 'A4', orderId: 'O4', orderNo: 'PD-004', activityType: 'inventory', operateTime: '2026-10-19 18:00:00', itemCount: 1, warehouseName: '四号库', status: 1, description: '执行中' }, { id: 'A5', orderId: 'O5', orderNo: 'PD-005', activityType: 'inventory', operateTime: '2026-10-19 09:00:00', itemCount: 1, warehouseName: '五号库', status: 0 }, { id: 'A6', orderId: 'O6', orderNo: 'RK-006', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-18 09:00:00', itemCount: 4, warehouseName: '六号库' }, { id: 'A7', orderId: 'O6', orderNo: 'RK-006', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-20 08:00:00', itemCount: 6, warehouseName: '六号库' }, { id: 'A8', orderId: 'O7', orderNo: 'PD-007', activityType: 'inventory', operateTime: '2026-10-17 09:00:00', itemCount: 1, warehouseName: '七号库', status: 2 } ]; const activityFeed = buildActivityFeed(activityFeedRows, activityNow); const activityById = (feed, id) => feed.items.filter((item) => item.id === id)[0] || {}; assert.equal(activityFeed.total, 8, 'total 必须是入参条数 8(含重复),用于核对后端到底回了几行'); assert.equal(activityFeed.deduped, 1, 'deduped 必须记 1 条被去掉的重复单据'); assert.equal(activityFeed.items.length, 5, '卡片只展示 5 条,多一条就会顶掉设计稿的 5 行节奏'); assert.equal(activityFeed.overflow, 2, 'overflow=去重后 7 条 − 展示 5 条 = 2,供「全部动态 ›」提示余量'); assert.equal(activityFeed.items.map((item) => item.id).join('|'), 'A1|A2|A3|A7|A4', '必须按 operateTime 倒序截断 5 条:A6 是同单号的旧记录,去重后只留较新的 A7'); assert.equal(activityFeed.items.filter((item) => item.orderId === 'O6').length, 1, '同一 orderId 在卡片上只能出现 1 次,否则同一张单会被看两遍'); // 盘点动作文案:以「盘点」开头 + 带库区 + **绝不出现「项」**。 // 后端盘点行的 itemCount 是占位常量 1(WmsHomeMapper.xml 盘点分支 CAST 成 1),拼成「盘点 1 项」就是伪造明细数。 const activityInventory = activityById(activityFeed, 'A4'); assert.match(activityInventory.actionText, /^盘点/, '盘点行动作文案必须以「盘点」开头(后端 description 里的状态词不得当动作描述)'); assert.doesNotMatch(activityInventory.actionText, /项/, '盘点行不得渲染 itemCount:后端盘点 itemCount 恒为占位常量 1,「盘点 1 项」是伪造数字'); assert.match(activityInventory.actionText, /四号库/, '盘点行必须带库区,否则同一时刻的多条盘点无从区分'); // io/transfer 反而不许丢掉真实明细行数:itemCount 是后端 COUNT(*) 真明细行数,只写「入库」会让人点进去才发现数量。 assert.match(activityById(activityFeed, 'A1').actionText, /\d+ 项/, '入库行动作文案必须带明细项数(后端 COUNT(*) 真明细行数)'); assert.match(activityById(activityFeed, 'A1').actionText, /3 项/, '入库行必须显示后端下发的 3 项,不能拿别处的常量顶替'); assert.match(activityById(activityFeed, 'A1').actionText, /一号库/, '入库行必须带库区'); assert.match(activityById(activityFeed, 'A3').actionText, /2 项/, '调拨行必须显示后端下发的 2 项'); assert.match(activityById(activityFeed, 'A3').actionText, /三号库/, '调拨行必须带库区'); // 下钻参数留在原始字段上:$emit('business','io',item) 依赖 activityType/orderId/orderNo/direction/warehouseId, // 展示字段一律用 timeText/actionText/statusText/tone 这类新名字,任何一个同名覆盖都等于悄悄丢掉下钻参数。 const activityInbound = activityById(activityFeed, 'A1'); assert.equal(activityInbound.id, 'A1', '原始 id 必须保留(v-for :key 与高亮 indexOf 都用它)'); assert.equal(activityInbound.orderId, 'O1', '原始 orderId 必须保留(去重键与下钻都靠它)'); assert.equal(activityInbound.orderNo, 'RK-001', '原始 orderNo 必须保留(单号段直接渲染它)'); assert.equal(activityInbound.activityType, 'io', '原始 activityType 必须保留,否则下钻落不到 V4 的出入库页签'); assert.equal(activityInbound.direction, 1, '原始 direction 必须保留,否则入库/出库方向在下钻时丢失'); assert.equal(activityInbound.operateTime, '2026-10-20 14:00:00', '原始 operateTime 必须保留,不能被 timeText 覆盖'); assert.equal(activityInbound.status, null, '原始 status 不得被状态文案覆盖(io 行恒为 null)'); assert.equal(activityInbound.timeText, '今日 14:00', '展示字段 timeText 必须由 formatActivityTime 给出'); assert.equal(activityInbound.statusText, '已完成', '展示字段 statusText 用新名字,不占用 status'); assert.equal(activityInbound.tone, 'done', '展示字段 tone 必须落在 done/waiting/doing/danger/neutral 之内'); // 空态:null/非数组入参必须安全返回空列表(后端未下发 activities、或 summary 还没到),绝不能抛错把整屏打黑。 for (const badFeed of [null, undefined, [], 'x', {}, 0]) { const emptyFeed = buildActivityFeed(badFeed, activityNow); assert.equal(emptyFeed.items.length, 0, '空/非数组入参必须返回空列表(入参 ' + String(badFeed) + ')'); assert.equal(emptyFeed.total, 0, '空/非数组入参的 total 必须是 0(入参 ' + String(badFeed) + ')'); } // —— (5) 超时角标:只有橙色「待*」且事件时间早于 now−24h 才标 —— // 给 done/doing 的行套「超时」没有业务含义,只会让用户以为还有活没干;重复求值也不许漂移(30 秒轮询会反复求值)。 const activityOverdueOf = (row) => buildActivityFeed([row], activityNow).items[0].overdue; assert.equal(activityOverdueOf({ id: 'w1', orderId: 'w1', activityType: 'inventory', status: 0, operateTime: '2026-10-18 09:00:00' }), true, '待执行超过 24 小时(54.5h)必须标超时'); assert.equal(activityOverdueOf({ id: 'w2', orderId: 'w2', activityType: 'inventory', status: 0, operateTime: '2026-10-19 20:00:00' }), false, '24 小时内的待执行不得标超时(19.5h)'); assert.equal(activityOverdueOf({ id: 'w3', orderId: 'w3', activityType: 'inventory', status: 0, operateTime: '2026-10-19 14:00:00' }), true, '刚过 24 小时(25.5h)必须标超时'); assert.equal(activityOverdueOf({ id: 'd1', orderId: 'd1', activityType: 'io', direction: 1, operateTime: '2026-09-01 09:00:00' }), false, '已完成行再老也不标超时(绿行没有超时语义)'); assert.equal(activityOverdueOf({ id: 'd2', orderId: 'd2', activityType: 'transfer', operateTime: '2026-09-01 09:00:00' }), false, '调拨(已执行)行再老也不标超时'); assert.equal(activityOverdueOf({ id: 'p1', orderId: 'p1', activityType: 'inventory', status: 1, operateTime: '2026-09-01 09:00:00' }), false, '进行中行再老也不标超时(doing 只表达正在跑)'); const activityFeedAgain = buildActivityFeed(activityFeedRows, activityNow); assert.equal(activityFeedAgain.items.map((item) => item.overdue).join('|'), activityFeed.items.map((item) => item.overdue).join('|'), '重复求值的 overdue 必须完全一致(幂等,轮询不得让角标闪来闪去)'); assert.equal(activityFeedAgain.items.map((item) => item.tone).join('|'), activityFeed.items.map((item) => item.tone).join('|'), '重复求值的 tone 必须完全一致'); // —— (6) diffActivityIds:首屏不整屏高亮、只标新增、同批二次为空 —— // prev 为空=首次加载:返回空数组,否则首屏 5 行全部高亮,等于什么都没强调,只是让卡片闪一下。 assert.equal(diffActivityIds([], [{ id: 'n1', orderId: 'N1' }]).length, 0, '首次加载(prev 为空)必须返回空数组,首屏不得整屏高亮'); assert.equal(diffActivityIds(null, [{ id: 'n1', orderId: 'N1' }]).length, 0, 'prev 缺失时同样不得高亮'); const activityPrevItems = [{ id: 'x', orderId: 'X' }, { id: 'y', orderId: 'Y' }]; const activityNextItems = [{ id: 'y', orderId: 'Y' }, { id: 'z', orderId: 'Z' }]; assert.equal(diffActivityIds(activityPrevItems, activityNextItems).join('|'), 'z', '只返回 next 里新增的 id,且顺序与 next 一致'); // 同一批数据二次求值必须返回空数组:activities 是计算属性,每次求值都是新数组, // 按引用比较会让 30 秒轮询把同一批行全部判成「新增」,整屏反复闪。 assert.equal(diffActivityIds(activityNextItems, activityNextItems).join('|'), '', '同一批数据二次调用必须返回空数组(轮询不得让整屏重新高亮)'); assert.equal(diffActivityIds([{ id: 'old-id', orderId: 'Y' }], [{ id: 'new-id', orderId: 'Y' }]).length, 0, '同一单据换了 id 也不算新增:比较键必须与去重键同源(orderId 优先)'); // —— (7) 模板四段结构 + 空态 + 「全部动态 ›」入口(源码字符串断言,防止语义被搬回模板)—— const activitySfc = compiler.parseComponent(overviewSource); const activityTpl = activitySfc.template.content; assert.match(activityTpl, /class="tm"\s*:title="item\.timeTitle"/, '第一段必须是时间(.tm 渲染 timeText,title 给完整时刻)'); assert.match(activityTpl, /class="feed-doc"/, '第二段必须是单据类型 + 单号(.feed-doc)'); assert.match(activityTpl, /class="feed-desc"\s*:title="item\.actionText"/, '第三段必须是动作描述(.feed-desc 渲染 actionText)'); // 状态色只认 item.tone:允许拼接写法变化('feed-status ' + item.tone 或数组写法),但必须能读到 item.tone, // 且模板里绝不能出现写死的 feed-status done/waiting/... —— 写死就等于在模板里又判了一遍业务状态。 assert.match(activityTpl, /:class="[^"]*feed-status[^"]*item\.tone[^"]*"/, '第四段状态色必须由 item.tone 拼成 feed-status 类(模板里不得再判业务状态)'); assert.doesNotMatch(activityTpl, /feed-status\s+(?:done|waiting|doing|danger|neutral)\b/, '模板不得写死 feed-status 的具体 tone 类,颜色只能由 item.tone 决定'); // 单号段必须自己可点,且 @click.prevent.stop:少了 prevent 页面会跳走,少了 stop 会冒泡到整行触发两次下钻。 assert.match(activityTpl, /class="feed-no"[\s\S]{0,260}@click\.prevent\.stop="openActivity\(item\)"/, '单号必须可点击且带 @click.prevent.stop(防跳转 + 防冒泡双触发下钻)'); assert.match(activityTpl, /v-if="item\.overdue"\s+class="badge-overdue">超时 0, '空态必须有固定文案「今日暂无作业动态」'); const activityEmptyOpen = activityTpl.lastIndexOf('', activityEmptyOpen) + 1); assert.match(activityEmptyTag, /class="empty-panel"/, '固定文案必须落在 .empty-panel 空态容器里,不能挂到别的卡片上'); const activityEmptyHead = activityTpl.slice(activityTpl.indexOf('>', activityEmptyOpen) + 1, activityEmptyIndex + 80); assert.match(activityEmptyHead, /^今日暂无作业动态/, '空态第一句必须是固定文案,不能只渲染后端原因'); assert.match(activityEmptyHead, /v-if="activityReason"/, '后端原因只能作为可选后缀拼在固定文案之后'); assert.doesNotMatch(activityTpl, /class="empty-panel"[^>]*>\s*]*>全部动态 ›<\/a>/) || [])[0] || ''; assert.ok(activityAllLink, '「全部动态 ›」入口必须存在(V4 出入库动态的唯一入口)'); assert.match(activityAllLink, /\$emit\('open', 'io'\)/, '「全部动态 ›」必须仍发 open(\'io\') 落到 V4,不得改成别的目标'); // —— (8) 高亮:3 秒渐隐定时器 + beforeDestroy 清理;轮询不得动滚动 —— const activityScript = activitySfc.script.content; assert.match(activityScript, /setTimeout\(\(\) => \{[\s\S]{0,200}?\}, 3000\)/, '新增事件高亮必须是 3000ms 定时器(与 scss 的 3s 渐隐动画同长)'); assert.match(activityScript, /beforeDestroy\(\)\s*\{[\s\S]{0,260}?clearTimeout\(this\.highlightTimer\)/, 'beforeDestroy 必须清理高亮定时器(否则 3 秒后往已销毁实例写响应式数据)'); // 滚动守卫前先剥注释:实现里为了说明「禁止 scrollIntoView/scrollTop」本身就写了这两个词, // 拿原文匹配会把这条说明当成违规;剥掉注释后剩下的才是真正的代码,守卫挡的就是它。 const stripCodeComments = (source) => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1'); const activityOverviewCode = stripCodeComments(overviewSource); const activityThemeCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-theme.scss'), 'utf8')); // 防呆:剥注释后仍必须读到真实模板与样式,否则下面的「不出现」断言会空转(剥太狠等于什么都没查)。 assert.match(activityOverviewCode, /class="feed-item"/, '剥注释后仍必须能读到 feed-item 模板(否则滚动守卫空转)'); assert.match(activityThemeCode, /\.feed-status\.done\s*\{/, '剥注释后仍必须能读到 .feed-status.done 规则(否则滚动守卫空转)'); for (const [activityFile, activityCode] of [['HomeOverview.vue', activityOverviewCode], ['dashboard-theme.scss', activityThemeCode]]) { assert.doesNotMatch(activityCode, /scrollIntoView|scrollTop|scrollTo|scrollBy|scrollLeft/, activityFile + ' 不得调用任何滚动 API:30 秒轮询里主动滚动会把用户正在看的位置顶走(只允许注释里提这些词)'); } // —— (9) 样式契约:五条 tone 规则 + is-new 渐隐关键帧 —— for (const activityTone of ['done', 'waiting', 'doing', 'danger', 'neutral']) { assert.match(activityThemeCode, new RegExp('\\.feed-status\\.' + activityTone + '\\s*\\{'), 'dashboard-theme.scss 必须有 .feed-status.' + activityTone + ' 规则:缺一条该状态就无色(neutral 灰是「前端不认识这个状态」的唯一视觉语言)'); } assert.match(activityThemeCode, /\.feed-item\.is-new\s*\{[\s\S]{0,80}?animation:\s*feed-new-fade\s+3s/, '新增行必须有 is-new 的 3s 渐隐动画,时长与 JS 的 3000ms 定时器一致'); assert.match(activityThemeCode, /@keyframes\s+feed-new-fade\s*\{[\s\S]{0,400}?\}/, 'is-new 的 @keyframes feed-new-fade 必须存在(否则 animation 引用不存在的动画,高亮静默失效)'); console.log('E3 最近作业动态断言通过:ACTIVITY_LIMIT=5 与待办条同口径 24h 超时、状态色表四 tone 全覆盖(已完成/已执行=done、待*=waiting、进行中=doing、已驳回/异常=danger)、formatActivityTime 四段(今日补前导 0 / 昨日 / MM-DD / 空与非法→「—」)、activityStatusOf 先按类型分流(io status=null 仍是「已完成」,不落 Number(null)===0 陷阱;未识别类型与未识别盘点 status 回落 neutral)、buildActivityFeed 同单号留 operateTime 最新且倒序截断 5 条(total 8/deduped 1/overflow 2)、盘点行不带占位 itemCount(无「项」)而 io/transfer 必须带「N 项」、原始 id/orderId/orderNo/activityType/direction/status 全保留、超时只标 waiting 且早于 now−24h(done/doing 再老也不标)并幂等、diffActivityIds 首屏不高亮/只返回新增/同批二次为空、模板四段结构与单号 prevent.stop、超时角标、空态固定文案、「全部动态 ›」仍走 open(io)、3000ms 高亮定时器与 beforeDestroy 清理、HomeOverview.vue 与 dashboard-theme.scss 均无滚动 API、scss 五条 tone 规则与 feed-new-fade 关键帧。'); (async () => { const state = { receiving: false, preparedReceipts: [{ requestKey: 'a' }, { requestKey: 'b' }], receiveQueue: [], filters: {}, $confirm: async () => {}, route() {}, $refs: { actions: { showResults() {} } }, refresh() {}, $message: { error(message) { throw new Error(message); } } }; await Promise.all([page.methods.submitReceipts.call(state), page.methods.submitReceipts.call(state)]); assert.equal(calls, 1, '重复点击只能发出一次批量请求'); assert.equal(state.preparedReceipts.length, 1); assert.equal(state.preparedReceipts[0].requestKey, 'b', '失败申请重试保留原幂等键'); assert.equal(state.receiving, false); console.log('仓储看板业务断言通过:月份、空值、大整数身份、授权入口、待办范围、分组下钻、收货去重与失败重试、周转/呆滞后端分级映射、呆滞分子分母不可颠倒(3÷70=4.29%)、pp 环比缺失回归、口径条阈值占位符与 180 回落、stagnantDays 命名守卫。'); })().catch(error => { console.error(error); process.exitCode = 1; }); // —— G1 深色主题作用域:只允许作用于看板自身,禁止 body 级全局 class —— // 背景:body.wms-dashboard-theme 曾把 .ele-admin-content 下的 Element 组件整片刷成深色, // 首页下钻到出库单详情、盘点工单详情等业务页时 URL 带着 dashboard=1,新页签因此整页变深蓝背景。 const dashboardElementCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-element.scss'), 'utf8')); const dashboardVueCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8')); const routerCode = stripCodeComments(fs.readFileSync(path.join(root, 'src/router/index.js'), 'utf8')); assert.doesNotMatch(dashboardElementCode, /body\.[A-Za-z-]+/, 'dashboard-element.scss 不得以 body 级 class 为作用域:它会把下钻后的业务页整页刷成深色'); assert.match(dashboardElementCode, /\.wms-home\s*(,|\{)/, 'dashboard-element.scss 必须保留 .wms-home 作用域,否则看板自身的深色样式全部失效'); assert.match(dashboardElementCode, /\.wms-dashboard-dialog/, '看板专用弹层 .wms-dashboard-dialog 必须保留:append-to-body 的弹层只能靠它着色'); assert.match(dashboardElementCode, /\.wms-dashboard-popper/, '看板专用下拉面板 .wms-dashboard-popper 必须保留:popper 被挂到 body,靠 .wms-home 覆盖不到'); assert.doesNotMatch(dashboardVueCode, /document\.body\.classList/, 'dashboard.vue 不得再操作 body 全局主题 class:销毁/失活时机与目标页渲染交错,残留即污染下一个页签'); assert.doesNotMatch(routerCode, /document\.body\.classList\.(toggle|add|remove)/, 'router 不得按 to.query.dashboard 切换 body 主题 class:dashboard=1 会随首页下钻带到业务页'); const navigateSource = (dashboardVueCode.match(/navigate\(path, query = \{\}\)[^\n]*/) || [''])[0]; assert.ok(navigateSource, 'dashboard.vue 必须保留下钻方法 navigate'); assert.doesNotMatch(navigateSource, /dashboard: *'1'/, 'navigate 不得把 dashboard=1 带进业务页 URL:系统原有入口不携带该参数'); assert.match(navigateSource, /NavigationDuplicated/, 'navigate 必须吞掉 NavigationDuplicated:重复点同一行不该弹错误提示'); console.log('G1 深色主题作用域断言通过:dashboard-element.scss 不再挂 body 级 class(下钻到出库单详情/盘点工单详情等业务页保持系统白底)、.wms-home/.wms-dashboard-dialog/.wms-dashboard-popper 三个作用域保留、dashboard.vue 与 router 均不再操作 body 主题 class、navigate 不再带 dashboard=1 且吞掉 NavigationDuplicated。'); // —— H1 首页默认基本单位:不选单位时 KPI 整片为空,必须默认第一个 —— const unitWith = (state) => Object.assign({ defaultUnit: page.methods.defaultUnit }, state); const defaultedState = unitWith({ unit: '', filters: {}, options: { defaultUnit: 'PCS', units: [{ unit: '个' }, { unit: 'PCS' }] } }); page.methods.applyDefaultUnit.call(defaultedState, false); assert.equal(defaultedState.unit, 'PCS', 'URL 未带 unit 时必须默认选中后端 home-options 的 defaultUnit'); assert.equal(defaultedState.filters.unit, 'PCS', '默认单位必须同时写进 filters,否则请求参数里根本没有 unit'); const explicitEmptyState = unitWith({ unit: '', filters: {}, options: { defaultUnit: 'PCS' } }); page.methods.applyDefaultUnit.call(explicitEmptyState, true); assert.equal(explicitEmptyState.unit, '', 'URL 显式 unit=(用户主动选「全部单位」)时必须保持空,不能被默认值覆盖'); assert.deepEqual(explicitEmptyState.filters, {}, '显式空单位不得写进 filters'); const keepState = unitWith({ unit: 'KG', filters: { unit: 'KG' }, options: { defaultUnit: 'PCS' } }); page.methods.applyDefaultUnit.call(keepState, false); assert.equal(keepState.unit, 'KG', '用户已选单位不得被默认值覆盖'); assert.equal(page.methods.defaultUnit.call({ options: { units: [{ unit: ' ' }, { unit: 'KG' }] } }), 'KG', 'defaultUnit 缺失时必须回退 units 第一个非空单位(空白不算)'); assert.equal(page.methods.defaultUnit.call({ options: { units: [] } }), '', '没有任何单位时返回空串,不得编造默认值'); assert.equal(page.methods.defaultUnit.call({ options: { units: [{ unit: '个' }] } }), '个', '未下发 defaultUnit 的老响应按 units[0] 回退'); const homeUnitSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8')); assert.match(homeUnitSource, /if \(this\.view === 'home'\) this\.applyDefaultUnit\(/, 'readRoute 必须在首页兜底默认单位(首次进入、切仓、返回首页都走这里)'); assert.match(homeUnitSource, /this\.applyDefaultUnit\(Object\.prototype\.hasOwnProperty\.call\(this\.\$route\.query, 'unit'\)\)/, 'created 拉到筛选项后必须再兜一次默认单位:第一次 readRoute 时 options 还没到'); assert.doesNotMatch(homeUnitSource, /warehouseId, unit: ''/, '切换仓库不得清空基本单位:清空后立刻落到「全部单位」,首页又没有数据'); assert.match(homeUnitSource, /if \(view === 'home' && this\.view !== 'home' && !filters\.unit\) filters\.unit = this\.defaultUnit\(\)/, '从出入库申请/入总库申请返回首页必须补回单位,否则业务视图清掉的空单位会一直留着'); console.log('H1 首页默认基本单位断言通过:URL 未带 unit 时默认选中 home-options.defaultUnit(缺失回退 units 首个非空单位)并写进请求参数、显式「全部单位」不被覆盖、已选单位不被覆盖、切换仓库与从业务视图返回首页都不再落回「全部单位」。'); // —— I1 盘点工单详情下钻:必须带 planId,且不得把首页筛选整包塞进业务页 URL —— let pushed = null; const navState = { filters: { startTime: '2026-10-01 00:00:00', endTime: '2026-10-31 23:59:59', page: '1', warehouseId: '2103077489545842690', unit: 'KG', categoryId: '2082647086249619457' }, $router: { push(target) { pushed = target; return Promise.resolve(); } }, $message: { error(message) { throw new Error(message); } } }; // cleanQuery 在 vm 上下文里执行,返回对象与本上下文原型不同,deepEqual 会误报,统一用 JSON 串比对。 const sameQuery = (actual, expected, message) => assert.equal(JSON.stringify(actual), JSON.stringify(expected), message); page.methods.navigate.call(navState, '/warehouseManagement/stocktaking/work/details', { id: '2115696383397093378', planId: '2115696383174795265' }); sameQuery(pushed.query, { id: '2115696383397093378', planId: '2115696383174795265' }, '盘点工单详情 URL 只允许 id 与 planId:首页的 startTime/endTime/page/unit/categoryId/warehouseId 一个都不该带进去'); assert.equal(pushed.path, '/warehouseManagement/stocktaking/work/details'); pushed = null; page.methods.navigate.call(navState, '/warehouseManagement/outgoingManagement/details', { id: '2115696383397093378', planId: undefined }); sameQuery(pushed.query, { id: '2115696383397093378' }, '没有 planId 的入口(旧数据/旧链接)不得拼出 planId=undefined'); let businessPush = null; const businessState = { navigate(path, query) { businessPush = { path, query }; }, open() {}, portal() {}, filters: {}, $refs: { actions: { open() {} } }, $message: { warning() {}, error() {} } }; businessPush = null; page.methods.business.call(businessState, 'todo', { type: 'inventory', id: '2115696383397093378', planId: '2115696383174795265' }); assert.equal(businessPush.path, '/warehouseManagement/stocktaking/work/details'); sameQuery(businessPush.query, { id: '2115696383397093378', planId: '2115696383174795265' }, '待办「盘点任务进行中」下钻必须同时带 id(planOrderId) 与 planId,否则详情页拿不到计划信息'); businessPush = null; page.methods.business.call(businessState, 'io', { activityType: 'inventory', orderId: '2115696383397093378', planId: '2115696383174795265' }); assert.equal(businessPush.path, '/warehouseManagement/stocktaking/work/details'); sameQuery(businessPush.query, { id: '2115696383397093378', planId: '2115696383174795265' }, '出入库动态里的盘点流水下钻同样要带 planId'); const navigateNoFiltersSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8')).match(/navigate\(path, query = \{\}\)[^\n]*/)[0]; assert.doesNotMatch(navigateNoFiltersSource, /\.\.\.this\.filters/, 'navigate 不得再整包透传首页筛选(this.filters)到业务页 URL'); console.log('I1 盘点工单详情下钻断言通过:待办/动态两个入口都带 id+planId、planId 缺失时不拼 undefined、下钻 URL 只保留显式参数(首页 startTime/endTime/page/unit/categoryId/warehouseId 不再进业务页)。'); // —— J1 「出入库动态 › 查看动态」必须跳系统菜单真实路由 warehouseManagement/inboundRequests —— // 不是首页内嵌的 ioapp 视图,也不是自建 V4 动态列表:用户要找的是「出入库申请」这张菜单页本身。 const homeTplSource = fs.readFileSync(path.join(folder, 'components/HomeOverview.vue'), 'utf8'); const trendLink = (homeTplSource.match(/]*>查看动态 ›<\/a>/) || [])[0] || ''; assert.ok(trendLink, '「查看动态 ›」入口必须存在'); assert.match(trendLink, /\$emit\('navigate', '\/warehouseManagement\/inboundRequests', \{ warehouseId: warehouseId \}\)/, '「查看动态 ›」必须发 navigate(\'/warehouseManagement/inboundRequests\', { warehouseId }):跳菜单真实路由,而不是内嵌 ioapp 视图'); assert.doesNotMatch(trendLink, /\$emit\('open', 'ioapp'/, '不得再用内嵌 ioapp 视图顶替菜单页'); assert.doesNotMatch(trendLink, /\$emit\('open', 'io'\)/, '也不得落到自建 V4 动态列表'); const dashboardTplSource = fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'); const homeOverviewTag = (dashboardTplSource.match(/]*>/) || [])[0] || ''; assert.match(homeOverviewTag, /@navigate="navigate"/, 'dashboard.vue 必须把 home-overview 的 navigate 事件接到 navigate(),否则点「查看动态」无反应'); let routePushed = null; const routeState = { $route: { path: '/warehouseManagement/index', query: {} }, $router: { push(target) { routePushed = target; return Promise.resolve(); } }, $message: { error(message) { throw new Error(message); } } }; page.methods.navigate.call(routeState, '/warehouseManagement/inboundRequests', { warehouseId: '2103077489545842690' }); assert.equal(routePushed.path, '/warehouseManagement/inboundRequests', '必须跳真实菜单路由,路径与系统菜单一致'); sameQuery(routePushed.query, { warehouseId: '2103077489545842690' }, '只带当前仓库,状态/时间/页签交给目标页默认值'); assert.ok(!('dashboard' in routePushed.query), '跳菜单页不得再带 dashboard=1:会误开看板批量收货勾选列'); routePushed = null; page.methods.navigate.call(routeState, '/warehouseManagement/inboundRequests', { warehouseId: '' }); sameQuery(routePushed.query, {}, '未选仓库(全部授权仓库)时 query 必须为空,不能塞空串或首页筛选'); const allLink = (homeTplSource.match(/]*>全部动态 ›<\/a>/) || [])[0] || ''; assert.match(allLink, /\$emit\('open', 'io'\)/, '「全部动态 ›」仍落 V4 动态列表,不被本次改动带走'); console.log('J1 出入库动态入口断言通过:「查看动态 ›」发 navigate(\'/warehouseManagement/inboundRequests\', { warehouseId }),dashboard.vue 已接 @navigate,跳真实菜单路由(独立页签)、只带仓库、不带 dashboard=1;「全部动态 ›」与趋势柱体仍落 V4 动态列表。'); // —— K1 首页首屏遮罩:数据回来之前不显示内容,且只在首屏出现一次 —— const dashSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8')); const maskTag = (dashSource.match(/]*class="home-loading"[^>]*>/) || [])[0] || ''; assert.ok(maskTag, 'dashboard.vue 必须有首屏遮罩节点(.home-loading)'); assert.match(maskTag, /v-if="homeLoading && view === 'home'"/, '遮罩只能在首页视图且 homeLoading 为真时出现:明细视图有自己的 v-loading,不能被这块盖住'); assert.ok(dashSource.includes('正在加载首页数据'), '遮罩必须给出中文加载文案,不能只放一个转圈'); assert.equal(page.data().homeLoading, true, 'homeLoading 必须默认 true:首屏还没请求就不能先渲染空卡片'); const createdBlock = dashSource.slice(dashSource.indexOf('async created()'), dashSource.indexOf('activated()')); assert.match(createdBlock, /this\.homeLoading = false;/, 'created 里首屏(筛选项 + 汇总)结束后必须撤掉遮罩'); assert.ok(createdBlock.indexOf('this.homeLoading = false') > createdBlock.indexOf('await this.refresh()'), '必须先 await refresh() 再撤遮罩,否则数据还没到就露出来了'); assert.ok((dashSource.match(/this\.homeLoading = false/g) || []).length === 1, 'homeLoading 只能在首屏置一次 false:定时刷新与筛选项切换不该闪遮罩'); const layoutScss = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-layout.scss'), 'utf8')); assert.match(layoutScss, /\.wms-home\s*\{[^}]*position:\s*relative/, '.wms-home 必须是定位上下文,否则遮罩会跑去页面别处'); assert.match(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*position:\s*absolute/, '遮罩必须绝对定位在内容区内'); assert.doesNotMatch(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*position:\s*fixed/, '遮罩不得用 fixed:会把系统页头、菜单、标签栏一起盖掉'); assert.match(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*z-index:\s*\d+/, '遮罩必须显式抬层级,否则盖不住卡片'); assert.match(layoutScss, /@keyframes\s+home-loading-spin\s*\{[\s\S]{0,120}?rotate\(360deg\)/, '转圈动画必须存在,否则 spinner 静态不转'); console.log('K1 首页首屏遮罩断言通过:homeLoading 默认 true、created 中 await refresh() 之后置 false(且只置一次),遮罩仅在首页视图出现并带中文文案;样式为 .wms-home 内的 absolute 遮罩(非 fixed,不盖系统页头/菜单)+ z-index + 转圈关键帧。');