check-warehouse-dashboard.cjs 134 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299
  1. // 只读断言:直接加载现有组件方法,禁止发出真实业务请求。
  2. const assert = require('assert/strict');
  3. const fs = require('fs');
  4. const path = require('path');
  5. const vm = require('vm');
  6. const root = path.resolve(__dirname, '..');
  7. const folder = path.join(root, 'src/views/warehouseManagement/index');
  8. const compiler = require(path.join(root, 'node_modules/vue-template-compiler'));
  9. function load(file, bindings = {}) {
  10. let source = fs.readFileSync(path.join(folder, file), 'utf8');
  11. if (file.endsWith('.vue')) source = compiler.parseComponent(source).script.content;
  12. source = source.replace(/^import .*;$/gm, '').replace(/export default/, 'module.exports =').replace(/export (function|const)/g, '$1');
  13. const context = { module: { exports: {} }, ...bindings };
  14. vm.runInNewContext(source, context, { filename: file });
  15. return { value: context.module.exports, context };
  16. }
  17. const { context: utils } = load('dashboard-utils.js');
  18. const { monthRange, cleanQuery, displayNumber, findPortalEntry, formatPpChange, fillStagnantDays } = utils;
  19. // const 导出只落在 vm 上下文的词法作用域里、不会成为上下文属性,所以用 runInContext 取回常量,
  20. // 再作为绑定传给组件脚本(组件里的 import 被 load() 剥掉了)。
  21. const utilConst = (name) => vm.runInContext(name, utils);
  22. const STAGNANT_DEFAULT_DAYS = utilConst('STAGNANT_DEFAULT_DAYS');
  23. assert.equal(STAGNANT_DEFAULT_DAYS, 180, '呆滞阈值默认回落值必须是 180 天');
  24. assert.equal(utilConst('STAGNANT_DAYS_PLACEHOLDER'), '{stagnantDays}', '口径条占位符必须与 dashboard-screens.js 的文案一致');
  25. // 百分点差值:带符号 + 固定 2 位小数 + pp 单位,0 视为持平。
  26. // 这条坏了会把「占比抬升」显示成没有单位的小数,用户无法判断是 pp 还是 %。
  27. assert.equal(formatPpChange(0.5), '+0.50pp', '上升必须带 + 号、2 位小数与 pp 单位');
  28. assert.equal(formatPpChange(-1.2), '-1.20pp', '下降必须带 - 号,不能用 0 顶替');
  29. assert.equal(formatPpChange(0), '持平', '差值为 0 只显示持平,不显示 +0.00pp');
  30. assert.equal(formatPpChange(null), '—', '空值返回占位符,调用方不得把它当 0 展示');
  31. // 阈值占位符替换:有值用值,缺失回落 180;不含占位符的文案原样返回(其它屏必须一字不改)。
  32. assert.equal(fillStagnantDays('库龄超过 {stagnantDays} 天', 90), '库龄超过 90 天');
  33. assert.equal(fillStagnantDays('库龄超过 {stagnantDays} 天', null), '库龄超过 180 天', '阈值缺失必须回落默认 180 天');
  34. assert.equal(fillStagnantDays('库龄超过 {stagnantDays} 天', 0), '库龄超过 180 天', '非法阈值(0)也要回落默认值');
  35. assert.equal(fillStagnantDays('无占位符文案', 90), '无占位符文案', '不含占位符的屏幕文案必须原样返回');
  36. assert.equal(monthRange(new Date(2024, 1, 15)).endTime, '2024-02-29 23:59:59');
  37. assert.equal(monthRange(new Date(2026, 11, 31)).startTime, '2026-12-01 00:00:00');
  38. assert.equal(displayNumber(null), '—');
  39. assert.equal(displayNumber(''), '—');
  40. assert.equal(displayNumber(0), '0');
  41. assert.equal(cleanQuery({ id: '9223372036854775806', status: 0, scope: false, empty: '', missing: null }).id, '9223372036854775806');
  42. assert.equal(cleanQuery({ status: 0 }).status, '0');
  43. assert.equal(cleanQuery({ scope: false }).scope, 'false');
  44. const menus = [{ path: '/page-wt', children: [{ path: '/bpm', children: [{ path: 'todo', component: 'bpm/todo/index' }] }] }];
  45. assert.equal(findPortalEntry(menus, n => /bpm\/todo/.test(n.component || '')), '/page-wt/bpm/todo');
  46. assert.equal(findPortalEntry([{ path: '/page-eom', children: [{ path: '/purchasingManage/purchaseNeedManage', name: '采购' }] }], n => n.name === '采购'), '/page-eom/purchasingManage/purchaseNeedManage');
  47. assert.equal(findPortalEntry([{ path: 'https://example.com', name: '外部' }], () => true), '');
  48. assert.equal(findPortalEntry(menus, n => n.name === '无权限菜单'), '');
  49. let calls = 0;
  50. const { context: screens } = load('dashboard-screens.js');
  51. const { value: page } = load('dashboard.vue', {
  52. HomeOverview: {}, DashboardDetail: {}, DashboardActions: {}, ...utils, screens: screens.screens,
  53. dashboardAction: async () => { calls++; return [{ key: 'a', success: true }, { key: 'b', success: false }]; }
  54. });
  55. for (const entry of ['index.vue', 'common.vue', 'yuxin.vue']) {
  56. const source = fs.readFileSync(path.join(folder, entry), 'utf8');
  57. assert.match(source, /from ['"]\.\/dashboard\.vue['"]/);
  58. assert.doesNotMatch(source, /from ['"]\.\/index\.vue['"]/);
  59. }
  60. const open = { filters: { unit: '吨', categoryId: '1', stockBatchId: '2' }, view: 'home', route(view, filters) { this.target = view; this.nextFilters = filters; } };
  61. page.methods.open.call(open, 'todo');
  62. assert.equal(open.nextFilters.type, 'inventory');
  63. page.methods.open.call(open, 'ioapp');
  64. assert.equal(open.nextFilters.status, '0');
  65. assert.equal(open.nextFilters.unit, '');
  66. assert.equal(open.nextFilters.categoryId, undefined);
  67. const receipt = { receiveQueue: [{ id: '1' }, { id: '2' }], preparedReceipts: [], receiving: false, $route: { query: { inId: '1' } }, nextReceipt() {} };
  68. page.methods.prepareReceipt.call(receipt, { sourceBizNo: 'R1' });
  69. page.methods.prepareReceipt.call(receipt, { sourceBizNo: 'R1' });
  70. assert.equal(receipt.preparedReceipts.length, 1, '旧表单重复事件不能准备到下一张申请');
  71. // 计算属性在真实 Vue 实例里通过 this 互相访问;这里用 defineProperty 复现同一解析方式,
  72. // 否则 kpis 里读取 this.stockWeekOverWeek / this.stockValueWeekOverWeek 会拿到 undefined。
  73. function componentState(component, overrides = {}) {
  74. const state = { ...overrides };
  75. for (const [key, getter] of Object.entries(component.computed || {})) {
  76. Object.defineProperty(state, key, { configurable: true, get: () => getter.call(state) });
  77. }
  78. return state;
  79. }
  80. const { value: overview } = load('components/HomeOverview.vue', { DashboardChart: {}, ...utils, STAGNANT_DEFAULT_DAYS });
  81. const kpis = overview.computed.kpis.call(componentState(overview, { summary: {}, unit: '吨' }));
  82. assert.equal(kpis.find(item => item.key === 'stock').target, 'stockLedger');
  83. assert.equal(kpis.find(item => item.key === 'value').target, 'stockLedger');
  84. // 库存金额为空值时只显示成本覆盖原因,不能把缺价显示成 0 元。
  85. const valueEmpty = kpis.find(item => item.key === 'value');
  86. assert.equal(valueEmpty.value, undefined);
  87. assert.equal(valueEmpty.delta, '库存金额待成本数据核实');
  88. // 有完整金额和可比快照时,角标为带符号百分比,并带出同一基准日期。
  89. const valueReady = overview.computed.kpis.call(componentState(overview, {
  90. summary: { inventoryValue: 12345.67, inventoryValueChangeRate: 8.5, comparisonBaseAt: '2026-10-19 23:59:00' }, unit: '吨'
  91. })).find(item => item.key === 'value');
  92. assert.equal(valueReady.value, 12345.67);
  93. assert.match(valueReady.delta, /较上周 \+8\.50%/);
  94. assert.match(valueReady.delta, /2026-10-19 日末/);
  95. // 缺价批次导致金额为空时,原样展示服务端覆盖原因,不能用 0% 或部分金额兜底。
  96. const valueUnknown = overview.computed.kpis.call(componentState(overview, {
  97. summary: { inventoryValue: null, valuationReason: '已计价 3/70 个批次,存在缺价批次' }, unit: ''
  98. })).find(item => item.key === 'value');
  99. assert.equal(valueUnknown.delta, '已计价 3/70 个批次,存在缺价批次');
  100. const todos = overview.computed.todoCards.call({ summary: {} });
  101. // 待办条下钻目标:入/出库都必须落到 V13 出入库申请,各自带自己的 direction。
  102. // 这两条坏了会把待出库单送进入总库申请(inapp),卡片数字与下钻列表立刻对不上。
  103. const todoOf = (key) => todos.find(item => item.key === key);
  104. assert.equal(todoOf('in').target, 'ioapp', '待入库单必须下钻 V13 出入库申请(入库页签),落到其它入口会让卡片数字与列表对不上');
  105. assert.equal(todoOf('out').target, 'ioapp', '待出库单必须下钻 V13 出入库申请(出库页签),指向 inapp 会让卡片数字与列表对不上');
  106. assert.equal((todoOf('in').filters || {}).direction, 'in', '待入库单必须显式带 direction=in,不能依赖 open() 的兜底');
  107. assert.equal((todoOf('out').filters || {}).direction, 'out', '待出库单必须下钻出库页签(direction=out),否则卡片数字与下钻列表对不上');
  108. // 库存周转卡片:目标/预警天数、状态、较上月差值全部由后端下发,前端只做映射展示。
  109. const turnoverKpi = (summary, unit = '天') => overview.computed.kpis.call(componentState(overview, { summary, unit })).find(item => item.key === 'turnover');
  110. const turnoverBase = { turnoverDays: 52.5, turnoverTargetDays: 45, turnoverWarnDays: 60, turnoverAsOf: '2026-10-19' };
  111. // 状态四态:颜色与文案只认后端的 达标/超目标/偏慢,null 走中性色并留空文案。
  112. // 这条坏了说明前端又在自算阈值,仓库配置的目标/预警天数会失效、健康度会误报。
  113. assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标' }).color, '#3BD48A', '达标必须取后端状态给绿色,前端自算阈值会误报健康度');
  114. assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标' }).status, '达标', '达标文案必须来自后端 turnoverStatus');
  115. assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '超目标' }).color, '#F0A03A', '超目标必须取橙色,否则看不出已过目标值');
  116. assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '偏慢' }).color, '#F25E5E', '偏慢必须取红色,否则周转偏慢会被当成正常');
  117. assert.equal(turnoverKpi({ ...turnoverBase, turnoverStatus: '偏慢' }).status, '周转偏慢', '偏慢文案沿用“周转偏慢”的既有措辞');
  118. const turnoverUnknown = turnoverKpi({ ...turnoverBase, turnoverStatus: null });
  119. assert.equal(turnoverUnknown.color, '#9FB8D4', '状态缺失必须回落中性色,不能凭空判定健康度');
  120. assert.equal(turnoverUnknown.status, '', '状态缺失时状态文案留空,不得由前端补造');
  121. // 较上月四态:负值(周转加快)绿、正值红、0 持平、null 只给原因。
  122. // 这条坏了会把「上月不可比」显示成 0 天,等于伪造环比数据。
  123. const turnoverDown = turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverChange: -3.2 });
  124. assert.match(turnoverDown.delta, /较上月 -3\.2 天/, '周转天数下降要显示“较上月 -3.2 天”');
  125. assert.equal(turnoverDown.deltaTone, 'good', '差值下降是向好,必须走绿色语义');
  126. const turnoverUp = turnoverKpi({ ...turnoverBase, turnoverStatus: '偏慢', turnoverChange: 8 });
  127. assert.equal(turnoverUp.deltaTone, 'bad', '差值上升是变慢,必须走红色语义');
  128. assert.match(turnoverUp.delta, /较上月 \+8 天/, '周转天数上升要显示带正号的差值');
  129. const turnoverFlat = turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverChange: 0 });
  130. assert.equal(turnoverFlat.delta, '较上月持平', '差值 0 只显示持平,不显示 0 天');
  131. assert.equal(turnoverFlat.deltaTone, 'dim', '持平不带健康度色调');
  132. const turnoverNoChange = turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverChange: null, turnoverChangeReason: '上月无日末快照' });
  133. assert.equal(turnoverNoChange.delta, '上月无日末快照', '不可比时必须原样展示后端原因');
  134. assert.doesNotMatch(turnoverNoChange.delta, /0 天/, '不可比绝不显示成 0 天');
  135. // 后端字段未上线的过渡态:卡片必须能渲染,用中性色与空状态兜底,页面不报错。
  136. const turnoverLegacy = turnoverKpi({});
  137. assert.equal(turnoverLegacy.color, '#9FB8D4', '后端字段缺失时用中性色,不能让卡片崩掉');
  138. assert.equal(turnoverLegacy.status, '', '后端字段缺失时状态留空');
  139. assert.equal(turnoverLegacy.delta, '', '没有 turnoverChange 时不显示环比,更不能显示 0');
  140. assert.equal(turnoverLegacy.value, undefined, '周转天数为空时交给 displayKpi 显示占位符');
  141. // T+1 标注:日末快照日期来自 turnoverAsOf,格式不可信时退回「截至昨日」。
  142. // 这条坏了用户会把 T+1 的旧值当成实时值看。
  143. assert.match(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标' }).subtotal.text, /截至 2026-10-19 日末/, '必须用 turnoverAsOf 标注 T+1 日末快照日期');
  144. assert.match(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverAsOf: '2026/10/19' }).subtotal.text, /截至昨日/, 'asOf 格式不符时退回“截至昨日”');
  145. assert.match(turnoverKpi({ ...turnoverBase, turnoverStatus: '达标', turnoverAsOf: null }).subtotal.text, /截至昨日/, 'asOf 为空时退回“截至昨日”');
  146. // 防回归:周转阈值判定只能在后端,源码里不得再出现 turnoverDays 的比较运算。
  147. // 这条坏了意味着有人把 45/60 写回前端,仓库配置的目标/预警天数立刻失效。
  148. const overviewSource = fs.readFileSync(path.join(folder, 'components/HomeOverview.vue'), 'utf8');
  149. assert.doesNotMatch(overviewSource, /turnoverDays\s*<=/, '周转阈值判定必须留在后端,前端写回 <= 会让仓库配置失效');
  150. assert.doesNotMatch(overviewSource, /turnoverDays\s*>=/, '周转阈值判定必须留在后端,前端写回 >= 会让仓库配置失效');
  151. assert.doesNotMatch(overviewSource, /turnover(Days|TargetDays|WarnDays)\s*[<>]/, '周转/目标/预警天数不得在前端做大小比较,阈值由后端唯一判定');
  152. // 呆滞品占比卡片:分级、阈值、环比差值全部由后端下发,前端只做映射。
  153. // 分级必须只认后端 stagnantLevel:前端一旦重算 8%/10%,仓库配置的天数阈值与 +0.5pp 入口级立刻失效。
  154. const stagnantKpi = (summary) => overview.computed.kpis.call(componentState(overview, { summary, unit: '%' })).find(item => item.key === 'stagnant');
  155. // 后端契约:stagnantBatchCount=呆滞批次数(分子 X),stagnantKnownBatchCount=可核实批次数(分母 Y),
  156. // stagnantUnknownBatchCount=日期未知批次数;总批次=known+unknown,只用于文案,不参与比值。
  157. // 这里刻意让「分子 3 / 分母 70 / 日期未知 5」三者互不相同,任何把分子当分母或拿总数顶替的写法都会露馅。
  158. const stagnantBase = { stagnantRate: 4.29, stagnantDays: 180, stagnantBatchCount: 3, stagnantKnownBatchCount: 70, stagnantUnknownBatchCount: 5 };
  159. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal' }).color, '#3BD48A', 'normal 必须映射绿色');
  160. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal' }).status, '正常', 'normal 文案为“正常”');
  161. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'near' }).color, '#F0A03A', 'near 必须映射橙色');
  162. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'near' }).status, '接近阈值', 'near 文案为“接近阈值”');
  163. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'alert' }).color, '#F25E5E', 'alert 必须映射红色');
  164. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'alert' }).status, '呆滞预警', 'alert 文案为“呆滞预警”');
  165. // 红优先:占比 <10% 但后端因环比入口级判 alert 时,前端不得用 8%/10% 把红色改回橙色。
  166. assert.equal(stagnantKpi({ ...stagnantBase, stagnantRate: 8.2, stagnantLevel: 'alert', stagnantChange: 0.6 }).color, '#F25E5E', '分级以后端为准,alert 不得被前端重算成橙色');
  167. // +0.5pp 入口级:占比仅 8.2%(未到 10%),后端因环比抬升 >= +0.5pp 判 near,前端必须照映射走橙色与“接近阈值”。
  168. // 这条坏了意味着环比入口级在后端生效、前端却按 8%/10% 把它显示成绿色「正常」。
  169. const stagnantEntry = stagnantKpi({ ...stagnantBase, stagnantRate: 8.2, stagnantLevel: 'near', stagnantChange: 0.5, stagnantBaseDate: '2026-09-30' });
  170. assert.equal(stagnantEntry.color, '#F0A03A', '+0.5pp 入口级必须走橙色,不得由前端按 8% 自行判绿');
  171. assert.equal(stagnantEntry.status, '接近阈值', '+0.5pp 入口级文案为“接近阈值”');
  172. assert.match(stagnantEntry.delta, /较上月 \+0\.50pp/, '入口级差值必须原样展示 +0.50pp');
  173. const stagnantUnknown = stagnantKpi({ ...stagnantBase, stagnantLevel: null });
  174. assert.equal(stagnantUnknown.color, '#9FB8D4', '分级缺失必须回落中性色,不能凭空判定健康度');
  175. assert.equal(stagnantUnknown.status, '', '分级缺失时状态文案留空,不得由前端补造');
  176. // 环比单位 pp:有值带符号 + pp 单位 + 基准日末;上升(恶化)走 bad、下降走 good、0 持平,与同屏周转卡一致。
  177. const stagnantUp = stagnantKpi({ ...stagnantBase, stagnantLevel: 'alert', stagnantChange: 0.6, stagnantBaseDate: '2026-09-30' });
  178. assert.match(stagnantUp.delta, /较上月 \+0\.60pp/, '环比必须带正号与 pp 单位');
  179. assert.match(stagnantUp.delta, /2026-09-30 日末/, '环比必须标注上月最后一个完整日结快照日期');
  180. assert.equal(stagnantUp.deltaTone, 'bad', '呆滞占比上升是恶化,与同屏周转卡统一走 bad(红)');
  181. const stagnantDown = stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: -1.2, stagnantBaseDate: '2026-09-30' });
  182. assert.match(stagnantDown.delta, /较上月 -1\.20pp/, '下降必须带负号,不能取绝对值');
  183. assert.equal(stagnantDown.deltaTone, 'good', '占比下降是好转,走绿色语义');
  184. const stagnantFlat = stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: 0, stagnantBaseDate: '2026-09-30' });
  185. assert.equal(stagnantFlat.delta, '较上月持平(基准 2026-09-30 日末)', '差值为 0 只显示持平,不显示 +0.00pp');
  186. assert.equal(stagnantFlat.deltaTone, 'dim', '持平不带健康度色调');
  187. // 环比不可比:只给原因,绝不显示 0 或 +0.00pp;原因按 环比原因 → 口径原因 → 数据待核实 逐级回落。
  188. const stagnantNoChange = stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: null, stagnantChangeReason: '上月无完整日结快照' });
  189. assert.equal(stagnantNoChange.delta, '上月无完整日结快照', '不可比时必须原样展示后端环比原因');
  190. assert.doesNotMatch(stagnantNoChange.delta, /0\.00pp|\+0/, '不可比绝不显示 +0.00pp');
  191. assert.equal(stagnantNoChange.delta === '0' || /\+?0(\.0+)?pp/.test(stagnantNoChange.delta), false, '不可比时不得以 0 或 +0.00pp 顶替');
  192. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'near', stagnantChange: null }).deltaTone, 'dim', '环比缺失时色调必须中性,不得暗示上升或下降');
  193. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: null, stagnantReason: '多仓汇总按默认 180 天' }).delta, '多仓汇总按默认 180 天', '环比原因缺失时回落到口径原因');
  194. assert.equal(stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal', stagnantChange: null }).delta, '数据待核实', '两个原因都缺失时只显示“数据待核实”,不得显示 0');
  195. // 分子/分母不可颠倒:占比复核必须用 呆滞批次数 ÷ 可核实批次数(3÷70=4.29%),
  196. // 错用 3÷75 或 70÷75 说明有人把 stagnantBatchCount 当分母、或拿总批次顶替可核实分母。
  197. const stagnantRatio = stagnantKpi({ ...stagnantBase, stagnantLevel: 'normal' }).subtotal.title;
  198. assert.match(stagnantRatio, /呆滞 3 批次 ÷ 可核实 70 批次 = 4\.29%/, 'X/Y 必须用 known 作分母:3÷70=4.29%,不是 3÷75 或 70÷75');
  199. assert.doesNotMatch(stagnantRatio, /= 4%/, '不得把 3÷75 的近似值当成复核比值');
  200. assert.doesNotMatch(stagnantRatio, /= 93\.33%/, '不得把 70÷75 当成呆滞占比');
  201. // 分母缺失(Y 缺失)时比值不可计算,只标注信息缺失,绝不拿总批次顶替分母。
  202. const stagnantNoDenominator = stagnantKpi({ stagnantBatchCount: 3, stagnantUnknownBatchCount: 5, stagnantLevel: 'normal' });
  203. assert.match(stagnantNoDenominator.info, /可核实批次数待核实/, '分母缺失必须标注待核实,不能拿 total 冒充可核实分母');
  204. assert.match(stagnantNoDenominator.subtotal.title, /比值不可计算/, '分母缺失时不得给出任何比值');
  205. assert.equal(stagnantNoDenominator.progress, null, '占比缺失时进度条必须为 null,不得用近似值顶替');
  206. // 阈值与可核实分母:info 文案取后端 stagnantDays;缺失时天数位仍是 180 天,但括号注明是默认阈值。
  207. assert.match(stagnantKpi({ ...stagnantBase, stagnantDays: 90, stagnantLevel: 'near' }).info, /库龄 > 90 天且期间无任何出库\/领用动态的批次数 ÷ 可核实批次数/, 'info 阈值必须取后端 stagnantDays');
  208. assert.match(stagnantKpi({ ...stagnantBase, stagnantDays: 90, stagnantLevel: 'near' }).info, /可核实 70\/75 批次/, 'info 必须给出可核实 X/Y 批次(X=可核实,Y=可核实+日期未知)');
  209. assert.match(stagnantKpi({ ...stagnantBase, stagnantDays: null, stagnantLevel: 'near' }).info, /库龄 > 180 天(默认阈值,仓库未配置)且期间无任何出库\/领用动态的批次数/, '阈值缺失时天数位仍是 180 天,来源写在括号里');
  210. assert.doesNotMatch(stagnantKpi({ ...stagnantBase, stagnantDays: null, stagnantLevel: 'near' }).info, /按默认 180 天/, '不得把“按默认”塞进天数位置,形成半截句子');
  211. assert.doesNotMatch(stagnantKpi({ ...stagnantBase, stagnantDays: null, stagnantLevel: 'near' }).info, /\{stagnantDays\}/, 'info 不得漏出占位符字面量');
  212. // 后端字段未上线的过渡态:卡片能渲染、中性色、状态留空、环比为空、不出现 NaN。
  213. const stagnantLegacy = stagnantKpi({});
  214. assert.equal(stagnantLegacy.color, '#9FB8D4', '后端字段缺失时用中性色,不能让卡片崩掉');
  215. assert.equal(stagnantLegacy.status, '', '后端字段缺失时状态留空');
  216. assert.equal(stagnantLegacy.delta, '数据待核实', '后端字段缺失时环比只给“数据待核实”');
  217. assert.equal(stagnantLegacy.progress, null, '占比为空时进度条不渲染,不能用 0 冒充');
  218. assert.match(stagnantLegacy.info, /可核实批次数待核实/, '分母字段缺失时标注待核实');
  219. // 防回归:8%/10% 判级必须留在后端,HomeOverview 不得再对 stagnantRate 做大小比较。
  220. assert.doesNotMatch(overviewSource, /stagnantRate\s*[<>]/, '呆滞分级必须留在后端,前端写回 8%/10% 会让仓库阈值与 +0.5pp 入口级失效');
  221. assert.match(overviewSource, /STAGNANT_LEVEL_STYLE/, '分级映射表必须存在,否则状态与颜色无从映射');
  222. // 分子/分母守卫(源码级):stagnantBatchCount 是分子,不得出现在除号右侧;
  223. // 也不得用 known+batchCount 之类的兜底表达式把分子拼进分母。行为级证据是上面的 3÷70=4.29% 断言。
  224. assert.doesNotMatch(overviewSource, /\/\s*s?\.?\s*stagnantBatchCount/, 'stagnantBatchCount 是分子,不得出现在除号右侧当分母');
  225. // 允许 s. 前缀,否则 /s*stagnantBatchCount/ 匹配不到 s.stagnantBatchCount,守卫会空转。
  226. assert.doesNotMatch(overviewSource, /stagnantKnownBatchCount\s*\+\s*(?:s\.)?stagnantBatchCount/, '不得用 known+batchCount 之类表达式掩盖分子/分母错位');
  227. assert.doesNotMatch(overviewSource, /stagnantBatchCount\s*\+\s*(?:s\.)?stagnantUnknownBatchCount/, '总批次只能由 known+unknown 得出,分子不得参与');
  228. assert.match(overviewSource, /stagnantKnownBatchCount/, '可核实批次数必须作为分母被显式读取');
  229. // 口径条占位符:dead 屏把阈值写成 {stagnantDays},由 detail.vue 用同一份 home-summary 阈值替换。
  230. const screenDefs = vm.runInContext('screens', screens);
  231. assert.match(screenDefs.dead.subtitle, /\{stagnantDays\}/, 'dead 屏副标题必须使用阈值占位符,不能写死 180 天');
  232. assert.match(screenDefs.dead.definition, /\{stagnantDays\}/, 'dead 屏口径说明必须使用阈值占位符');
  233. assert.doesNotMatch(screenDefs.dead.subtitle, /180/, 'dead 屏文案不得再写死 180 天');
  234. const { value: detail } = load('detail.vue', { ...utils, screens: screenDefs });
  235. const deadReady = detail.computed.current.call({ view: 'dead', stagnantDays: 90 });
  236. assert.match(deadReady.subtitle, /库龄超过 90 天/, '口径条阈值必须来自 stagnantDays');
  237. assert.doesNotMatch(deadReady.subtitle + deadReady.definition, /\{stagnantDays\}/, '渲染结果不得漏出占位符字面量');
  238. const deadFallback = detail.computed.current.call({ view: 'dead', stagnantDays: null });
  239. assert.match(deadFallback.subtitle, /库龄超过 180 天/, '阈值缺失必须回落 180 天');
  240. // 其它屏不含占位符:渲染结果必须与 screens 原文逐字一致,一个字符都不能变。
  241. for (const key of Object.keys(screenDefs)) {
  242. if (key === 'dead') continue;
  243. const current = detail.computed.current.call({ view: key, stagnantDays: 90 });
  244. assert.equal(current.subtitle, screenDefs[key].subtitle, key + ' 屏副标题不得被阈值替换改动');
  245. assert.equal(current.definition, screenDefs[key].definition, key + ' 屏口径说明不得被阈值替换改动');
  246. }
  247. // dashboard.vue 透传:优先用已请求到的 home-summary.stagnantDays,缺失/非法传 null 交给 detail.vue 回落。
  248. // 字段名与后端契约一致(stagnantDays),避免排查时被误读成另一个概念。
  249. assert.equal(page.computed.stagnantDays.call({ summary: { stagnantDays: 90 } }), 90, '必须把后端阈值透传给口径条');
  250. assert.equal(page.computed.stagnantDays.call({ summary: {} }), null, '阈值缺失要传 null,不能伪装成 180');
  251. assert.equal(page.computed.stagnantDays.call({ summary: { stagnantDays: 'abc' } }), null, '阈值非法要传 null');
  252. assert.equal(page.computed.stagnantDays.call({}), null, 'summary 未加载时也要安全返回 null');
  253. // 命名守卫:源码与脚本里都不得残留 staleDays,防止两个概念混用。
  254. assert.doesNotMatch(fs.readFileSync(path.join(folder, 'detail.vue'), 'utf8'), /staleDays|stale-days/, 'detail.vue 的 prop 必须叫 stagnantDays');
  255. assert.doesNotMatch(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'), /staleDays|stale-days/, 'dashboard.vue 的透传字段必须叫 stagnantDays');
  256. // 原有系统入口:库存台账直接切路由;“呆滞”等首页视图走内部 route,不经过旧入口映射。
  257. const legacy = {
  258. filters: { unit: '吨' },
  259. $router: { push(location) { this.location = location; return Promise.resolve(); } },
  260. route(view, filters) { this.view = view; this.filters = filters; }
  261. };
  262. page.methods.open.call(legacy, 'stockLedger');
  263. assert.equal(legacy.$router.location.path, '/warehouseManagement/stockLedger');
  264. page.methods.open.call(legacy, 'dead');
  265. assert.equal(legacy.view, 'dead');
  266. assert.equal(legacy.filters.unit, '吨');
  267. let navigation = 0;
  268. let navigationEvent = '';
  269. let navigationTarget = '';
  270. overview.methods.categorySelect.call({ $emit(event, target) { navigation++; navigationEvent = event; navigationTarget = target; } }, { id: 'other' });
  271. assert.equal(navigation, 1, '分类图点击进入库存台账');
  272. assert.equal(navigationEvent, 'open');
  273. assert.equal(navigationTarget, 'stockLedger', '“其他”聚合分组也只进入台账,不伪造具体物料');
  274. // D2 分类分布下钻:非「其他」行必须把分类层级 id 带进台账;「其他」是前端 TOP8 的聚合兜底行
  275. // (id 固定为字符串 'other'),只开台账不带分类,绝不伪造具体物料(与上方既有断言同一条口径)。
  276. const drillCalls = [];
  277. const drillEmitter = { unit: '个', $emit(event, target, extra) { drillCalls.push({ event, target, extra }); } };
  278. overview.methods.categorySelect.call(drillEmitter, { id: '1072414', name: '原料', quantity: 120, ratio: 12.5 });
  279. assert.equal(drillCalls.length, 1, '分类图点击只发一次 open');
  280. assert.equal(drillCalls[0].target, 'stockLedger');
  281. assert.equal(drillCalls[0].extra.categoryLevelId, '1072414', '非「其他」行必须带 categoryLevelId 下钻');
  282. assert.deepEqual(Object.keys(drillCalls[0].extra), ['categoryLevelId'], '下钻只传分类层级 id,不携带展示字段');
  283. drillCalls.length = 0;
  284. overview.methods.categorySelect.call(drillEmitter, { id: 'other', name: '其他', quantity: 30, ratio: 5 });
  285. assert.equal(drillCalls.length, 1, '「其他」也必须能点开台账');
  286. assert.equal(drillCalls[0].extra, undefined, '「其他」是聚合兜底行,不得带 categoryLevelId 下钻');
  287. drillCalls.length = 0;
  288. overview.methods.categorySelect.call(drillEmitter);
  289. assert.equal(drillCalls[0].extra, undefined, '没有行数据时退回不过滤,不得伪造 id 也不得抛错');
  290. // dashboard.open('stockLedger', extra):extra 必须经 cleanQuery 合并进 route query,
  291. // 否则首屏下钻参数在跳转瞬间就被丢掉,台账只会打开全量列表。
  292. const ledgerState = { filters: { unit: '吨' }, $router: { push(location) { this.location = location; return Promise.resolve(); } }, $message: { error() {} } };
  293. page.methods.open.call(ledgerState, 'stockLedger', { categoryLevelId: '1072414' });
  294. assert.equal(ledgerState.$router.location.path, '/warehouseManagement/stockLedger', '下钻仍复用既有台账路由,path 不得改');
  295. assert.equal(ledgerState.$router.location.query.categoryLevelId, '1072414', 'extra 必须合并进 route query');
  296. assert.deepEqual(Object.keys(ledgerState.$router.location.query), ['categoryLevelId'], '只带下钻参数,不把首页筛选整包塞进台账 query');
  297. const ledgerEmpty = { filters: {}, $router: { push(location) { this.location = location; return Promise.resolve(); } }, $message: { error() {} } };
  298. page.methods.open.call(ledgerEmpty, 'stockLedger');
  299. // 注意 cleanQuery 在 vm 沙箱里执行,返回的是另一个 realm 的对象,deepStrictEqual 会因原型不同而报错,
  300. // 所以这里比对键数量而不是对象本身。
  301. assert.equal(Object.keys(ledgerEmpty.$router.location.query).length, 0, '没有 extra 时 query 为空对象,等价于旧的「只跳转不过滤」');
  302. // V2 台账读取下钻参数(静态源码断言):台账页必须自己消费 query.categoryLevelId、递归找树节点并选中,
  303. // 找不到时降级为不过滤。三段任一缺失,首页下钻都会静默失效,而静态断言在没有后端数据的环境里也能守住这条链路。
  304. const ledgerSource = fs.readFileSync(path.join(root, 'src/views/warehouseManagement/stockLedger/index.vue'), 'utf8');
  305. assert.match(ledgerSource, /categoryLevelId/, '台账页必须读取下钻参数 categoryLevelId');
  306. assert.match(ledgerSource, /findTreeNode\s*\(/, '台账页必须递归查找分类树节点');
  307. assert.match(ledgerSource, /setCurrentKey\(/, '台账页必须调用 treeRef.setCurrentKey 选中节点');
  308. assert.match(ledgerSource, /\$set\(\s*this\.current\s*,\s*'data'/, 'current 必须补成 { id, data } 供 item-list 读取');
  309. assert.match(ledgerSource, /\[wms-stock-ledger\]/, '台账页日志前缀必须是 [wms-stock-ledger]');
  310. assert.doesNotMatch(ledgerSource, /\[wms-home\]/, '台账页不在首页模块,不得复用 [wms-home] 前缀');
  311. // 分类图 tooltip(静态源码断言):名称/数量/占比 + 下钻提示,单位必须来自 props.unit。
  312. const chartSource = fs.readFileSync(path.join(folder, 'components/DashboardChart.vue'), 'utf8');
  313. assert.match(chartSource, /占比:/, '分类图 tooltip 必须显示占比');
  314. assert.match(chartSource, /点击进入台账/, '分类图 tooltip 必须提示可点击下钻');
  315. assert.match(chartSource, /this\.unit \? ' ' \+ this\.unit : ''/, '单位必须取自 props.unit,为空时不加单位后缀');
  316. console.log('D2 分类分布下钻断言通过:非「其他」行带 categoryLevelId、other 行不带、route query 传递、V2 台账读取与递归查找、分类图 tooltip 单位取 props.unit。');
  317. // 质检通过率卡片:阈值分级(98/95 边界逐位一致)、较上月 pp 上下标识、让步接收小字与空值语义。
  318. // 后端契约:qualityInspectedCount 可能为 0 而 qualityPassRate 为 null,此时必须显示「—」,绝不能渲染成 0%。
  319. const qualityKpi = (summary) => overview.computed.kpis.call(componentState(overview, { summary, unit: '%' })).find(item => item.key === 'quality');
  320. const qualityBase = { qualityPassRate: 98, qualityInspectedCount: 20, qualityPassedCount: 18, qualityConcessionCount: 2, qualityChange: 1.2 };
  321. assert.equal(qualityKpi({ ...qualityBase }).color, '#3BD48A', '98 必须归绿(>=98 达标,边界值不得滑到橙)');
  322. assert.equal(qualityKpi({ ...qualityBase }).status, '达标', '98 状态文案为「达标」');
  323. assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 97.9 }).color, '#F0A03A', '97.9 必须归橙(接近阈值)');
  324. assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 97.9 }).status, '接近阈值', '97.9 状态文案为「接近阈值」');
  325. assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 95 }).color, '#F0A03A', '95 必须归橙(>=95,边界值不得滑到红)');
  326. assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 95 }).status, '接近阈值', '95 状态文案为「接近阈值」');
  327. assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 94.9 }).color, '#F25E5E', '94.9 必须归红(低于目标)');
  328. assert.equal(qualityKpi({ ...qualityBase, qualityPassRate: 94.9 }).status, '低于目标', '94.9 状态文案为「低于目标」');
  329. // 无已检批次:通过率为 null 时中性色、状态留空、进度条不渲染,值交给 displayKpi 显示「—」。
  330. const qualityEmpty = qualityKpi({ qualityPassRate: null, qualityInspectedCount: 0, qualityPassedCount: 0, qualityConcessionCount: 0, qualityChange: null, qualityChangeReason: '上月无已检批次' });
  331. assert.equal(qualityEmpty.color, '#9FB8D4', '通过率为 null 必须中性色,不得显示成不达标');
  332. assert.equal(qualityEmpty.status, '', '通过率为 null 时状态文案留空,不得由前端补造');
  333. assert.equal(qualityEmpty.value, null, '通过率为 null 时值为 null,显示「—」而不是 0%');
  334. assert.equal(qualityEmpty.progress, null, '通过率为 null 时进度条不渲染,不能用 0 冒充');
  335. // 较上月 pp:上升为好(good 绿 ▲)、下降为坏(bad 红 ▼)、0 持平(dim)、null 只留占位符与原因。
  336. const qualityUp = qualityKpi({ ...qualityBase, qualityChange: 1.2 });
  337. assert.match(qualityUp.delta, /\+1\.20pp/, '较上月必须复用 formatPpChange 的 pp 文本,不得拼成 %');
  338. assert.match(qualityUp.delta, /▲/, '通过率上升必须有 ▲ 标识');
  339. assert.equal(qualityUp.deltaTone, 'good', '通过率上升是好转,必须与周转/呆滞同一套 good(绿)语义');
  340. const qualityDown = qualityKpi({ ...qualityBase, qualityPassRate: 96, qualityChange: -1.2 });
  341. assert.match(qualityDown.delta, /-1\.20pp/, '下降必须带负号,不能取绝对值');
  342. assert.match(qualityDown.delta, /▼/, '通过率下降必须有 ▼ 标识');
  343. assert.equal(qualityDown.deltaTone, 'bad', '通过率下降是恶化,必须走 bad(红)语义');
  344. const qualityFlat = qualityKpi({ ...qualityBase, qualityChange: 0 });
  345. assert.match(qualityFlat.delta, /持平/, '差值为 0 只显示持平');
  346. assert.doesNotMatch(qualityFlat.delta, /0\.00pp/, '持平不得拼成 +0.00pp');
  347. assert.equal(qualityFlat.deltaTone, 'dim', '持平不带健康度色调');
  348. assert.equal(qualityEmpty.delta, '—', '不可比时显示占位符,绝不显示 0 或「持平」');
  349. assert.equal(qualityEmpty.deltaTone, 'dim', '不可比时不带健康度色调');
  350. assert.match(qualityEmpty.deltaTitle, /上月无已检批次/, '不可比原因必须写进 title');
  351. // 后端字段未上线的过渡态:卡片能渲染、中性色、状态留空、原因缺失时 title 回退「较上月不可比」。
  352. const qualityLegacy = qualityKpi({});
  353. assert.equal(qualityLegacy.color, '#9FB8D4', '后端字段缺失时用中性色,不能让卡片崩掉');
  354. assert.equal(qualityLegacy.status, '', '后端字段缺失时状态留空');
  355. assert.equal(qualityLegacy.delta, '—', '后端字段缺失时较上月只给占位符');
  356. assert.match(qualityLegacy.deltaTitle, /较上月不可比/, '原因缺失时 title 回退「较上月不可比」');
  357. assert.equal(qualityLegacy.subtotal, null, '让步与已检都缺失时整行小字不渲染');
  358. // 让步接收批次单独成列:> 0 才出现,0 或缺失时不出现(不制造「让步接收 0 批」噪音)。
  359. assert.match(qualityKpi({ ...qualityBase, qualityConcessionCount: 2 }).subtotal.text, /让步接收 2 批 · 进分母不计合格/, '让步接收 >0 必须单列批次数');
  360. assert.match(qualityKpi({ ...qualityBase, qualityConcessionCount: 2 }).subtotal.text, /已检 20 批 \/ 合格 18 批/, '小字必须附分子分母便于核对');
  361. assert.doesNotMatch(qualityKpi({ ...qualityBase, qualityConcessionCount: 0 }).subtotal.text, /让步接收/, '让步接收为 0 时不得出现「让步接收 0 批」');
  362. assert.doesNotMatch(qualityKpi({ ...qualityBase, qualityConcessionCount: null }).subtotal.text, /让步接收/, '让步接收缺失时不得出现该段');
  363. assert.doesNotMatch(qualityKpi({ ...qualityBase }).subtotal.title, /undefined|NaN/, '小字 title 不得漏出 undefined/NaN');
  364. // 明细页 KPI 条:qualityChange 走 formatPpChange(自带符号与 pp),单位小字因此不再追加。
  365. const metricLabelsForTest = vm.runInContext('metricLabels', screens);
  366. assert.equal(metricLabelsForTest.qualityChange[0], '较上月', '明细页必须给 qualityChange 显示名');
  367. assert.equal(metricLabelsForTest.qualityChange[1], '', 'qualityChange 单位位留空(pp 已由 formatPpChange 拼进数值)');
  368. assert.equal(detail.methods.metricValue.call({ summary: { qualityChange: 1.2 } }, 'qualityChange'), '+1.20pp', '明细页较上月必须带符号与 pp');
  369. assert.equal(detail.methods.metricValue.call({ summary: { qualityChange: -1.2 } }, 'qualityChange'), '-1.20pp', '明细页下降必须带负号');
  370. assert.equal(detail.methods.metricValue.call({ summary: { qualityChange: 0 } }, 'qualityChange'), '持平', '明细页差值为 0 显示持平');
  371. assert.equal(detail.methods.metricValue.call({ summary: { qualityChange: null } }, 'qualityChange'), '—', '明细页不可比显示占位符');
  372. // V10 口径文案与后端口径逐条对齐:分母只含已检,未检/无需质检/来源不可追溯都不进分母,较上月单位 pp。
  373. assert.match(screenDefs.qc.definition, /检验完成日期落在本月/, '口径必须写明检验完成日期落在本月');
  374. assert.match(screenDefs.qc.definition, /让步接收进分母不计合格/, '口径必须写明让步接收进分母不计合格');
  375. assert.match(screenDefs.qc.definition, /无质检要求与来源不可追溯的批次不计入分母/, '口径必须写明未检与不可追溯批次不进分母');
  376. assert.match(screenDefs.qc.definition, /pp/, '较上月单位必须是 pp,不能写成 %');
  377. // 精度措辞必须与 formatPpChange 的固定两位小数一致,否则口径条承诺的位数与卡片实际显示打架。
  378. assert.match(screenDefs.qc.definition, /较上月保留两位小数,单位 pp/, '较上月精度措辞必须与 formatPpChange 的两位小数一致');
  379. assert.doesNotMatch(screenDefs.qc.definition, /至多一位/, '口径条不得再写「至多一位」,卡片实际固定两位小数');
  380. assert.equal(screenDefs.qc.metrics.indexOf('qualityChange'), screenDefs.qc.metrics.indexOf('qualityPassRate') + 1, 'qualityChange 必须紧跟 qualityPassRate');
  381. // qc 明细页时间窗由后端固定(queryHomeDetail 对 qc 强制 YearMonth.now(),传 startTime/endTime 无效),
  382. // 因此不得再渲染可编辑的开始/结束日期:改了不生效的筛选比没有筛选更糟。以下为源码级守卫。
  383. const detailSource = fs.readFileSync(path.join(folder, 'detail.vue'), 'utf8');
  384. assert.doesNotMatch(detailSource, /current\.period && view !== 'flow'/, 'qc 必须与其它 period 视图分开,日期筛选不得再对 qc 渲染');
  385. assert.match(detailSource, /v-if="current\.period && !\['flow', 'qc'\]\.includes\(view\)"/, '开始/结束日期条件必须显式排除 qc,其它 period 视图保持原样');
  386. assert.match(detailSource, /v-if="view === 'qc'"[\s\S]{0,200}检验完成日期/, 'qc 必须给出只读的时间窗口径提示(说明检验完成日期落在本月)');
  387. assert.match(detailSource, /本页时间窗由后端固定,不接受自定义区间/, '只读提示的 title 必须说明时间窗由后端固定、不接受自定义区间');
  388. assert.doesNotMatch(detailSource, /v-if="view === 'qc'"[^>]*><input/, 'qc 的统计区间不得渲染成可编辑输入框');
  389. console.log('质检明细页时间窗守卫通过:qc 不渲染可编辑日期、改为只读「本月(检验完成日期,1 号至今)」提示,其它 period 视图条件未变。');
  390. console.log('质检卡片断言通过:98/95 阈值边界、pp 环比上下标识与持平/不可比、让步接收小字仅在 >0 时出现、明细页 pp 展示与 V10 口径文案。');
  391. // ===== D1「出入库动态图」:近 30 天滚动窗口、缺数据 null、无作业日、连续同号风险、点击下钻 =====
  392. assert.equal(utilConst('TREND_WINDOW_DAYS'), 30, 'D1 窗口固定为近 30 天滚动(含今日)');
  393. assert.equal(utilConst('TREND_RISK_MIN_DAYS'), 2, '连续同号 >=2 天才提示风险');
  394. // 窗口:30 天、升序、含今日,跨月跨年都连续且不重复。
  395. const tradeWindow = utils.trendWindowDates(new Date(2026, 9, 30));
  396. assert.equal(tradeWindow.length, 30, 'D1 窗口必须恰好 30 天(含今日)');
  397. assert.equal(tradeWindow[0], '2026-10-01', '窗口第一天=今日往前 29 天');
  398. assert.equal(tradeWindow[29], '2026-10-30', '窗口最后一天必须是今日');
  399. assert.equal(JSON.stringify([...tradeWindow].sort()), JSON.stringify(tradeWindow), '窗口必须按日期升序');
  400. assert.equal(new Set(tradeWindow).size, 30, '窗口不得出现重复日期');
  401. const yearWindow = utils.trendWindowDates(new Date(2026, 0, 5));
  402. assert.equal(yearWindow[0], '2025-12-07', '跨年窗口必须正确回退到上一年');
  403. assert.equal(yearWindow[29], '2026-01-05', '跨年窗口最后一天=今日');
  404. // 缺数据必须补 null:后端只返回有作业的日期,缺日绝不能用 0 冒充。
  405. const gapWindow = utils.buildTrendWindow([{ date: '2026-10-30', inbound: 12, outbound: 5 }], new Date(2026, 9, 30));
  406. assert.equal(gapWindow.length, 30);
  407. assert.equal(gapWindow[29].inbound, 12);
  408. assert.equal(gapWindow[29].net, 7, '单日净差=入库−出库');
  409. assert.equal(gapWindow[28].inbound, null, '缺数据日必须补 null,不能用 0 冒充');
  410. assert.equal(gapWindow[28].outbound, null, '缺数据日必须补 null,不能用 0 冒充');
  411. assert.equal(gapWindow[28].net, null, '无作业日没有净差,必须是 null 而不是 0');
  412. assert.equal(gapWindow[28].idle, true, '两侧都无数据=无作业日');
  413. assert.equal(gapWindow[29].idle, false, '单侧有数据不算无作业日');
  414. // 无作业判定:两侧皆空=true;任一方向有数据(含真实的 0)=false。
  415. assert.equal(utils.isIdleTrendDay({}), true, '两侧都无数据=无作业日');
  416. assert.equal(utils.isIdleTrendDay({ inbound: null, outbound: null }), true);
  417. assert.equal(utils.isIdleTrendDay({ inbound: 0, outbound: null }), false, '入库 0 是真实数据,不能当无作业日');
  418. assert.equal(utils.isIdleTrendDay({ inbound: null, outbound: 8 }), false, '只有出库也算有作业');
  419. assert.equal(utils.isIdleTrendDay({ inbound: '3', outbound: '1' }), false, '字符串数字按有数据解析');
  420. // 净差方向固定「入库 − 出库」,能取负值。
  421. assert.equal(utils.trendNetOf(10, 4), 6);
  422. assert.equal(utils.trendNetOf(4, 10), -6, '净差必须能取负值,不能取绝对值');
  423. assert.equal(utils.trendNetOf(null, 6), -6, '只有出库时净差为负');
  424. assert.equal(utils.trendNetOf(null, null), null, '两侧都无数据时净差为 null');
  425. // 连续同号段:末尾无作业日跳过、0 与中间空日打断、符号翻转只算最近一段。
  426. const streakOf = (nets) => utils.trendStreak(nets.map((net, index) => ({ date: '2026-10-' + String(index + 1).padStart(2, '0'), net })));
  427. // vm 里的数组/对象来自另一个 realm,原型不同,deepEqual 会假失败,这里按 JSON 逐字比较。
  428. assert.equal(JSON.stringify(streakOf([1, 2, 3])), JSON.stringify({ direction: 1, days: 3, start: '2026-10-01', end: '2026-10-03' }), '连续为正必须累计 3 天并给出起止日');
  429. assert.equal(JSON.stringify(streakOf([-1, -2, -3])), JSON.stringify({ direction: -1, days: 3, start: '2026-10-01', end: '2026-10-03' }), '连续为负同样累计 3 天');
  430. assert.equal(streakOf([1, -2, -3]).days, 2, '符号翻转只算窗口末尾那一段');
  431. assert.equal(streakOf([1, -2, -3]).direction, -1);
  432. assert.equal(streakOf([5, 4, 0]).days, 0, '末尾持平的 0 不构成同号段');
  433. assert.equal(streakOf([5, 4, null]).days, 2, '末尾无作业日跳过,从最近一个有数据的日子起算');
  434. assert.equal(streakOf([5, null, 4]).days, 1, '中间的无作业日打断连续段,不能跨空日断言');
  435. assert.equal(streakOf([0, 0, 0]).days, 0, '连续持平不构成同号段');
  436. assert.equal(streakOf([null, null]).days, 0, '整个窗口无作业时没有连续段');
  437. assert.equal(streakOf([1]).days, 1, '单日同号只有 1 天');
  438. // 风险文案方向:连续为正=积压风险,连续为负=缺货风险,不足 2 天不提示。
  439. const riskUp = utils.trendRisk(streakOf([1, 2, 3]));
  440. assert.match(riskUp.text, /积压风险/, '连续为正必须提示积压风险');
  441. assert.doesNotMatch(riskUp.text, /缺货/, '积压风险不得写成缺货风险');
  442. assert.match(riskUp.text, /连续 3 天/, '风险文案必须写明连续天数');
  443. assert.equal(riskUp.tone, 'warn', '积压风险走橙色语义');
  444. assert.match(riskUp.title, /2026-10-01 ~ 2026-10-03/, '风险 title 必须给连续段起止日');
  445. const riskDown = utils.trendRisk(streakOf([-1, -2]));
  446. assert.match(riskDown.text, /缺货风险/, '连续为负必须提示缺货风险');
  447. assert.doesNotMatch(riskDown.text, /积压/, '缺货风险不得写成积压风险');
  448. assert.equal(riskDown.tone, 'danger', '缺货风险走红色语义');
  449. assert.equal(utils.trendRisk(streakOf([1])), null, '连续 1 天不提示风险');
  450. assert.equal(utils.trendRisk(streakOf([0, 0])), null, '连续持平不提示风险');
  451. assert.equal(utils.trendRisk(streakOf([null, null])), null, '无作业日不提示风险');
  452. // 组件层:趋势计算属性固定输出 30 行,连续 3 天净入库必须出积压风险提示,无流水时保留空态。
  453. const windowDates = utils.trendWindowDates();
  454. const trendFixture = [
  455. { date: windowDates[27], inbound: 10, outbound: 2 },
  456. { date: windowDates[28], inbound: 8, outbound: 1 },
  457. { date: windowDates[29], inbound: 5, outbound: 0 }
  458. ];
  459. const trendState = componentState(overview, { summary: { trend: trendFixture }, unit: '个' });
  460. const trendRows = trendState.trend;
  461. assert.equal(trendRows.length, 30, '趋势计算属性必须固定输出 30 行');
  462. assert.equal(trendRows[29].date, windowDates[29], '窗口最后一天必须是今日');
  463. assert.equal(trendRows[29].net, 5, '净差=当日入库 5 − 当日出库 0');
  464. assert.equal(trendRows[0].inbound, null, '缺数据日必须是 null 而不是 0');
  465. assert.equal(trendRows[0].idle, true, '缺数据日必须标记为无作业日');
  466. assert.match(trendState.trendRiskHint.text, /积压风险/, '连续 3 天净入库必须提示积压风险');
  467. assert.equal(trendState.trendRiskHint.tone, 'warn');
  468. assert.equal(overview.computed.trend.call(componentState(overview, { summary: {} })).length, 0, '没有任何流水时保留空态,不能硬凑 30 行空柱');
  469. assert.match(overviewSource, /近 30 天滚动窗口/, '副标必须明确写「近 30 天滚动窗口」');
  470. assert.match(overviewSource, /trendWindow\.start[\s\S]{0,40}trendWindow\.end/, '副标必须给出窗口起止日期');
  471. // 点击柱体(含无作业日)→ 打开 V4 出入库动态并带入该日整日区间。
  472. const drill = { $emit(event, target, payload) { this.event = event; this.target = target; this.payload = payload; } };
  473. overview.methods.trendSelect.call(drill, trendRows[0]);
  474. assert.equal(drill.target, 'io', '点击柱体必须打开 V4 出入库动态');
  475. assert.equal(drill.payload.startTime, trendRows[0].date + ' 00:00:00', '必须带入该日 00:00:00');
  476. assert.equal(drill.payload.endTime, trendRows[0].date + ' 23:59:59', '必须带入该日 23:59:59');
  477. assert.equal(trendRows[0].idle, true, '无作业日同样必须能按该日下钻(列表为空态)');
  478. // 图表 option:净趋势紫色虚线 + 右轴单日净差、无作业日 markArea 底纹、tooltip 明写「该日无作业」。
  479. let chartOption = null;
  480. let trendClickHandler = null;
  481. let pixelIndex = 0;
  482. const chartStub = {
  483. setOption(option) { chartOption = option; },
  484. on() {},
  485. getZr() { return { on(name, handler) { if (name === 'click') trendClickHandler = handler; } }; },
  486. containPixel() { return true; },
  487. convertFromPixel() { return [pixelIndex, 1]; },
  488. resize() {},
  489. dispose() {}
  490. };
  491. const { value: chart } = load('components/DashboardChart.vue', {
  492. echarts: { init: () => chartStub, graphic: { LinearGradient: function LinearGradient() {} } },
  493. isIdleTrendDay: utils.isIdleTrendDay,
  494. trendQuantity: utils.trendQuantity,
  495. agingColorOf: utils.agingColorOf,
  496. formatAgingRatio: utils.formatAgingRatio,
  497. isAgingUnknown: utils.isAgingUnknown
  498. });
  499. // 真实实例上 methods 混入 this;这里同样摊进去,render 里才能调用 this.trendOption。
  500. const chartState = { ...chart.methods, chart: chartStub, kind: 'trend', unit: '个', rows: trendRows, events: [], $emit(event, payload) { this.events.push([event, payload]); } };
  501. chart.methods.render.call(chartState);
  502. assert.ok(chartOption, 'render 必须把 option 交给 echarts');
  503. assert.equal(Array.isArray(chartOption.yAxis), true, 'D1 必须是双 Y 轴(数量 + 单日净差)');
  504. assert.equal(chartOption.yAxis.length, 2, 'D1 必须恰好两个 Y 轴');
  505. assert.match(String(chartOption.yAxis[0].name), /数量/, '左轴必须是数量轴');
  506. assert.match(String(chartOption.yAxis[1].name), /单日净差/, '右轴必须明确命名为单日净差');
  507. const netSeries = chartOption.series.filter((series) => series.name === '净趋势')[0];
  508. assert.equal(netSeries.yAxisIndex, 1, '净趋势必须绑右轴(净差刻度不能与数量刻度共用)');
  509. assert.equal(netSeries.lineStyle.type, 'dashed', '净趋势必须是虚线');
  510. assert.equal(netSeries.lineStyle.color, '#9B7DF7', '净趋势必须是紫色 #9B7DF7');
  511. assert.equal(netSeries.data[0], null, '无作业日的净趋势必须是 null(断线),不能画成 0');
  512. const barSeries = chartOption.series.filter((series) => series.name === '入库量')[0];
  513. assert.equal(barSeries.data[0], null, '无作业日不画柱:入库必须是 null 而不是 0');
  514. assert.equal(barSeries.markArea.data.length, 27, '前 27 天无作业必须各有一块 markArea 底纹');
  515. assert.match(String(barSeries.markArea.itemStyle.color), /rgba\(159,184,212/, '无作业日底纹必须是浅灰');
  516. const idleTip = chartOption.tooltip.formatter([{ dataIndex: 0 }]);
  517. assert.match(idleTip, /该日无作业/, '无作业日 hover 必须提示「该日无作业」');
  518. // 空日系列值全是 null,ECharts 可能只给 axisValue 不给 dataIndex,这里必须仍能认出无作业日。
  519. assert.match(chartOption.tooltip.formatter([{ axisValue: trendRows[0].date }]), /该日无作业/, '缺 dataIndex 时也必须靠日期认出无作业日');
  520. assert.doesNotMatch(idleTip, /入库量/, '无作业日不显示入库/出库行,避免读成 0');
  521. const workTip = chartOption.tooltip.formatter([{ dataIndex: 29 }]);
  522. assert.match(workTip, /入库量:5/, '有作业日必须给出入库量');
  523. assert.match(workTip, /单日净差:\+5/, '有作业日必须给出带符号的单日净差');
  524. // 无作业占位系列(防回归):轴触发 tooltip 会丢掉该 dataIndex 上取值为 null 的系列
  525. // (echarts axisPointer/axisTrigger.js 的 buildPayloadsBySeries 对 null/NaN 直接 return),
  526. // 空日若没有任何非 null 数据点,formatter 只会收到空数组,「该日无作业」永远出不来。
  527. const placeholderSeries = chartOption.series.filter((series) => series.name === '无作业占位')[0];
  528. assert.ok(placeholderSeries, '必须存在无作业占位系列,否则空日没有可命中的 dataIndex,tooltip 不会出现');
  529. assert.equal(placeholderSeries.type, 'bar', '占位系列用柱体:数量轴基线本就是 0,占位 0 不改变刻度');
  530. assert.equal(placeholderSeries.yAxisIndex, 0, '占位系列必须绑左轴(数量轴),不得污染右侧净差轴');
  531. assert.equal(placeholderSeries.silent, true, '占位系列必须 silent,不能拦截 zr 点击下钻');
  532. assert.equal(placeholderSeries.itemStyle.opacity, 0, '占位系列必须完全透明,不能画出多余的柱体');
  533. trendRows.forEach((row, index) => {
  534. const hitCount = chartOption.series.filter((series) => series.data[index] != null).length;
  535. if (row.idle) assert.ok(hitCount > 0, '无作业日在 dataIndex ' + index + ' 没有任何非 null 数据点:空日没有可命中的 dataIndex,tooltip 不会出现');
  536. assert.equal(placeholderSeries.data[index], row.idle ? 0 : null, '占位值必须只出现在无作业日(第 ' + index + ' 天:' + row.date + '),正常日给 0 会让 tooltip 多出一行');
  537. if (!row.idle) assert.equal(chartOption.series.filter((series) => series.name !== '无作业占位').some((series) => series.data[index] != null), true, '有作业日必须由真实系列提供数据点');
  538. });
  539. // 占位 0 绝不能出现在提示里:空日仍只返回「该日无作业」一行。
  540. const placeholderTip = chartOption.tooltip.formatter([{ dataIndex: 0, seriesName: '无作业占位', value: 0 }]);
  541. assert.match(placeholderTip, /该日无作业/, '占位系列命中时也必须提示「该日无作业」');
  542. assert.doesNotMatch(placeholderTip, /入库量|出库量|单日净差/, '占位 0 不得被渲染成入库/出库/净差行');
  543. assert.doesNotMatch(placeholderTip, /:0(?:[^0-9]|$)/, '占位值 0 不得出现在 tooltip 文本里');
  544. // 点击:zr 点击 → 类别下标 → $emit,无作业日没有柱体也能命中。
  545. chart.methods.bindTrendClick.call(chartState);
  546. assert.equal(typeof trendClickHandler, 'function', '趋势图必须注册 zr 点击,无作业日没有柱体也能下钻');
  547. trendClickHandler({ offsetX: 10, offsetY: 10 });
  548. assert.equal(chartState.events.length, 1);
  549. assert.equal(chartState.events[0][0], 'select');
  550. assert.equal(chartState.events[0][1].idle, true, 'zr 点击必须选中该无作业日');
  551. pixelIndex = 29;
  552. trendClickHandler({ offsetX: 10, offsetY: 10 });
  553. assert.equal(chartState.events[1][1].date, windowDates[29], 'zr 点击必须命中被点的那个日期');
  554. console.log('D1 出入库动态断言通过:近 30 天滚动窗口(含今日、升序、跨年)、缺数据补 null 非 0、无作业判定(两侧皆空)、连续同号段(0 与空日打断)、积压/缺货风险方向、双 Y 轴 + #9B7DF7 虚线净趋势 + markArea 底纹 + 「该日无作业」tooltip、柱体与无作业日点击带日期跳 V4。');
  555. // 日志:窗口构建与下钻都必须留 [wms-home] 痕迹,且只记关键字段与耗时、不打印整包响应。
  556. // homeLog 绑定在 dashboard-utils 的上下文里,所以用带 recording console 的上下文重载一次再断言。
  557. const logs = [];
  558. const { context: utilsLogged } = load('dashboard-utils.js', { console: { log: (...args) => logs.push(args), warn: (...args) => logs.push(args) } });
  559. const { value: overviewLogged } = load('components/HomeOverview.vue', { DashboardChart: {}, ...utilsLogged, STAGNANT_DEFAULT_DAYS });
  560. const loggedState = componentState(overviewLogged, { summary: { trend: trendFixture }, unit: '个' });
  561. void loggedState.trend;
  562. assert.equal(logs.length, 1, '构建 30 天窗口必须打一条 [wms-home] 日志');
  563. assert.equal(logs[0][0], '[wms-home]', '日志前缀必须是 [wms-home]');
  564. assert.match(logs[0][1], /D1 出入库动态窗口/, '日志必须标明是 D1 窗口构建');
  565. assert.equal(logs[0][2].windowDays, 30, '日志必须记录窗口天数');
  566. assert.equal(logs[0][2].sourceRows, 3, '日志必须记录后端来源行数');
  567. assert.equal(logs[0][2].idleDays, 27, '日志必须记录无作业天数');
  568. assert.equal(typeof logs[0][2].costMs, 'number', '日志必须记录构建耗时');
  569. void loggedState.trend;
  570. assert.equal(logs.length, 1, '窗口未变化时不得重复刷日志');
  571. overviewLogged.methods.trendSelect.call({ $emit() {} }, trendRows[0]);
  572. assert.equal(logs.length, 2, '下钻必须留日志');
  573. assert.match(logs[1][1], /D1 出入库动态下钻/);
  574. assert.equal(logs[1][2].date, trendRows[0].date, '下钻日志必须记录日期');
  575. assert.equal(logs[1][2].idle, true, '下钻日志必须记录是否无作业日');
  576. // ===== E1「库龄结构」:批次库龄分段、四段占比、平均库龄较上月、>180 联动呆滞 =====
  577. // 分段 key / label / ratio 全部由后端下发,前端只分拣、配色与格式化:
  578. // 一旦这里开始复算占比或重算库龄,图上百分比就会与后端 summary 分叉。
  579. assert.equal(utilConst('AGING_STAGNANT_SEGMENT_DAYS'), 180, '>180 段的库龄边界必须与呆滞默认阈值同界(180 天)');
  580. // ratio 为 null=后端判定「总量非正不可算」,必须给占位符;渲染成 0% 会被读成「这段真的没有份额」。
  581. assert.equal(utils.formatAgingRatio(42.1), '42.1%', '占比必须原样格式化后端 ratio');
  582. assert.equal(utils.formatAgingRatio('12'), '12%');
  583. assert.equal(utils.formatAgingRatio(null), '—', 'ratio 为 null 必须给占位符,不能用 0% 冒充');
  584. assert.equal(utils.formatAgingRatio(''), '—');
  585. // 精度必须与后端一致(后端 ratio 是两位小数):前端若截到 1 位,四段各自四舍五入后
  586. // 页面上可见的合计会变成 99.9%/100.1%(33.33/33.33/33.34 → 33.3/33.3/33.3=99.9),
  587. // 与「四段占比合计 100%」的口径自相矛盾。
  588. assert.equal(utils.formatAgingRatio(33.34), '33.34%', '占比必须保留后端两位小数,否则四段可见合计不为 100%');
  589. assert.equal(utils.formatAgingRatio(33.33), '33.33%');
  590. assert.equal(utils.formatAgingRatio(0.01), '0.01%', '最小占比不得被截断成 0%');
  591. // 下面 33.33/33.33/33.34 这一组是「后端做最大余数配平后的期望形态」,不是前端自己配平出来的数:
  592. // 后端保证 unknown=0 时四段合计恰为 100.00,前端只负责逐段原样显示这两位小数。
  593. const thirdSplit = [33.33, 33.33, 33.34].map((value) => utils.formatAgingRatio(value).replace('%', ''));
  594. assert.equal(thirdSplit.join('|'), '33.33|33.33|33.34', '后端配平后的两位小数必须原样展示,不得二次取整');
  595. assert.equal(Math.round(thirdSplit.reduce((sum, value) => sum + Number(value), 0) * 100) / 100, 100, '后端配平后的四段可见合计必须正好 100,不能因前端截断变成 99.9');
  596. // 走页面同一条路径(buildAgingSegments → ratioText)再验一次:占比条逐段保留两位小数,真实 0 仍显示 0%。
  597. const preciseBuilt = utils.buildAgingSegments([
  598. { key: '0-30', label: '0-30天', quantity: 1, ratio: 33.33 },
  599. { key: '31-90', label: '31-90天', quantity: 1, ratio: 33.33 },
  600. { key: '91-180', label: '91-180天', quantity: 1, ratio: 33.34 },
  601. { key: '180+', label: '>180天', quantity: 0, ratio: 0 }
  602. ]);
  603. assert.equal(preciseBuilt.segments.map((item) => item.ratioText).join('|'), '33.33%|33.33%|33.34%|0%', '占比条必须逐段保留后端两位小数(真实 0 仍显示 0%)');
  604. assert.equal(preciseBuilt.ratioSum, 100, '后端配平后四段合计必须正好 100');
  605. // 前端不参与配平、也不抹平差异:后端若给出 99.99,页面与日志都照实反映 99.99(差异留给后端修),
  606. // 一旦这里被改成 100,说明有人把「配平」搬进了前端,占比复算这条红线就被破了。
  607. const unbalancedBuilt = utils.buildAgingSegments([
  608. { key: '0-30', label: '0-30天', quantity: 1, ratio: 33.33 },
  609. { key: '31-90', label: '31-90天', quantity: 1, ratio: 33.33 },
  610. { key: '91-180', label: '91-180天', quantity: 1, ratio: 33.33 },
  611. { key: '180+', label: '>180天', quantity: 0, ratio: 0 }
  612. ]);
  613. assert.equal(unbalancedBuilt.ratioSum, 99.99, '前端不得把后端给的合计配平成 100');
  614. assert.equal(unbalancedBuilt.segments.map((item) => item.ratioText).join('|'), '33.33%|33.33%|33.33%|0%', '前端逐段原样展示后端 ratio,不做配平');
  615. assert.equal(utils.formatAverageAgeDays(52.5), '52.5 天', '平均库龄保留后端给出的一位小数');
  616. assert.equal(utils.formatAverageAgeDays(null), '—', '平均库龄不可算时必须给占位符,不能显示 0 天');
  617. // 段边界:180 天属于 91-180 段,只有 >180 才是呆滞候选;日期待核实(null/空)不染红也不给处置入口。
  618. assert.equal(utils.isStagnantAge(180), false, '180 天属于 91-180 段,不得提前染红');
  619. assert.equal(utils.isStagnantAge(181), true, '>180 天才是与呆滞联动的红色段');
  620. assert.equal(utils.isStagnantAge(null), false, '日期待核实不参与红色强调');
  621. assert.equal(utils.isStagnantAge(''), false);
  622. // 分拣:后端行序被打乱也要按固定 key 顺序出图例;unknown 单独一行且不参与四段占比。
  623. // 刻意让数量份额与后端 ratio 不一致(180+ 数量 12/105≈11.4% 但 ratio=10),任何前端复算都会露馅。
  624. const agingFixture = [
  625. { key: '180+', label: '>180天', quantity: 12, ratio: 10 },
  626. { key: '0-30', label: '0-30天', quantity: 42, ratio: 42.1 },
  627. { key: 'unknown', label: '日期待核实', quantity: 5, ratio: 4.8 },
  628. { key: '91-180', label: '91-180天', quantity: 20, ratio: 22.9 },
  629. { key: '31-90', label: '31-90天', quantity: 26, ratio: 25 }
  630. ];
  631. const agingBuilt = utils.buildAgingSegments(agingFixture);
  632. assert.equal(agingBuilt.segments.map((item) => item.key).join(','), '0-30,31-90,91-180,180+', '四段必须按固定 key 顺序排列,不随后端返回顺序漂移');
  633. assert.equal(agingBuilt.segments[3].color, '#F25E5E', '>180 段必须固定红色(与呆滞 alert 同色阶)');
  634. assert.equal(agingBuilt.segments[3].stagnant, true, '>180 段必须标记为呆滞联动段');
  635. assert.equal(agingBuilt.segments[3].label, '>180天', 'label 必须用后端原文');
  636. assert.equal(agingBuilt.unknown.color, '#7E93AD', 'unknown 段必须是中性灰,不属于四段色阶');
  637. assert.equal(agingBuilt.unknown.ratioText, '不参与四段占比', 'unknown 必须明说不参与四段占比');
  638. assert.equal(agingBuilt.ratioSum, 100, '四段占比合计必须是后端给的 100%');
  639. assert.equal(agingBuilt.hasUnknown, true);
  640. assert.equal(agingBuilt.rowCount, 5, '库龄结构固定 5 行(四段 + 日期待核实)');
  641. assert.equal(utils.buildAgingSegments([{ key: 'expired', quantity: 1 }]).unexpectedKeys.join(','), 'expired', '未识别分段必须被记录,不能静默当成一段');
  642. const utilsSource = fs.readFileSync(path.join(folder, 'dashboard-utils.js'), 'utf8');
  643. assert.doesNotMatch(utilsSource, /quantity\s*\/\s*total/, '库龄占比不得在前端用数量÷总量复算');
  644. // 首页卡片:四段占比逐项展示后端 ratio;>180 段红显;平均库龄与较上月角标按天数差着色。
  645. const agingState = componentState(overview, { summary: { aging: agingFixture, averageAgeDays: 52.5, averageAgeChange: -4 }, unit: '个' });
  646. assert.equal(agingState.agingRatio.length, 4, '占比条只列四段');
  647. assert.equal(agingState.agingRatio.map((item) => item.ratioText).join('|'), '42.1%|25%|22.9%|10%', '占比必须原样展示后端 ratio,不得按数量复算(数量份额与 ratio 刻意不一致)');
  648. assert.equal(agingState.agingRatio.filter((item) => item.stagnant).length, 1, '四段里只有 >180 段带红色强调');
  649. assert.equal(agingState.agingUnknown.ratioText, '不参与四段占比', 'unknown 不混进四段占比');
  650. assert.equal(agingState.averageAge.text, '52.5 天', '副标必须展示后端平均库龄');
  651. assert.equal(agingState.averageAgeChange.text, '较上月 -4 天', '较上月必须给天数差,负数带负号');
  652. assert.equal(agingState.averageAgeChange.tone, 'good', '平均库龄下降=库存变“新”,必须走绿色(向好)');
  653. const agingUp = componentState(overview, { summary: { aging: agingFixture, averageAgeDays: 60, averageAgeChange: 3.5 } });
  654. assert.match(agingUp.averageAgeChange.text, /较上月 \+3\.5 天/, '上升必须带正号');
  655. assert.equal(agingUp.averageAgeChange.tone, 'bad', '平均库龄上升=变“老”,必须走红色');
  656. const agingFlat = componentState(overview, { summary: { aging: agingFixture, averageAgeDays: 60, averageAgeChange: 0 } });
  657. assert.equal(agingFlat.averageAgeChange.text, '较上月持平', '差值为 0 只显示持平,不显示 0 天');
  658. assert.equal(agingFlat.averageAgeChange.tone, 'dim', '持平不带健康度色调');
  659. // 无基准:不渲染角标数值,只给后端原因;平均库龄不可算时同样给占位符,绝不显示 0 天。
  660. const agingNoBase = componentState(overview, { summary: { aging: agingFixture, averageAgeDays: null, averageAgeChange: null, averageAgeChangeReason: '上月无日末快照' } });
  661. assert.equal(agingNoBase.averageAgeChange.text, '', '无基准时不得渲染角标数值');
  662. assert.match(agingNoBase.averageAgeChange.title, /上月无日末快照/, '不可比时必须原样展示后端原因');
  663. assert.equal(agingNoBase.averageAge.text, '—', '平均库龄不可算显示占位符');
  664. assert.doesNotMatch(JSON.stringify(agingNoBase.averageAgeChange), /0 天/, '无基准绝不显示成 0 天');
  665. assert.doesNotMatch(agingNoBase.averageAge.title, /undefined/, '原因缺失时不得漏出 undefined');
  666. // 日志:库龄结构解析只打一条汇总(五行数量、四段占比合计、平均库龄、是否含 unknown),数值不变不重复刷。
  667. logs.length = 0;
  668. const agingLogState = componentState(overviewLogged, { summary: { aging: agingFixture, averageAgeDays: 52.5, averageAgeChange: -4 }, unit: '个' });
  669. void agingLogState.agingRatio;
  670. assert.equal(logs.length, 1, '库龄结构解析必须只打一条汇总日志');
  671. assert.equal(logs[0][0], '[wms-home]', '日志前缀必须是 [wms-home]');
  672. assert.match(logs[0][1], /E1 库龄结构/, '日志必须标明是 E1 库龄结构');
  673. assert.equal(logs[0][2].rows, 5, '汇总日志必须记录五行数量');
  674. assert.equal(logs[0][2].ratioSum, 100, '汇总日志必须记录四段占比合计');
  675. assert.equal(logs[0][2].averageAgeDays, 52.5, '汇总日志必须记录平均库龄');
  676. assert.equal(logs[0][2].hasUnknown, true, '汇总日志必须记录是否含 unknown');
  677. assert.equal('segments' in logs[0][2], false, '汇总日志不得逐行打印分段明细');
  678. void agingLogState.agingRatio;
  679. void agingLogState.agingUnknown;
  680. assert.equal(logs.length, 1, '数值未变时不得重复刷库龄日志');
  681. // 图例文案必须来自后端 label(含「>180天」),占比与下钻 key 一并带出。
  682. logs.length = 0;
  683. const ageDrill = { $emit(event, target2, payload) { this.event = event; this.target2 = target2; this.payload = payload; } };
  684. overviewLogged.methods.ageSelect.call(ageDrill, agingFixture[0]);
  685. assert.equal(ageDrill.target2, 'age', '点击分段必须进 V6 批次库龄明细');
  686. assert.equal(ageDrill.payload.age, '180+', '下钻必须带后端分段 key');
  687. assert.equal(logs.length, 1, '下钻必须留一条 [wms-home] 日志');
  688. assert.match(logs[0][1], /E1 库龄结构下钻/, '下钻日志必须标明是 E1 库龄结构');
  689. assert.equal(logs[0][2].key, '180+');
  690. // 库龄环形图:分段配色(>180 红、unknown 灰)、后端 label、unknown 不给占比数字、点击带 key。
  691. let agingOption = null;
  692. let agingClick = null;
  693. const agingStub = {
  694. setOption(option) { agingOption = option; },
  695. on(name, handler) { if (name === 'click') agingClick = handler; },
  696. getZr() { return { on() {} }; },
  697. resize() {},
  698. dispose() {}
  699. };
  700. const { value: agingChart } = load('components/DashboardChart.vue', {
  701. echarts: { init: () => agingStub, graphic: { LinearGradient: function LinearGradient() {} } },
  702. isIdleTrendDay: utils.isIdleTrendDay,
  703. trendQuantity: utils.trendQuantity,
  704. agingColorOf: utils.agingColorOf,
  705. formatAgingRatio: utils.formatAgingRatio,
  706. isAgingUnknown: utils.isAgingUnknown
  707. });
  708. const agingChartState = { ...agingChart.methods, chart: agingStub, kind: 'aging', unit: '个', rows: agingFixture, events: [], $emit(event, payload) { this.events.push([event, payload]); } };
  709. agingChart.methods.render.call(agingChartState);
  710. assert.ok(agingOption, '库龄图 render 必须把 option 交给 echarts');
  711. assert.equal(agingOption.series[0].type, 'pie', '库龄结构必须是环形图');
  712. // 配色按分段 key 断言(扇形顺序=后端行序,用于保持 dataIndex 与下钻 row 一一对应,故不能按下标硬编颜色)。
  713. const colorByLabel = {};
  714. agingOption.series[0].data.forEach((item) => { colorByLabel[item.name] = item.itemStyle.color; });
  715. assert.equal(colorByLabel['>180天'], '#F25E5E', '>180 段必须红色(与呆滞 alert 同色阶)');
  716. assert.equal(colorByLabel['91-180天'], '#F0A03A', '91-180 段用顺序色');
  717. assert.equal(colorByLabel['31-90天'], '#2E8BE8', '31-90 段用顺序色');
  718. assert.equal(colorByLabel['0-30天'], '#38C8F5', '0-30 段用顺序色');
  719. assert.equal(colorByLabel['日期待核实'], '#7E93AD', 'unknown 段中性灰,不进四段色阶');
  720. assert.equal(new Set(['0-30天', '31-90天', '91-180天', '>180天'].map((label) => colorByLabel[label])).size, 4, '四段颜色必须互不相同(顺序色阶)');
  721. assert.equal(agingOption.legend.data.join('|'), '>180天|0-30天|日期待核实|91-180天|31-90天', '图例必须用后端 label(含「>180天」)');
  722. const agingTipHot = agingOption.tooltip.formatter({ dataIndex: 0 });
  723. assert.match(agingTipHot, /占比:10%/, '>180 段占比必须原样取后端 ratio');
  724. assert.doesNotMatch(agingTipHot, /11\.4/, '占比不得按数量复算成 11.4%');
  725. assert.match(agingTipHot, /点击查看该段明细/);
  726. const agingTipUnknown = agingOption.tooltip.formatter({ dataIndex: 2 });
  727. assert.match(agingTipUnknown, /不参与 0-30 \/ 31-90 \/ 91-180 \/ >180 四段占比/, 'unknown 段 tooltip 必须说明不参与四段占比');
  728. assert.doesNotMatch(agingTipUnknown, /占比:\d/, 'unknown 段 tooltip 不得给出任何占比数字');
  729. agingChart.methods.bindItemClick.call(agingChartState);
  730. agingClick({ dataIndex: 0 });
  731. assert.equal(agingChartState.events.length, 1, '点击分段必须发一次 select');
  732. assert.equal(agingChartState.events[0][0], 'select');
  733. assert.equal(agingChartState.events[0][1].key, '180+', '点击 >180 段必须带 key=180+ 下钻');
  734. agingClick({ dataIndex: 2 });
  735. assert.equal(agingChartState.events[1][1].key, 'unknown', '点击日期待核实段同样要能下钻');
  736. // V6 明细:首次入库日期区分「按上线日起算」与「日期待核实」,最后入库日期与负责人照常展示,空值一律「—」。
  737. const detailCell = (row, key) => detail.methods.cell.call({}, row, key);
  738. assert.equal(detailCell({ firstInboundTime: null, ageSource: 'warehouse' }, 'firstInboundTime'), '按上线日起算', '期初数据必须显示「按上线日起算」,不能混成「日期待核实」');
  739. assert.equal(detailCell({ firstInboundTime: null, ageSource: null }, 'firstInboundTime'), '日期待核实', '真实缺失才写「日期待核实」');
  740. assert.equal(detailCell({ firstInboundTime: '2026-01-05 10:00:00', ageSource: 'batch' }, 'firstInboundTime'), '2026-01-05 10:00:00', '有真实首次入库日期时原样展示');
  741. assert.equal(detailCell({ lastInboundTime: null }, 'lastInboundTime'), '—', '最后入库日期缺失必须给占位符,不能用 0 或当天顶替');
  742. assert.equal(detailCell({ lastInboundTime: '2026-09-01 08:00:00' }, 'lastInboundTime'), '2026-09-01 08:00:00');
  743. assert.equal(detailCell({ ownerName: '' }, 'ownerName'), '—', '负责人为空必须给占位符');
  744. assert.equal(detailCell({ ownerName: '张三' }, 'ownerName'), '张三');
  745. // >180 行:红色强调与「建呆滞处置单」入口判定;180 天本身属于 91-180 段,不进红线。
  746. assert.equal(detail.methods.agingStagnant.call({}, { ageDays: 200 }), true, '>180 天行必须触发红色与处置入口');
  747. assert.equal(detail.methods.agingStagnant.call({}, { ageDays: 180 }), false, '180 天属于 91-180 段,不得给呆滞处置入口');
  748. assert.equal(detail.methods.agingStagnant.call({}, {}), false, '日期待核实不染红也不给入口');
  749. // 单元格提示:最后入库日期必须写明「补库不刷新库龄」。
  750. assert.match(detail.methods.cellTitle.call({}, { lastInboundTime: '2026-09-01' }, 'lastInboundTime'), /补库不刷新库龄/);
  751. assert.match(detail.methods.cellTitle.call({}, { firstInboundTime: null, ageSource: 'warehouse' }, 'firstInboundTime'), /上线日起算/);
  752. // 未来日期的首次入库不参与起算(后端仍给首入库日期 + ageSource=warehouse):列里照实显示日期,提示必须指明按上线日算。
  753. // ageSource='warehouse' 且首次入库日期有值,后端有两种来源:来源不可追溯(provenance_status<>1)、
  754. // 首次入库日期晚于今天。title 必须同时覆盖两者,只写「晚于今天」会把不可追溯误说成日期在未来。
  755. const futureTitle = detail.methods.cellTitle.call({}, { firstInboundTime: '2099-01-01 00:00:00', ageSource: 'warehouse' }, 'firstInboundTime');
  756. assert.match(futureTitle, /来源不可追溯或晚于今天/, '提示必须同时覆盖「来源不可追溯」与「晚于今天」两种 ageSource=warehouse 来源');
  757. assert.match(futureTitle, /上线日(库龄起算日)起算/, '提示必须说明库龄按仓库配置的库龄起算日起算');
  758. const untraceableTitle = detail.methods.cellTitle.call({}, { firstInboundTime: '2020-01-01 08:00:00', ageSource: 'warehouse' }, 'firstInboundTime');
  759. assert.equal(untraceableTitle, futureTitle, '过去日期但来源不可追溯时,title 必须与未来日期同一条口径,不得退回「按首次入库日期起算」');
  760. assert.match(detail.methods.cellTitle.call({}, { firstInboundTime: '2026-01-05 10:00:00', ageSource: 'batch' }, 'firstInboundTime'), /调拨、拆合包不重置库龄/, '真实首次入库日期必须说明库龄起算口径');
  761. assert.equal(detail.methods.cellTitle.call({}, { categoryName: 'X' }, 'categoryName'), '', '非库龄列不制造空的悬浮提示');
  762. // 处置入口复用 dead 视图既有动作:business('dead', row) 打开同一套 disposal 弹层,整行原样传下去。
  763. const disposeRow = { categoryId: '1072414', warehouseId: '9', batchNo: 'B1', systemBatchNo: 'S1', ageDays: 200 };
  764. const disposeEmitter = { $emit(event, target2, payload) { this.event = event; this.target2 = target2; this.payload = payload; } };
  765. const { value: detailLogged } = load('detail.vue', { ...utilsLogged, screens: screenDefs });
  766. logs.length = 0;
  767. detailLogged.methods.disposeAge.call(disposeEmitter, disposeRow);
  768. assert.equal(disposeEmitter.target2, 'dead', '建呆滞处置单必须复用 dead 视图的既有处置动作,不新造页面');
  769. assert.equal(disposeEmitter.payload, disposeRow, '必须把整行原样传给处置弹层,物料/仓库/批次参数不能丢');
  770. assert.equal(logs.length, 1, '处置入口必须留一条 [wms-home] 日志');
  771. assert.equal(logs[0][0], '[wms-home]');
  772. assert.match(logs[0][1], /E1 库龄明细建呆滞处置单/);
  773. // V6 列与口径条:补「最后入库」「负责人」,口径写明起算规则与 >180 联动。
  774. const ageColumns = screenDefs.age.columns.map((column) => column.key);
  775. assert.ok(ageColumns.indexOf('lastInboundTime') !== -1, 'V6 明细必须含「最后入库」列');
  776. assert.ok(ageColumns.indexOf('ownerName') !== -1, 'V6 明细必须含「负责人」列');
  777. assert.ok(ageColumns.indexOf('firstInboundTime') !== -1 && ageColumns.indexOf('firstInboundTime') < ageColumns.indexOf('lastInboundTime'), '首次入库必须排在最后入库之前');
  778. assert.ok(ageColumns.indexOf('lastInboundTime') < ageColumns.indexOf('lastActivityTime'), '最后入库必须排在「最近实际消耗」之前,避免两个日期混淆');
  779. assert.match(screenDefs.age.definition, /库龄=当前日期−首次入库日期/, '口径必须写明库龄起算规则');
  780. assert.match(screenDefs.age.definition, /上线日起算/, '口径必须写明期初按上线日起算');
  781. assert.match(screenDefs.age.definition, /补库不刷新库龄/, '口径必须写明最后入库日期不刷新库龄');
  782. assert.match(screenDefs.age.definition, /四段占比合计 100%/, '口径必须写明四段占比合计 100%');
  783. assert.match(screenDefs.age.definition, />180 天与呆滞口径联动/, '口径必须写明 >180 与呆滞联动');
  784. assert.match(screenDefs.age.definition, /负责人取仓库负责人/, '口径必须写明负责人取仓库负责人');
  785. // 源码级守卫:红显类名、处置入口、库龄筛选文案(含分段边界)都必须落在模板里。
  786. assert.match(detailSource, /aging-overdue/, '>180 天行的库龄单元格必须有红色类名');
  787. assert.match(detailSource, /建呆滞处置单/, 'V6 明细必须给「建呆滞处置单」入口');
  788. assert.match(detailSource, /按上线日起算/, '明细单元格必须能展示「按上线日起算」');
  789. assert.match(detailSource, /0-30 天(≤30 天)/, '库龄筛选文案必须与后端分段一致并写明边界');
  790. assert.match(detailSource, /&gt;180 天(与呆滞口径同界)/, '>180 段文案必须显式写明与呆滞同界');
  791. assert.doesNotMatch(detailSource, /\[wms-home\]/, '日志前缀只在 dashboard-utils 里定义,组件内不得硬写');
  792. assert.match(overviewSource, /buildAgingSegments/, '首页库龄结构必须走后端字段分拣,不得前端自算分段');
  793. assert.match(overviewSource, /averageAgeChangeReason/, '较上月不可比时必须能展示后端原因');
  794. assert.match(chartSource, /agingColorOf\(row\)/, '库龄分段颜色必须由 agingColorOf 统一给出(四段顺序色 + >180 红 + unknown 灰)');
  795. assert.match(chartSource, /不参与 0-30 \/ 31-90 \/ 91-180 \/ >180 四段占比/, '库龄图 tooltip 必须说明 unknown 不参与四段占比');
  796. // 模板编译守卫:本次改动全部落在模板里(占比条、角标、红显、处置入口、明细列),
  797. // 模板语法错误只会在浏览器里表现为空屏/错位,静态字符串断言抓不到,这里直接让 vue-template-compiler 编译一遍。
  798. for (const file of ['components/HomeOverview.vue', 'components/DashboardChart.vue', 'detail.vue']) {
  799. const sfc = compiler.parseComponent(fs.readFileSync(path.join(folder, file), 'utf8'));
  800. const compiled = compiler.compile(sfc.template.content);
  801. assert.equal((compiled.errors || []).length, 0, file + ' 模板必须编译通过:' + (compiled.errors || []).join(';'));
  802. }
  803. console.log('E1 库龄结构断言通过:四段占比只展示后端 ratio(合计 100%)、>180 段固定红(与呆滞同色阶)、unknown 中性灰且不参与占比、平均库龄较上月负数绿/正数红/无基准只给原因、平均库龄与 ratio 空值不渲染成 0、V6 明细补最后入库与负责人、按上线日起算与日期待核实可区分、>180 行红色且可建呆滞处置单、日志前缀与去重。');
  804. // ===== F1「安全库存预警」断言块 =====
  805. // 口径来源:docs/仓储首页看板口径.md 的 A1 节 + WmsHomeMapper.xml 的 alertRows。
  806. // 这里只读既有实现(纯函数 + 模板源码 + 后端 SQL 静态文本),不发任何真实请求。
  807. const {
  808. alertStatusLabel, alertColorOf, alertGapAmountText, alertGapText,
  809. formatAlertAmount, alertGapAmountReason, buildAlertRows, buildAlertSummary
  810. } = utilsLogged;
  811. const ALERT_TOP_LIMIT = utilConst('ALERT_TOP_LIMIT');
  812. // TOP 上限是卡片与「全部 N 项」的分界线:改成 6 会让首页多出一行、与设计稿的 5 行节奏错位。
  813. assert.equal(ALERT_TOP_LIMIT, 5, '首页预警卡 TOP 上限必须是 5(缺口金额倒序前 5 条)');
  814. // 三色兜底色只允许这四种:normal 行按契约不给 color,造一个颜色出来等于凭空表达健康度。
  815. assert.equal(utilConst('ALERT_FALLBACK_COLORS').critical, '#F25E5E', '缺料兜底色必须是红');
  816. assert.equal(utilConst('ALERT_FALLBACK_COLORS').warning, '#F0A03A', '偏低兜底色必须是橙');
  817. assert.equal(utilConst('ALERT_FALLBACK_COLORS').excess, '#9B7DF7', '超储兜底色必须是紫');
  818. assert.equal(utilConst('ALERT_FALLBACK_COLORS').unknown, '#7E93AD', '待核实兜底色必须是灰');
  819. console.log('F1-(0) 颜色与标签兜底表就位');
  820. // —— (1) 纯函数:标签 / 配色 / 缺口文案 / TOP 截断 / 汇总条 ——
  821. // statusLabel 优先:文案由后端下发,前端拿自己的映射覆盖会把后端改的措辞吃掉。
  822. assert.equal(alertStatusLabel({ status: 'critical', statusLabel: '缺料(后端文案)' }), '缺料(后端文案)', 'statusLabel 由后端下发时必须优先');
  823. assert.equal(alertStatusLabel({ status: 'critical' }), '缺料', 'statusLabel 缺失时按 status 兜底为「缺料」');
  824. assert.equal(alertStatusLabel({ status: 'warning' }), '偏低', 'statusLabel 缺失时 warning 兜底为「偏低」');
  825. assert.equal(alertStatusLabel({ status: 'excess' }), '超储', 'statusLabel 缺失时 excess 兜底为「超储」');
  826. assert.equal(alertStatusLabel({ status: '未知值' }), '待核实', '未识别状态一律按「待核实」降级,绝不猜成已知状态');
  827. // color 优先:颜色跟着后端判定走,改系数后颜色自动跟着变;只在后端没给时按状态兜底(并 warn 一次)。
  828. assert.equal(alertColorOf({ status: 'warning', color: '#123456' }), '#123456', '后端下发的 color 必须优先');
  829. assert.equal(alertColorOf({ status: 'critical' }), '#F25E5E', '缺色时 critical 按状态兜底为红');
  830. assert.equal(alertColorOf({ status: 'excess' }), '#9B7DF7', '缺色时 excess 按状态兜底为紫');
  831. assert.equal(alertColorOf({ status: 'unknown' }), '#7E93AD', '缺色时 unknown 按状态兜底为灰');
  832. assert.equal(alertColorOf({ status: 'normal' }), null, 'normal 行不表达健康度,不得造一个兜底色出来');
  833. // 缺口金额:null 是「缺价、算不出来」,绝不是 0;0 是真实零,要显示两位小数。
  834. assert.equal(alertGapAmountText({ gapAmount: null }), '金额待补价', '缺价行必须显示「金额待补价」,用 0 或空串冒充都会误导补货排序');
  835. assert.equal(alertGapAmountText({ gapAmount: 0 }), '0.00 元', '缺口金额为真实 0 时必须显示 0.00 元,不能走缺价分支');
  836. assert.equal(formatAlertAmount(null), '—', '金额缺失在底层格式化层给占位符');
  837. assert.equal(formatAlertAmount(0), '0.00', '真实 0 在底层格式化层照实显示两位小数');
  838. assert.match(alertGapAmountReason({}), /移动加权不含税单价不可用/, '缺价原因缺失时必须给事实说明,不得编造单价来源');
  839. assert.match(alertGapText({ gapQuantity: 3, unit: '个', gapAmount: null }), /金额待补价/, '行文案缺价时必须含「金额待补价」');
  840. assert.equal(alertGapText({ gapQuantity: 3, unit: '个', gapAmount: null }), '缺口 3 个 / 金额待补价', '缺口文案=缺口量+单位+金额,缺价走待补价');
  841. assert.equal(alertGapText({ gapQuantity: null }), '缺口 — / 金额待补价', '缺口量缺失显示「—」,不按 0 计');
  842. // TOP5 截断:7 行进、5 行出,并给出 overflow 供「全部 N 项」说明;顺序完全沿用后端,前端不重排。
  843. const alertRowsInput = Array.from({ length: 7 }, (_, index) => ({
  844. id: String(index + 1), name: '物料' + (index + 1), unit: '个', status: 'critical', statusLabel: '缺料', color: '#F25E5E',
  845. gapQuantity: index + 1, gapAmount: 1000 - index * 100, purchaseRequestCode: null, workOrderCount: index
  846. }));
  847. const alertTop = buildAlertRows(alertRowsInput, ALERT_TOP_LIMIT);
  848. assert.equal(alertTop.rows.length, 5, '首页预警卡只展示前 5 条');
  849. assert.equal(alertTop.total, 7, 'total 必须是后端原始条数,不受截断影响');
  850. assert.equal(alertTop.overflow, 2, '超出 TOP 的条数必须给出 overflow');
  851. assert.equal(alertTop.rows[0].id, '1', '前端不得重排:顺序必须与后端 gapAmount DESC 一致');
  852. assert.equal(alertTop.statusCounts.critical, 7, '状态分拣计数按后端 status 统计,供核对卡片与清单是否同源');
  853. assert.equal(buildAlertRows(null).rows.length, 0, '后端未下发 alerts 时必须空态返回,不能崩');
  854. assert.equal(buildAlertRows([]).overflow, 0, '空列表不得产生 overflow');
  855. // 前端绝不复算阈值:状态是后端短路判定的结果,available<secure 但后端给 normal 时也照 normal 展示。
  856. const alertTampered = buildAlertRows([{ id: 'x', name: '物料X', status: 'normal', statusLabel: '正常', availableQuantity: 1, secureInventory: 100, gapQuantity: 0, gapAmount: 0 }], ALERT_TOP_LIMIT);
  857. assert.equal(alertTampered.rows[0].status, 'normal', '前端不得按 available/secure 复算状态(复算会在改系数后与后端静默分叉)');
  858. assert.equal(alertTampered.rows[0].statusLabel, '正常', '前端不得改写后端下发的状态标签');
  859. // 汇总条全 null:一律「—」,且不得用当前可见行数冒充 alertCount(前端自己加总与清单页条数立刻分叉)。
  860. const alertEmptySummary = buildAlertSummary({ alerts: [{ id: '1' }, { id: '2' }, { id: '3' }], alertCount: null, alertUnknownCount: null, alertGapAmount: null, alertWorkOrderCount: null });
  861. assert.equal(alertEmptySummary.countText, '—', 'alertCount 缺失必须显示「—」');
  862. assert.equal(alertEmptySummary.unknownText, '—', 'alertUnknownCount 缺失必须显示「—」');
  863. assert.equal(alertEmptySummary.gapAmountText, '—', 'alertGapAmount 缺失必须显示「—」');
  864. assert.equal(alertEmptySummary.workOrderText, '—', 'alertWorkOrderCount 缺失必须显示「—」,绝不按 0');
  865. assert.notEqual(alertEmptySummary.countText, '3', '汇总条不得用可见行数冒充 alertCount');
  866. const alertFilledSummary = buildAlertSummary({ alertCount: 12, alertUnknownCount: 2, alertGapAmount: 1234.5, alertWorkOrderCount: 3 });
  867. assert.equal(alertFilledSummary.countText, '12', 'alertCount 有值时原样展示(缺料+偏低+超储,待核实不计入)');
  868. assert.equal(alertFilledSummary.gapAmountText, '1,234.50 元', '缺口金额合计固定两位小数 + 千分位');
  869. assert.equal(alertFilledSummary.workOrderText, '3', '影响生产工单数由后端给出,前端只展示');
  870. console.log('F1-(1) 纯函数断言通过:statusLabel/color 优先级、normal 不造色、缺价「金额待补价」而 0→0.00、TOP5 截断与 overflow、状态不复算、汇总条全 null→「—」且不自算合计');
  871. // —— (2) detail.vue 的 alertActionOf 五分支(经 computed rows 预计算后读取)——
  872. const alertActionFor = (row) => detailLogged.computed.rows.call({ view: 'alerts', result: { records: [row] } })[0].alertAction;
  873. assert.equal(alertActionFor({ status: 'unknown' }).primary, 'threshold', '待核实行只给「核对阈值」');
  874. assert.equal(alertActionFor({ status: 'unknown' }).primaryLabel, '核对阈值');
  875. assert.notEqual(alertActionFor({ status: 'unknown' }).primary, 'purchase', '待核实行不得给补货入口:阈值不成立,补货只会被后端 hasReplenishmentAlert 拒绝');
  876. assert.equal(alertActionFor({ status: 'excess' }).primary, 'transfer', '超储行的闭环是调拨,不是继续采购');
  877. assert.equal(alertActionFor({ status: 'excess' }).primaryLabel, '发起调拨');
  878. assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).primary, 'purchase-document', '已生成申请的行只给「打开采购需求」');
  879. assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).code, 'CG-2026-0001', '单号必须透传用于行尾标记');
  880. assert.notEqual(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).primary, 'purchase', '有单号时绝不出现「生成采购申请」,否则就是重复下单入口');
  881. assert.equal(alertActionFor({ status: 'critical', purchaseRequestCode: 'CG-2026-0001' }).document, false, '主按钮已是「打开采购需求」时不得再渲染一次');
  882. assert.equal(alertActionFor({ status: 'critical' }).primary, 'purchase', '缺料行无单号时给「生成采购申请」');
  883. assert.equal(alertActionFor({ status: 'warning' }).primary, 'purchase', '偏低行无单号时同样给「生成采购申请」');
  884. assert.equal(alertActionFor({ status: 'normal' }).primary, '', '正常行不给任何动作,绝不凭空造一个点了就失败的按钮');
  885. assert.equal(alertActionFor({ status: 'excess', purchaseRequestCode: 'CG-2' }).document, true, '超储行若已有申请单号,额外给「打开采购需求」');
  886. assert.equal(alertActionFor({}).primary, '', '状态缺失时按兜底处理,不给动作');
  887. console.log('F1-(2) alertActionOf 五分支通过:unknown→核对阈值(不给补货)、excess→发起调拨、有单号→打开采购需求(绝不出补货)、critical/warning→生成采购申请、normal→无动作');
  888. // —— (3) 首页卡片模板守卫:三色点、状态标签、影响工单、已生成申请、金额待补价 ——
  889. assert.match(overviewSource, /:style="\{ background: item\.color, color: item\.color \}"/, '三色状态点必须取 item.color(后端 color,缺失时 alertColorOf 按 status 兜底)');
  890. assert.match(overviewSource, /class="alert-state" :style="\{ color: item\.color \}"[\s\S]{0,40}\{\{ item\.statusLabel \}\}/, '状态中文标签必须与状态点同排且用后端 statusLabel');
  891. assert.match(overviewSource, /影响生产工单/, '汇总条必须出现「影响生产工单」');
  892. assert.match(overviewSource, /已生成申请/, '卡片必须给出「已生成申请」标记,避免同一行被重复下单');
  893. assert.match(overviewSource, /金额待补价/, '卡片必须写明缺价行显示「金额待补价」,不得渲染成 0');
  894. assert.match(overviewSource, /item\.gapText/, '行内必须展示缺口量/缺口金额文案');
  895. assert.match(overviewSource, /alertSummary\.workOrderText/, '汇总条工单数必须消费后端 workOrderText(null 时是「—」)');
  896. assert.match(overviewSource, /level: 'critical'/, '「影响生产工单」下钻必须按 level=critical,与工单数同源');
  897. assert.match(detailSource, /含待检、未放行批次/, '清单页口径条必须写明可用量含待检/未放行批次');
  898. console.log('F1-(3) 卡片模板守卫通过:三色 fallback、状态标签同排、影响生产工单、已生成申请、金额待补价、工单下钻 level=critical');
  899. // —— (4) screens.alerts 口径文案守卫:新口径关键词必须在,旧口径不得复现 ——
  900. const alertDefinition = screenDefs.alerts.definition;
  901. assert.match(alertDefinition, /含待检/, '预警口径条必须写明可用量含待检批次');
  902. assert.match(alertDefinition, /偏低系数/, '预警口径条必须写明 K1 偏低系数');
  903. assert.match(alertDefinition, /超储兜底/, '预警口径条必须写明 K2 超储兜底系数');
  904. assert.doesNotMatch(alertDefinition, /排除未放行/, '「排除未放行」是 batchRows 的口径,不得再出现在预警口径条里');
  905. assert.match(alertDefinition, /最低库存仅作展示列、不参与触发判定/, '口径条必须写明最低库存不参与触发判定');
  906. assert.match(alertDefinition, /最大库存(允许为空/, '口径条必须写明最大库存可为空');
  907. assert.match(alertDefinition, /缺料[\s\S]{0,40}偏低[\s\S]{0,40}超储/, '口径条必须写明短路顺序 缺料→偏低→超储');
  908. assert.match(alertDefinition, /待核实单列且不计入/, '口径条必须写明待核实单列且不计入预警条数');
  909. console.log('F1-(4) screens.alerts 文案守卫通过:含待检、偏低系数、超储兜底、最低库存不参与判定、最大库存可空、待核实单列,且不再出现「排除未放行」');
  910. // —— (5) 后端 SQL 静态守卫(只读文本;后端仓库不在同级目录时 SKIP 而非 fail)——
  911. const alertMapperPath = path.resolve(root, '..', 'aiot202312/aiot-biz/wms-biz/wms-biz-provider/src/main/resources/mapper/WmsHomeMapper.xml');
  912. if (!fs.existsSync(alertMapperPath)) {
  913. console.log('SKIP F1-(5) 后端 SQL 静态守卫:未找到 ' + alertMapperPath + '(后端仓库不在当前工作区同级目录,跳过不 fail)');
  914. } else {
  915. const alertMapperXml = fs.readFileSync(alertMapperPath, 'utf8');
  916. const sqlFragment = (id) => {
  917. const start = alertMapperXml.indexOf('<sql id="' + id + '">');
  918. if (start < 0) return null;
  919. const end = alertMapperXml.indexOf('</sql>', start);
  920. return end < 0 ? null : alertMapperXml.slice(start, end);
  921. };
  922. const alertFragment = sqlFragment('alertRows');
  923. const batchFragment = sqlFragment('batchRows');
  924. assert.ok(alertFragment, 'alertRows 片段必须存在(预警列表与首页 TOP5 共用)');
  925. assert.ok(batchFragment, 'batchRows 片段必须存在');
  926. // critical 判定行:只能由 secureInventory 判,不得引用 minInventory(最低库存只作展示列)。
  927. const alertCriticalLines = alertFragment.split('\n').filter((line) => /THEN 'critical'/.test(line));
  928. assert.equal(alertCriticalLines.length, 1, 'critical 判定行必须唯一(短路判定里出现两处说明有人改坏了 CASE)');
  929. assert.doesNotMatch(alertCriticalLines[0], /minInventory/, 'critical 判定不得引用 minInventory:最低库存只作展示列、不参与触发');
  930. assert.match(alertCriticalLines[0], /secureInventory/, 'critical 必须由 secureInventory 判定(可用量<安全库存)');
  931. // K1/K2 必须由 Mapper 参数下发,不能写死 1.2 / 3——写死就没法按环境调。
  932. assert.match(alertFragment, /#\{warningRatio\}/, 'warning 判定必须用参数 #{warningRatio}(wms.home.alert.warning-ratio,默认 1.2)');
  933. assert.match(alertFragment, /#\{excessRatio\}/, '超储兜底必须用参数 #{excessRatio}(wms.home.alert.excess-ratio,默认 3)');
  934. assert.match(alertFragment, /COALESCE\(cfg\.maxInventory,cfg\.secureInventory\*#\{excessRatio\}\)/, '超储兜底只应在 maxInventory 为空时生效');
  935. // 排序:缺口金额倒序 + 缺价(NULL)排最后 + 状态严重度 + id 稳定收尾。
  936. assert.match(alertMapperXml, /ORDER BY gapAmount IS NULL,gapAmount DESC/, '预警排序必须 gapAmount IS NULL,gapAmount DESC(缺价行排最后,不依赖 DESC 的隐式 NULL 规则)');
  937. assert.match(alertMapperXml, /FIELD\(status,'critical','warning','excess','normal','unknown'\)/, '预警次级排序必须按状态严重度 critical→warning→excess→normal→unknown');
  938. // 护栏①:batchRows 的 availableQuantity 定义不得改(四个既有 KPI 同时消费它)。
  939. assert.match(batchFragment, /available_count_base-COALESCE\(b\.lock_count_base,0\)/, 'batchRows 的 availableQuantity 必须仍是 available_count_base−锁定量(护栏:改它会静默改掉四个既有消费者的数字)');
  940. assert.equal((batchFragment.match(/AS availableQuantity/g) || []).length, 1, 'batchRows 只能有一个 availableQuantity 定义');
  941. // 护栏②:预警可用量必须走独立 av 子查询(Σ在库量−Σ锁定量,含待检),不得复用 batchRows 的 available_count_base 口径。
  942. assert.match(alertFragment, /SUM\(b\.stock_count_base\)-SUM\(COALESCE\(b\.lock_count_base,0\)\)/, '预警可用量必须是独立 av 子查询的 Σ(stock_count_base)−Σ(lock_count_base)(含待检)');
  943. assert.doesNotMatch(alertFragment, /available_count_base/, '预警可用量不得复用 batchRows 的 available_count_base(那会排除未放行批次,与阈值判定口径冲突)');
  944. // 缺口量 / 缺口金额 / 工单数 / 申请单号的 SQL 语义(缺价为 NULL,不兜底成 0)。
  945. assert.match(alertFragment, /GREATEST\(b0\.secureInventory-b0\.availableQuantity,0\)/, '缺口量必须是 GREATEST(安全库存−可用量,0)');
  946. assert.match(alertFragment, /ar\.gapQuantity\*ar\.costUnitPrice/, '缺口金额必须是 缺口量×移动加权不含税单价');
  947. assert.match(alertFragment, /THEN '缺口物料缺少移动加权不含税单价,缺口金额不可折算'/, '缺价时必须给 gapAmountReason,不得用 0 冒充');
  948. assert.match(alertFragment, /CASE WHEN ar\.status='critical' THEN wo\.workOrderCount END AS workOrderCount/, '行级 workOrderCount 只给 critical 行,非缺料行必须是 NULL 而不是 0');
  949. assert.match(alertFragment, /command_type/, '申请单号必须关联 t_wms_dashboard_command(command_type=PURCHASE)');
  950. assert.match(alertFragment, /CAST\(pc\.business_id AS CHAR\) AS purchaseRequestId/, '申请主键必须 CAST AS CHAR,禁止让大整数进 JS 数字');
  951. // 阈值落库:max_inventory 允许留空 → jdbcType=DECIMAL 无条件写,留空写 NULL。
  952. assert.match(alertMapperXml, /max_inventory=#\{q\.maxInventory,jdbcType=DECIMAL\}/, '阈值 insert 必须显式 jdbcType=DECIMAL(max_inventory 允许留空写 NULL)');
  953. assert.match(alertMapperXml, /max_inventory=#\{q\.maxInventory,jdbcType=DECIMAL\},update_user_id/, '阈值 update 必须显式 jdbcType=DECIMAL(留空写 NULL,不能要求必填)');
  954. 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');
  955. }
  956. // ===== E3「最近作业动态」断言块 =====
  957. // 口径来源:docs/仓储首页看板口径.md 的 E3 节 + WmsHomeMapper.xml 的 activities 分支。
  958. // 只读既有实现:装载 dashboard-utils.js 调真实纯函数(一律传固定 now,否则断言会跟着跑脚本的当天时钟漂),
  959. // 模板/样式只做源码字符串断言。全程不发请求、不写任何文件。
  960. const {
  961. formatActivityTime, activityStatusOf, buildActivityFeed, diffActivityIds
  962. } = utilsLogged;
  963. // const 常量落在 vm 词法作用域、不会成为上下文属性,必须用 utilConst 取(同 STAGNANT_DEFAULT_DAYS / ALERT_TOP_LIMIT)。
  964. // 卡片固定 5 条是视觉契约:改成 6 会让卡片多出一行,与设计稿的 5 行节奏错位。
  965. assert.equal(utilConst('ACTIVITY_LIMIT'), 5, '最近作业动态卡片必须固定展示 5 条');
  966. // 超时口径必须与待办条(overdueInbound/overdueOutbound/overdueApproval)同源 24 小时,
  967. // 否则同一屏上「待入库单超时」与「入库流水超时」会用两个阈值各说一套。
  968. assert.equal(utilConst('ACTIVITY_OVERDUE_HOURS'), 24, '待* 超时阈值必须与待办条同口径 24 小时');
  969. // 固定 now=本地时间 2026-10-20 15:30,四段文案与超时判定全部由它推出,脚本哪天跑结果都一样。
  970. const activityNow = new Date(2026, 9, 20, 15, 30, 0);
  971. // —— (1) 状态词 → tone 色表:文案与颜色解耦的唯一来源 ——
  972. const activityStyles = utilConst('ACTIVITY_STATUS_STYLES');
  973. const activityToneOf = (word) => (activityStyles[word] || {}).tone;
  974. const activityStyleTones = Object.keys(activityStyles).map((word) => activityToneOf(word));
  975. const ACTIVITY_TONES = ['done', 'waiting', 'doing', 'danger'];
  976. // 表里每个词都必须有合法 tone:漏一个会让模板拼出 'feed-status undefined',那一行状态直接无色。
  977. assert.equal(activityStyleTones.filter((tone) => ACTIVITY_TONES.indexOf(tone) === -1).length, 0, '状态色表里每个状态词都必须映射到 done/waiting/doing/danger 之一(否则模板会拼出无色状态)');
  978. assert.equal(ACTIVITY_TONES.filter((tone) => activityStyleTones.indexOf(tone) === -1).length, 0, '四种 tone 必须都被至少一个状态词使用,否则说明色表被删词');
  979. assert.equal(activityToneOf('已完成'), 'done', '已完成=闭环,必须绿色 done');
  980. assert.equal(activityToneOf('已执行'), 'done', '已执行=闭环(调拨),必须绿色 done');
  981. assert.equal(activityToneOf('待质检'), 'waiting', '待质检=在等质检处理,必须橙色 waiting');
  982. assert.equal(activityToneOf('待审批'), 'waiting', '待审批=在等审批,必须橙色 waiting');
  983. assert.equal(activityToneOf('待执行'), 'waiting', '待执行=还没开始,必须橙色 waiting(超时角标只跟 waiting 走)');
  984. assert.equal(activityToneOf('进行中'), 'doing', '进行中=正在跑,必须青色 doing');
  985. assert.equal(activityToneOf('已驳回'), 'danger', '已驳回=出问题,必须红色 danger');
  986. assert.equal(activityToneOf('异常'), 'danger', '异常=出问题,必须红色 danger');
  987. // —— (2) formatActivityTime 四段:今日 HH:mm / 昨日 / MM-DD / — ——
  988. // 今日必须补前导 0(09:05 不能写 9:5):这是「今日 HH:mm」与后端时间串的唯一视觉差别。
  989. assert.equal(formatActivityTime('2026-10-20 09:05:00', activityNow), '今日 09:05', '今日事件必须显示「今日 HH:mm」,小时与分钟都补前导 0');
  990. assert.equal(formatActivityTime('2026-10-20 23:59:00', activityNow), '今日 23:59', '当天 23:59 仍算今日,不得提前算成昨日');
  991. assert.equal(formatActivityTime('2026-10-19 23:59:00', activityNow), '昨日', '昨日事件只显示「昨日」,不显示时刻');
  992. assert.equal(formatActivityTime('2026-10-18 10:00:00', activityNow), '10-18', '更早事件显示 MM-DD');
  993. assert.equal(formatActivityTime('2026-09-05 08:00:00', activityNow), '09-05', '更早事件的月/日也要补前导 0(09-05 不能写 9-5)');
  994. assert.equal(formatActivityTime('', activityNow), '—', '空时间必须给占位符「—」,不能显示成今日');
  995. assert.equal(formatActivityTime(null, activityNow), '—', 'null 时间必须给占位符「—」');
  996. assert.equal(formatActivityTime('不是时间', activityNow), '—', '非法时间串必须给占位符,绝不拿「现在」兜底——那会把一条陈年流水顶到列表最前面');
  997. // —— (3) activityStatusOf:先按 activityType 分流,再谈 status ——
  998. const activityStatus = (row) => { const status = activityStatusOf(row); return status.text + '/' + status.tone; };
  999. assert.equal(activityStatus({ activityType: 'inventory', status: 0 }), '待执行/waiting', '盘点 status 0 必须「待执行」橙');
  1000. assert.equal(activityStatus({ activityType: 'inventory', status: 1 }), '进行中/doing', '盘点 status 1 必须「进行中」青');
  1001. assert.equal(activityStatus({ activityType: 'inventory', status: 2 }), '已完成/done', '盘点 status 2 必须「已完成」绿');
  1002. // 本卡最容易被改坏的一条:io 行的 status 恒为 null,而 Number(null) === 0。
  1003. // 一旦把 status 判断提到类型分流之前,审核通过的入库单会整片显示成「待执行」并挂上超时角标(假待办)。
  1004. assert.equal(activityStatus({ activityType: 'io', status: null }), '已完成/done', 'io 的 status 为 null 时必须「已完成」,绝不能因 Number(null)===0 退化成「待执行」');
  1005. assert.equal(activityStatus({ activityType: 'io' }), '已完成/done', 'io 完全没有 status 字段时同样「已完成」');
  1006. assert.equal(activityStatus({ activityType: 'io', status: 0 }), '已完成/done', 'io 即使带着 0 也仍是「已完成」(后端只把审核通过的流水放进 activities)');
  1007. assert.equal(activityStatus({ activityType: 'transfer', status: null }), '已执行/done', '调拨恒为「已执行」,status 为 null 时不得退化成「待执行」');
  1008. assert.equal(activityStatus({ activityType: 'transfer' }), '已执行/done', '调拨缺 status 时同样「已执行」');
  1009. // 未识别一律中性灰:造 done 会被读成业务结论,造 waiting 会凭空生成一个待办并跟着挂超时角标。
  1010. assert.equal(activityStatus({ activityType: '质检单' }), '状态待核实/neutral', '未识别 activityType 必须回落 neutral,不得猜成 done/waiting');
  1011. assert.equal(activityStatus({}), '状态待核实/neutral', '完全没有类型的行必须回落 neutral');
  1012. assert.equal(activityStatus({ activityType: 'inventory', status: 9 }), '状态待核实/neutral', '未识别盘点 status 必须回落 neutral,绝不猜成某个已知状态');
  1013. assert.equal(activityStatus({ activityType: 'inventory', status: null }), '状态待核实/neutral', '盘点缺 status 时回落 neutral,不得默认成「待执行」');
  1014. // —— (4) buildActivityFeed:去重(同单号留最新)→ operateTime 倒序 → 截断 5 条 ——
  1015. // fixture:8 条入参、含 1 条重复(O6 两条),期望 total=8 / deduped=1 / shown=5 / overflow=2。
  1016. // 重复的两条刻意把「旧的一条排在前面」,用来证明去重认的是 operateTime 而不是先到者。
  1017. const activityFeedRows = [
  1018. { id: 'A1', orderId: 'O1', orderNo: 'RK-001', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-20 14:00:00', itemCount: 3, warehouseName: '一号库' },
  1019. { id: 'A2', orderId: 'O2', orderNo: 'CK-002', activityType: 'io', direction: 2, status: null, operateTime: '2026-10-20 12:00:00', itemCount: 12, warehouseName: '二号库' },
  1020. { id: 'A3', orderId: 'O3', orderNo: 'DB-003', activityType: 'transfer', status: null, operateTime: '2026-10-20 10:00:00', itemCount: 2, warehouseName: '三号库' },
  1021. { id: 'A4', orderId: 'O4', orderNo: 'PD-004', activityType: 'inventory', operateTime: '2026-10-19 18:00:00', itemCount: 1, warehouseName: '四号库', status: 1, description: '执行中' },
  1022. { id: 'A5', orderId: 'O5', orderNo: 'PD-005', activityType: 'inventory', operateTime: '2026-10-19 09:00:00', itemCount: 1, warehouseName: '五号库', status: 0 },
  1023. { id: 'A6', orderId: 'O6', orderNo: 'RK-006', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-18 09:00:00', itemCount: 4, warehouseName: '六号库' },
  1024. { id: 'A7', orderId: 'O6', orderNo: 'RK-006', activityType: 'io', direction: 1, status: null, operateTime: '2026-10-20 08:00:00', itemCount: 6, warehouseName: '六号库' },
  1025. { id: 'A8', orderId: 'O7', orderNo: 'PD-007', activityType: 'inventory', operateTime: '2026-10-17 09:00:00', itemCount: 1, warehouseName: '七号库', status: 2 }
  1026. ];
  1027. const activityFeed = buildActivityFeed(activityFeedRows, activityNow);
  1028. const activityById = (feed, id) => feed.items.filter((item) => item.id === id)[0] || {};
  1029. assert.equal(activityFeed.total, 8, 'total 必须是入参条数 8(含重复),用于核对后端到底回了几行');
  1030. assert.equal(activityFeed.deduped, 1, 'deduped 必须记 1 条被去掉的重复单据');
  1031. assert.equal(activityFeed.items.length, 5, '卡片只展示 5 条,多一条就会顶掉设计稿的 5 行节奏');
  1032. assert.equal(activityFeed.overflow, 2, 'overflow=去重后 7 条 − 展示 5 条 = 2,供「全部动态 ›」提示余量');
  1033. assert.equal(activityFeed.items.map((item) => item.id).join('|'), 'A1|A2|A3|A7|A4', '必须按 operateTime 倒序截断 5 条:A6 是同单号的旧记录,去重后只留较新的 A7');
  1034. assert.equal(activityFeed.items.filter((item) => item.orderId === 'O6').length, 1, '同一 orderId 在卡片上只能出现 1 次,否则同一张单会被看两遍');
  1035. // 盘点动作文案:以「盘点」开头 + 带库区 + **绝不出现「项」**。
  1036. // 后端盘点行的 itemCount 是占位常量 1(WmsHomeMapper.xml 盘点分支 CAST 成 1),拼成「盘点 1 项」就是伪造明细数。
  1037. const activityInventory = activityById(activityFeed, 'A4');
  1038. assert.match(activityInventory.actionText, /^盘点/, '盘点行动作文案必须以「盘点」开头(后端 description 里的状态词不得当动作描述)');
  1039. assert.doesNotMatch(activityInventory.actionText, /项/, '盘点行不得渲染 itemCount:后端盘点 itemCount 恒为占位常量 1,「盘点 1 项」是伪造数字');
  1040. assert.match(activityInventory.actionText, /四号库/, '盘点行必须带库区,否则同一时刻的多条盘点无从区分');
  1041. // io/transfer 反而不许丢掉真实明细行数:itemCount 是后端 COUNT(*) 真明细行数,只写「入库」会让人点进去才发现数量。
  1042. assert.match(activityById(activityFeed, 'A1').actionText, /\d+ 项/, '入库行动作文案必须带明细项数(后端 COUNT(*) 真明细行数)');
  1043. assert.match(activityById(activityFeed, 'A1').actionText, /3 项/, '入库行必须显示后端下发的 3 项,不能拿别处的常量顶替');
  1044. assert.match(activityById(activityFeed, 'A1').actionText, /一号库/, '入库行必须带库区');
  1045. assert.match(activityById(activityFeed, 'A3').actionText, /2 项/, '调拨行必须显示后端下发的 2 项');
  1046. assert.match(activityById(activityFeed, 'A3').actionText, /三号库/, '调拨行必须带库区');
  1047. // 下钻参数留在原始字段上:$emit('business','io',item) 依赖 activityType/orderId/orderNo/direction/warehouseId,
  1048. // 展示字段一律用 timeText/actionText/statusText/tone 这类新名字,任何一个同名覆盖都等于悄悄丢掉下钻参数。
  1049. const activityInbound = activityById(activityFeed, 'A1');
  1050. assert.equal(activityInbound.id, 'A1', '原始 id 必须保留(v-for :key 与高亮 indexOf 都用它)');
  1051. assert.equal(activityInbound.orderId, 'O1', '原始 orderId 必须保留(去重键与下钻都靠它)');
  1052. assert.equal(activityInbound.orderNo, 'RK-001', '原始 orderNo 必须保留(单号段直接渲染它)');
  1053. assert.equal(activityInbound.activityType, 'io', '原始 activityType 必须保留,否则下钻落不到 V4 的出入库页签');
  1054. assert.equal(activityInbound.direction, 1, '原始 direction 必须保留,否则入库/出库方向在下钻时丢失');
  1055. assert.equal(activityInbound.operateTime, '2026-10-20 14:00:00', '原始 operateTime 必须保留,不能被 timeText 覆盖');
  1056. assert.equal(activityInbound.status, null, '原始 status 不得被状态文案覆盖(io 行恒为 null)');
  1057. assert.equal(activityInbound.timeText, '今日 14:00', '展示字段 timeText 必须由 formatActivityTime 给出');
  1058. assert.equal(activityInbound.statusText, '已完成', '展示字段 statusText 用新名字,不占用 status');
  1059. assert.equal(activityInbound.tone, 'done', '展示字段 tone 必须落在 done/waiting/doing/danger/neutral 之内');
  1060. // 空态:null/非数组入参必须安全返回空列表(后端未下发 activities、或 summary 还没到),绝不能抛错把整屏打黑。
  1061. for (const badFeed of [null, undefined, [], 'x', {}, 0]) {
  1062. const emptyFeed = buildActivityFeed(badFeed, activityNow);
  1063. assert.equal(emptyFeed.items.length, 0, '空/非数组入参必须返回空列表(入参 ' + String(badFeed) + ')');
  1064. assert.equal(emptyFeed.total, 0, '空/非数组入参的 total 必须是 0(入参 ' + String(badFeed) + ')');
  1065. }
  1066. // —— (5) 超时角标:只有橙色「待*」且事件时间早于 now−24h 才标 ——
  1067. // 给 done/doing 的行套「超时」没有业务含义,只会让用户以为还有活没干;重复求值也不许漂移(30 秒轮询会反复求值)。
  1068. const activityOverdueOf = (row) => buildActivityFeed([row], activityNow).items[0].overdue;
  1069. assert.equal(activityOverdueOf({ id: 'w1', orderId: 'w1', activityType: 'inventory', status: 0, operateTime: '2026-10-18 09:00:00' }), true, '待执行超过 24 小时(54.5h)必须标超时');
  1070. assert.equal(activityOverdueOf({ id: 'w2', orderId: 'w2', activityType: 'inventory', status: 0, operateTime: '2026-10-19 20:00:00' }), false, '24 小时内的待执行不得标超时(19.5h)');
  1071. assert.equal(activityOverdueOf({ id: 'w3', orderId: 'w3', activityType: 'inventory', status: 0, operateTime: '2026-10-19 14:00:00' }), true, '刚过 24 小时(25.5h)必须标超时');
  1072. assert.equal(activityOverdueOf({ id: 'd1', orderId: 'd1', activityType: 'io', direction: 1, operateTime: '2026-09-01 09:00:00' }), false, '已完成行再老也不标超时(绿行没有超时语义)');
  1073. assert.equal(activityOverdueOf({ id: 'd2', orderId: 'd2', activityType: 'transfer', operateTime: '2026-09-01 09:00:00' }), false, '调拨(已执行)行再老也不标超时');
  1074. assert.equal(activityOverdueOf({ id: 'p1', orderId: 'p1', activityType: 'inventory', status: 1, operateTime: '2026-09-01 09:00:00' }), false, '进行中行再老也不标超时(doing 只表达正在跑)');
  1075. const activityFeedAgain = buildActivityFeed(activityFeedRows, activityNow);
  1076. assert.equal(activityFeedAgain.items.map((item) => item.overdue).join('|'), activityFeed.items.map((item) => item.overdue).join('|'), '重复求值的 overdue 必须完全一致(幂等,轮询不得让角标闪来闪去)');
  1077. assert.equal(activityFeedAgain.items.map((item) => item.tone).join('|'), activityFeed.items.map((item) => item.tone).join('|'), '重复求值的 tone 必须完全一致');
  1078. // —— (6) diffActivityIds:首屏不整屏高亮、只标新增、同批二次为空 ——
  1079. // prev 为空=首次加载:返回空数组,否则首屏 5 行全部高亮,等于什么都没强调,只是让卡片闪一下。
  1080. assert.equal(diffActivityIds([], [{ id: 'n1', orderId: 'N1' }]).length, 0, '首次加载(prev 为空)必须返回空数组,首屏不得整屏高亮');
  1081. assert.equal(diffActivityIds(null, [{ id: 'n1', orderId: 'N1' }]).length, 0, 'prev 缺失时同样不得高亮');
  1082. const activityPrevItems = [{ id: 'x', orderId: 'X' }, { id: 'y', orderId: 'Y' }];
  1083. const activityNextItems = [{ id: 'y', orderId: 'Y' }, { id: 'z', orderId: 'Z' }];
  1084. assert.equal(diffActivityIds(activityPrevItems, activityNextItems).join('|'), 'z', '只返回 next 里新增的 id,且顺序与 next 一致');
  1085. // 同一批数据二次求值必须返回空数组:activities 是计算属性,每次求值都是新数组,
  1086. // 按引用比较会让 30 秒轮询把同一批行全部判成「新增」,整屏反复闪。
  1087. assert.equal(diffActivityIds(activityNextItems, activityNextItems).join('|'), '', '同一批数据二次调用必须返回空数组(轮询不得让整屏重新高亮)');
  1088. assert.equal(diffActivityIds([{ id: 'old-id', orderId: 'Y' }], [{ id: 'new-id', orderId: 'Y' }]).length, 0, '同一单据换了 id 也不算新增:比较键必须与去重键同源(orderId 优先)');
  1089. // —— (7) 模板四段结构 + 空态 + 「全部动态 ›」入口(源码字符串断言,防止语义被搬回模板)——
  1090. const activitySfc = compiler.parseComponent(overviewSource);
  1091. const activityTpl = activitySfc.template.content;
  1092. assert.match(activityTpl, /class="tm"\s*:title="item\.timeTitle"/, '第一段必须是时间(.tm 渲染 timeText,title 给完整时刻)');
  1093. assert.match(activityTpl, /class="feed-doc"/, '第二段必须是单据类型 + 单号(.feed-doc)');
  1094. assert.match(activityTpl, /class="feed-desc"\s*:title="item\.actionText"/, '第三段必须是动作描述(.feed-desc 渲染 actionText)');
  1095. // 状态色只认 item.tone:允许拼接写法变化('feed-status ' + item.tone 或数组写法),但必须能读到 item.tone,
  1096. // 且模板里绝不能出现写死的 feed-status done/waiting/... —— 写死就等于在模板里又判了一遍业务状态。
  1097. assert.match(activityTpl, /:class="[^"]*feed-status[^"]*item\.tone[^"]*"/, '第四段状态色必须由 item.tone 拼成 feed-status 类(模板里不得再判业务状态)');
  1098. assert.doesNotMatch(activityTpl, /feed-status\s+(?:done|waiting|doing|danger|neutral)\b/, '模板不得写死 feed-status 的具体 tone 类,颜色只能由 item.tone 决定');
  1099. // 单号段必须自己可点,且 @click.prevent.stop:少了 prevent 页面会跳走,少了 stop 会冒泡到整行触发两次下钻。
  1100. assert.match(activityTpl, /class="feed-no"[\s\S]{0,260}@click\.prevent\.stop="openActivity\(item\)"/, '单号必须可点击且带 @click.prevent.stop(防跳转 + 防冒泡双触发下钻)');
  1101. assert.match(activityTpl, /v-if="item\.overdue"\s+class="badge-overdue">超时</, '超时角标必须只在 item.overdue 时渲染「超时」文案');
  1102. // 空态必须有一句固定文案:后端不给 activityReason 时页面不能变成一块空白(那会被当成卡片坏了)。
  1103. // 模板里不止一处 .empty-panel(库龄卡也有一个),所以先从活动卡那句固定文案反查它自己的容器,
  1104. // 直接抓第一个 .empty-panel 会抓到别的卡片、让这条断言永远通过(或永远指向错元素)。
  1105. const activityEmptyIndex = activityTpl.indexOf('今日暂无作业动态');
  1106. assert.ok(activityEmptyIndex > 0, '空态必须有固定文案「今日暂无作业动态」');
  1107. const activityEmptyOpen = activityTpl.lastIndexOf('<div', activityEmptyIndex);
  1108. const activityEmptyTag = activityTpl.slice(activityEmptyOpen, activityTpl.indexOf('>', activityEmptyOpen) + 1);
  1109. assert.match(activityEmptyTag, /class="empty-panel"/, '固定文案必须落在 .empty-panel 空态容器里,不能挂到别的卡片上');
  1110. const activityEmptyHead = activityTpl.slice(activityTpl.indexOf('>', activityEmptyOpen) + 1, activityEmptyIndex + 80);
  1111. assert.match(activityEmptyHead, /^今日暂无作业动态/, '空态第一句必须是固定文案,不能只渲染后端原因');
  1112. assert.match(activityEmptyHead, /v-if="activityReason"/, '后端原因只能作为可选后缀拼在固定文案之后');
  1113. assert.doesNotMatch(activityTpl, /class="empty-panel"[^>]*>\s*<span v-if="activityReason"/, '空态不得只剩 activityReason:后端不给原因时会渲染成空白');
  1114. // V4 入口不许改:「全部动态 ›」仍是 open('io'),改成别的目标会把用户带到别的单据类型列表。
  1115. const activityAllLink = (activityTpl.match(/<a\b[^>]*>全部动态 ›<\/a>/) || [])[0] || '';
  1116. assert.ok(activityAllLink, '「全部动态 ›」入口必须存在(V4 出入库动态的唯一入口)');
  1117. assert.match(activityAllLink, /\$emit\('open', 'io'\)/, '「全部动态 ›」必须仍发 open(\'io\') 落到 V4,不得改成别的目标');
  1118. // —— (8) 高亮:3 秒渐隐定时器 + beforeDestroy 清理;轮询不得动滚动 ——
  1119. const activityScript = activitySfc.script.content;
  1120. assert.match(activityScript, /setTimeout\(\(\) => \{[\s\S]{0,200}?\}, 3000\)/, '新增事件高亮必须是 3000ms 定时器(与 scss 的 3s 渐隐动画同长)');
  1121. assert.match(activityScript, /beforeDestroy\(\)\s*\{[\s\S]{0,260}?clearTimeout\(this\.highlightTimer\)/, 'beforeDestroy 必须清理高亮定时器(否则 3 秒后往已销毁实例写响应式数据)');
  1122. // 滚动守卫前先剥注释:实现里为了说明「禁止 scrollIntoView/scrollTop」本身就写了这两个词,
  1123. // 拿原文匹配会把这条说明当成违规;剥掉注释后剩下的才是真正的代码,守卫挡的就是它。
  1124. const stripCodeComments = (source) => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
  1125. const activityOverviewCode = stripCodeComments(overviewSource);
  1126. const activityThemeCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-theme.scss'), 'utf8'));
  1127. // 防呆:剥注释后仍必须读到真实模板与样式,否则下面的「不出现」断言会空转(剥太狠等于什么都没查)。
  1128. assert.match(activityOverviewCode, /class="feed-item"/, '剥注释后仍必须能读到 feed-item 模板(否则滚动守卫空转)');
  1129. assert.match(activityThemeCode, /\.feed-status\.done\s*\{/, '剥注释后仍必须能读到 .feed-status.done 规则(否则滚动守卫空转)');
  1130. for (const [activityFile, activityCode] of [['HomeOverview.vue', activityOverviewCode], ['dashboard-theme.scss', activityThemeCode]]) {
  1131. assert.doesNotMatch(activityCode, /scrollIntoView|scrollTop|scrollTo|scrollBy|scrollLeft/, activityFile + ' 不得调用任何滚动 API:30 秒轮询里主动滚动会把用户正在看的位置顶走(只允许注释里提这些词)');
  1132. }
  1133. // —— (9) 样式契约:五条 tone 规则 + is-new 渐隐关键帧 ——
  1134. for (const activityTone of ['done', 'waiting', 'doing', 'danger', 'neutral']) {
  1135. assert.match(activityThemeCode, new RegExp('\\.feed-status\\.' + activityTone + '\\s*\\{'), 'dashboard-theme.scss 必须有 .feed-status.' + activityTone + ' 规则:缺一条该状态就无色(neutral 灰是「前端不认识这个状态」的唯一视觉语言)');
  1136. }
  1137. assert.match(activityThemeCode, /\.feed-item\.is-new\s*\{[\s\S]{0,80}?animation:\s*feed-new-fade\s+3s/, '新增行必须有 is-new 的 3s 渐隐动画,时长与 JS 的 3000ms 定时器一致');
  1138. assert.match(activityThemeCode, /@keyframes\s+feed-new-fade\s*\{[\s\S]{0,400}?\}/, 'is-new 的 @keyframes feed-new-fade 必须存在(否则 animation 引用不存在的动画,高亮静默失效)');
  1139. 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 关键帧。');
  1140. (async () => {
  1141. 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); } } };
  1142. await Promise.all([page.methods.submitReceipts.call(state), page.methods.submitReceipts.call(state)]);
  1143. assert.equal(calls, 1, '重复点击只能发出一次批量请求');
  1144. assert.equal(state.preparedReceipts.length, 1);
  1145. assert.equal(state.preparedReceipts[0].requestKey, 'b', '失败申请重试保留原幂等键');
  1146. assert.equal(state.receiving, false);
  1147. console.log('仓储看板业务断言通过:月份、空值、大整数身份、授权入口、待办范围、分组下钻、收货去重与失败重试、周转/呆滞后端分级映射、呆滞分子分母不可颠倒(3÷70=4.29%)、pp 环比缺失回归、口径条阈值占位符与 180 回落、stagnantDays 命名守卫。');
  1148. })().catch(error => { console.error(error); process.exitCode = 1; });
  1149. // —— G1 深色主题作用域:只允许作用于看板自身,禁止 body 级全局 class ——
  1150. // 背景:body.wms-dashboard-theme 曾把 .ele-admin-content 下的 Element 组件整片刷成深色,
  1151. // 首页下钻到出库单详情、盘点工单详情等业务页时 URL 带着 dashboard=1,新页签因此整页变深蓝背景。
  1152. const dashboardElementCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-element.scss'), 'utf8'));
  1153. const dashboardVueCode = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'));
  1154. const routerCode = stripCodeComments(fs.readFileSync(path.join(root, 'src/router/index.js'), 'utf8'));
  1155. assert.doesNotMatch(dashboardElementCode, /body\.[A-Za-z-]+/, 'dashboard-element.scss 不得以 body 级 class 为作用域:它会把下钻后的业务页整页刷成深色');
  1156. assert.match(dashboardElementCode, /\.wms-home\s*(,|\{)/, 'dashboard-element.scss 必须保留 .wms-home 作用域,否则看板自身的深色样式全部失效');
  1157. assert.match(dashboardElementCode, /\.wms-dashboard-dialog/, '看板专用弹层 .wms-dashboard-dialog 必须保留:append-to-body 的弹层只能靠它着色');
  1158. assert.match(dashboardElementCode, /\.wms-dashboard-popper/, '看板专用下拉面板 .wms-dashboard-popper 必须保留:popper 被挂到 body,靠 .wms-home 覆盖不到');
  1159. assert.doesNotMatch(dashboardVueCode, /document\.body\.classList/, 'dashboard.vue 不得再操作 body 全局主题 class:销毁/失活时机与目标页渲染交错,残留即污染下一个页签');
  1160. assert.doesNotMatch(routerCode, /document\.body\.classList\.(toggle|add|remove)/, 'router 不得按 to.query.dashboard 切换 body 主题 class:dashboard=1 会随首页下钻带到业务页');
  1161. const navigateSource = (dashboardVueCode.match(/navigate\(path, query = \{\}\)[^\n]*/) || [''])[0];
  1162. assert.ok(navigateSource, 'dashboard.vue 必须保留下钻方法 navigate');
  1163. assert.doesNotMatch(navigateSource, /dashboard: *'1'/, 'navigate 不得把 dashboard=1 带进业务页 URL:系统原有入口不携带该参数');
  1164. assert.match(navigateSource, /NavigationDuplicated/, 'navigate 必须吞掉 NavigationDuplicated:重复点同一行不该弹错误提示');
  1165. 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。');
  1166. // —— H1 首页默认基本单位:不选单位时 KPI 整片为空,必须默认第一个 ——
  1167. const unitWith = (state) => Object.assign({ defaultUnit: page.methods.defaultUnit }, state);
  1168. const defaultedState = unitWith({ unit: '', filters: {}, options: { defaultUnit: 'PCS', units: [{ unit: '个' }, { unit: 'PCS' }] } });
  1169. page.methods.applyDefaultUnit.call(defaultedState, false);
  1170. assert.equal(defaultedState.unit, 'PCS', 'URL 未带 unit 时必须默认选中后端 home-options 的 defaultUnit');
  1171. assert.equal(defaultedState.filters.unit, 'PCS', '默认单位必须同时写进 filters,否则请求参数里根本没有 unit');
  1172. const explicitEmptyState = unitWith({ unit: '', filters: {}, options: { defaultUnit: 'PCS' } });
  1173. page.methods.applyDefaultUnit.call(explicitEmptyState, true);
  1174. assert.equal(explicitEmptyState.unit, '', 'URL 显式 unit=(用户主动选「全部单位」)时必须保持空,不能被默认值覆盖');
  1175. assert.deepEqual(explicitEmptyState.filters, {}, '显式空单位不得写进 filters');
  1176. const keepState = unitWith({ unit: 'KG', filters: { unit: 'KG' }, options: { defaultUnit: 'PCS' } });
  1177. page.methods.applyDefaultUnit.call(keepState, false);
  1178. assert.equal(keepState.unit, 'KG', '用户已选单位不得被默认值覆盖');
  1179. assert.equal(page.methods.defaultUnit.call({ options: { units: [{ unit: ' ' }, { unit: 'KG' }] } }), 'KG', 'defaultUnit 缺失时必须回退 units 第一个非空单位(空白不算)');
  1180. assert.equal(page.methods.defaultUnit.call({ options: { units: [] } }), '', '没有任何单位时返回空串,不得编造默认值');
  1181. assert.equal(page.methods.defaultUnit.call({ options: { units: [{ unit: '个' }] } }), '个', '未下发 defaultUnit 的老响应按 units[0] 回退');
  1182. const homeUnitSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'));
  1183. assert.match(homeUnitSource, /if \(this\.view === 'home'\) this\.applyDefaultUnit\(/, 'readRoute 必须在首页兜底默认单位(首次进入、切仓、返回首页都走这里)');
  1184. assert.match(homeUnitSource, /this\.applyDefaultUnit\(Object\.prototype\.hasOwnProperty\.call\(this\.\$route\.query, 'unit'\)\)/, 'created 拉到筛选项后必须再兜一次默认单位:第一次 readRoute 时 options 还没到');
  1185. assert.doesNotMatch(homeUnitSource, /warehouseId, unit: ''/, '切换仓库不得清空基本单位:清空后立刻落到「全部单位」,首页又没有数据');
  1186. assert.match(homeUnitSource, /if \(view === 'home' && this\.view !== 'home' && !filters\.unit\) filters\.unit = this\.defaultUnit\(\)/, '从出入库申请/入总库申请返回首页必须补回单位,否则业务视图清掉的空单位会一直留着');
  1187. console.log('H1 首页默认基本单位断言通过:URL 未带 unit 时默认选中 home-options.defaultUnit(缺失回退 units 首个非空单位)并写进请求参数、显式「全部单位」不被覆盖、已选单位不被覆盖、切换仓库与从业务视图返回首页都不再落回「全部单位」。');
  1188. // —— I1 盘点工单详情下钻:必须带 planId,且不得把首页筛选整包塞进业务页 URL ——
  1189. let pushed = null;
  1190. const navState = {
  1191. filters: { startTime: '2026-10-01 00:00:00', endTime: '2026-10-31 23:59:59', page: '1', warehouseId: '2103077489545842690', unit: 'KG', categoryId: '2082647086249619457' },
  1192. $router: { push(target) { pushed = target; return Promise.resolve(); } },
  1193. $message: { error(message) { throw new Error(message); } }
  1194. };
  1195. // cleanQuery 在 vm 上下文里执行,返回对象与本上下文原型不同,deepEqual 会误报,统一用 JSON 串比对。
  1196. const sameQuery = (actual, expected, message) => assert.equal(JSON.stringify(actual), JSON.stringify(expected), message);
  1197. page.methods.navigate.call(navState, '/warehouseManagement/stocktaking/work/details', { id: '2115696383397093378', planId: '2115696383174795265' });
  1198. sameQuery(pushed.query, { id: '2115696383397093378', planId: '2115696383174795265' },
  1199. '盘点工单详情 URL 只允许 id 与 planId:首页的 startTime/endTime/page/unit/categoryId/warehouseId 一个都不该带进去');
  1200. assert.equal(pushed.path, '/warehouseManagement/stocktaking/work/details');
  1201. pushed = null;
  1202. page.methods.navigate.call(navState, '/warehouseManagement/outgoingManagement/details', { id: '2115696383397093378', planId: undefined });
  1203. sameQuery(pushed.query, { id: '2115696383397093378' }, '没有 planId 的入口(旧数据/旧链接)不得拼出 planId=undefined');
  1204. let businessPush = null;
  1205. const businessState = {
  1206. navigate(path, query) { businessPush = { path, query }; },
  1207. open() {}, portal() {}, filters: {},
  1208. $refs: { actions: { open() {} } },
  1209. $message: { warning() {}, error() {} }
  1210. };
  1211. businessPush = null;
  1212. page.methods.business.call(businessState, 'todo', { type: 'inventory', id: '2115696383397093378', planId: '2115696383174795265' });
  1213. assert.equal(businessPush.path, '/warehouseManagement/stocktaking/work/details');
  1214. sameQuery(businessPush.query, { id: '2115696383397093378', planId: '2115696383174795265' },
  1215. '待办「盘点任务进行中」下钻必须同时带 id(planOrderId) 与 planId,否则详情页拿不到计划信息');
  1216. businessPush = null;
  1217. page.methods.business.call(businessState, 'io', { activityType: 'inventory', orderId: '2115696383397093378', planId: '2115696383174795265' });
  1218. assert.equal(businessPush.path, '/warehouseManagement/stocktaking/work/details');
  1219. sameQuery(businessPush.query, { id: '2115696383397093378', planId: '2115696383174795265' },
  1220. '出入库动态里的盘点流水下钻同样要带 planId');
  1221. const navigateNoFiltersSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8')).match(/navigate\(path, query = \{\}\)[^\n]*/)[0];
  1222. assert.doesNotMatch(navigateNoFiltersSource, /\.\.\.this\.filters/, 'navigate 不得再整包透传首页筛选(this.filters)到业务页 URL');
  1223. console.log('I1 盘点工单详情下钻断言通过:待办/动态两个入口都带 id+planId、planId 缺失时不拼 undefined、下钻 URL 只保留显式参数(首页 startTime/endTime/page/unit/categoryId/warehouseId 不再进业务页)。');
  1224. // —— J1 「出入库动态 › 查看动态」必须跳系统菜单真实路由 warehouseManagement/inboundRequests ——
  1225. // 不是首页内嵌的 ioapp 视图,也不是自建 V4 动态列表:用户要找的是「出入库申请」这张菜单页本身。
  1226. const homeTplSource = fs.readFileSync(path.join(folder, 'components/HomeOverview.vue'), 'utf8');
  1227. const trendLink = (homeTplSource.match(/<a\b[^>]*>查看动态 ›<\/a>/) || [])[0] || '';
  1228. assert.ok(trendLink, '「查看动态 ›」入口必须存在');
  1229. assert.match(trendLink, /\$emit\('navigate', '\/warehouseManagement\/inboundRequests', \{ warehouseId: warehouseId \}\)/,
  1230. '「查看动态 ›」必须发 navigate(\'/warehouseManagement/inboundRequests\', { warehouseId }):跳菜单真实路由,而不是内嵌 ioapp 视图');
  1231. assert.doesNotMatch(trendLink, /\$emit\('open', 'ioapp'/, '不得再用内嵌 ioapp 视图顶替菜单页');
  1232. assert.doesNotMatch(trendLink, /\$emit\('open', 'io'\)/, '也不得落到自建 V4 动态列表');
  1233. const dashboardTplSource = fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8');
  1234. const homeOverviewTag = (dashboardTplSource.match(/<home-overview\b[^>]*>/) || [])[0] || '';
  1235. assert.match(homeOverviewTag, /@navigate="navigate"/, 'dashboard.vue 必须把 home-overview 的 navigate 事件接到 navigate(),否则点「查看动态」无反应');
  1236. let routePushed = null;
  1237. const routeState = {
  1238. $route: { path: '/warehouseManagement/index', query: {} },
  1239. $router: { push(target) { routePushed = target; return Promise.resolve(); } },
  1240. $message: { error(message) { throw new Error(message); } }
  1241. };
  1242. page.methods.navigate.call(routeState, '/warehouseManagement/inboundRequests', { warehouseId: '2103077489545842690' });
  1243. assert.equal(routePushed.path, '/warehouseManagement/inboundRequests', '必须跳真实菜单路由,路径与系统菜单一致');
  1244. sameQuery(routePushed.query, { warehouseId: '2103077489545842690' }, '只带当前仓库,状态/时间/页签交给目标页默认值');
  1245. assert.ok(!('dashboard' in routePushed.query), '跳菜单页不得再带 dashboard=1:会误开看板批量收货勾选列');
  1246. routePushed = null;
  1247. page.methods.navigate.call(routeState, '/warehouseManagement/inboundRequests', { warehouseId: '' });
  1248. sameQuery(routePushed.query, {}, '未选仓库(全部授权仓库)时 query 必须为空,不能塞空串或首页筛选');
  1249. const allLink = (homeTplSource.match(/<a\b[^>]*>全部动态 ›<\/a>/) || [])[0] || '';
  1250. assert.match(allLink, /\$emit\('open', 'io'\)/, '「全部动态 ›」仍落 V4 动态列表,不被本次改动带走');
  1251. console.log('J1 出入库动态入口断言通过:「查看动态 ›」发 navigate(\'/warehouseManagement/inboundRequests\', { warehouseId }),dashboard.vue 已接 @navigate,跳真实菜单路由(独立页签)、只带仓库、不带 dashboard=1;「全部动态 ›」与趋势柱体仍落 V4 动态列表。');
  1252. // —— K1 首页首屏遮罩:数据回来之前不显示内容,且只在首屏出现一次 ——
  1253. const dashSource = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard.vue'), 'utf8'));
  1254. const maskTag = (dashSource.match(/<div\b[^>]*class="home-loading"[^>]*>/) || [])[0] || '';
  1255. assert.ok(maskTag, 'dashboard.vue 必须有首屏遮罩节点(.home-loading)');
  1256. assert.match(maskTag, /v-if="homeLoading && view === 'home'"/, '遮罩只能在首页视图且 homeLoading 为真时出现:明细视图有自己的 v-loading,不能被这块盖住');
  1257. assert.ok(dashSource.includes('正在加载首页数据'), '遮罩必须给出中文加载文案,不能只放一个转圈');
  1258. assert.equal(page.data().homeLoading, true, 'homeLoading 必须默认 true:首屏还没请求就不能先渲染空卡片');
  1259. const createdBlock = dashSource.slice(dashSource.indexOf('async created()'), dashSource.indexOf('activated()'));
  1260. assert.match(createdBlock, /this\.homeLoading = false;/, 'created 里首屏(筛选项 + 汇总)结束后必须撤掉遮罩');
  1261. assert.ok(createdBlock.indexOf('this.homeLoading = false') > createdBlock.indexOf('await this.refresh()'), '必须先 await refresh() 再撤遮罩,否则数据还没到就露出来了');
  1262. assert.ok((dashSource.match(/this\.homeLoading = false/g) || []).length === 1, 'homeLoading 只能在首屏置一次 false:定时刷新与筛选项切换不该闪遮罩');
  1263. const layoutScss = stripCodeComments(fs.readFileSync(path.join(folder, 'dashboard-layout.scss'), 'utf8'));
  1264. assert.match(layoutScss, /\.wms-home\s*\{[^}]*position:\s*relative/, '.wms-home 必须是定位上下文,否则遮罩会跑去页面别处');
  1265. assert.match(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*position:\s*absolute/, '遮罩必须绝对定位在内容区内');
  1266. assert.doesNotMatch(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*position:\s*fixed/, '遮罩不得用 fixed:会把系统页头、菜单、标签栏一起盖掉');
  1267. assert.match(layoutScss, /\.wms-home \.home-loading\s*\{[^}]*z-index:\s*\d+/, '遮罩必须显式抬层级,否则盖不住卡片');
  1268. assert.match(layoutScss, /@keyframes\s+home-loading-spin\s*\{[\s\S]{0,120}?rotate\(360deg\)/, '转圈动画必须存在,否则 spinner 静态不转');
  1269. console.log('K1 首页首屏遮罩断言通过:homeLoading 默认 true、created 中 await refresh() 之后置 false(且只置一次),遮罩仅在首页视图出现并带中文文案;样式为 .wms-home 内的 absolute 遮罩(非 fixed,不盖系统页头/菜单)+ z-index + 转圈关键帧。');