|
|
@@ -0,0 +1,162 @@
|
|
|
+// S8-REAL-DATA-PRESENTATION-1:真实事实 vs 参考基线 的来源守卫。
|
|
|
+//
|
|
|
+// 本文件钉住一条边界:**真实事实缺失时,不得由兜底值 / 基线 / seed 顶替**。
|
|
|
+// 背景(2026-09-21 只读取证):
|
|
|
+// · asNodeStatus('') 曾返回 'green' —— 后端 REAL 路径把 CurrentStatus 下发为空串
|
|
|
+// (S8RealOrderArchiveService.cs:272),于是**每一个真实订单**都被涂成绿色「正常」,
|
|
|
+// 而 S8 从未对真实订单做过任何红黄绿评级。
|
|
|
+// · computeOrderExecutionKpis 曾 `?? 0` —— 三个 KPI 卡在毫无 Authority 的情况下
|
|
|
+// 打印「0.0 分钟」,读起来就是「响应很快」。
|
|
|
+// · chain(SEED)接口的响应曾整体覆盖 detail(REAL)已取到的阶段事实,
|
|
|
+// 只有 4 个 DataState 字段被显式救回。
|
|
|
+//
|
|
|
+// 同文件内早有正确范式:asCustomerType / asPriority 对空串一律给 null(渲染 "--"),
|
|
|
+// 注释写着「不得凭空造值」。本测试把同一条规矩扩到其余字段。
|
|
|
+
|
|
|
+import { describe, expect, it } from 'vitest';
|
|
|
+
|
|
|
+import { computeOrderExecutionKpis, getAverage } from './aggregation';
|
|
|
+import { mapDomainOrderListItem } from './domainMapper';
|
|
|
+import { computeNodeStatus, computeStageDataHint } from '../../components/order-execution/statusMapping';
|
|
|
+import type { SalesOrderExecution, StageSnapshot } from './types';
|
|
|
+
|
|
|
+/** 后端 REAL 列表项的最小形状:只带本测试关心的字段,其余给空。 */
|
|
|
+function realListDto(over: Record<string, unknown> = {}) {
|
|
|
+ return {
|
|
|
+ soNo: 'SO-REAL-0001',
|
|
|
+ salesOrderId: 90001,
|
|
|
+ customerName: '真实客户',
|
|
|
+ customerCode: '',
|
|
|
+ customerType: '',
|
|
|
+ productName: '',
|
|
|
+ productLine: '',
|
|
|
+ region: '',
|
|
|
+ priority: '',
|
|
|
+ workflowStatus: 'in_progress',
|
|
|
+ // REAL 路径后端固定下发空串——没有任何评级 Authority
|
|
|
+ currentStatus: '',
|
|
|
+ currentOrderFlowCode: 'MATERIAL_PURCHASE',
|
|
|
+ exceptionStatus: '',
|
|
|
+ releaseAt: null,
|
|
|
+ responseDisplayMinutes: null,
|
|
|
+ currentProcessingMinutes: null,
|
|
|
+ totalLossTime: null,
|
|
|
+ ...over,
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function stage(over: Partial<StageSnapshot> = {}): StageSnapshot {
|
|
|
+ return {
|
|
|
+ key: 'material_procurement',
|
|
|
+ orderFlowCode: 'MATERIAL_PURCHASE',
|
|
|
+ orderFlowName: '材料采购',
|
|
|
+ sortNo: 3,
|
|
|
+ status: 'pending',
|
|
|
+ targetDate: null,
|
|
|
+ actualStartedAt: null,
|
|
|
+ actualReachedAt: null,
|
|
|
+ actualDays: null,
|
|
|
+ actualHours: null,
|
|
|
+ nodeVarianceDays: null,
|
|
|
+ cumulativeVarianceDays: null,
|
|
|
+ calculationStatus: null,
|
|
|
+ dataState: null,
|
|
|
+ dataScope: null,
|
|
|
+ knownItemCount: null,
|
|
|
+ completedItemCount: null,
|
|
|
+ subSteps: [],
|
|
|
+ ...over,
|
|
|
+ } as StageSnapshot;
|
|
|
+}
|
|
|
+
|
|
|
+describe('S8 order-execution provenance — 缺失事实不得被兜底值顶替', () => {
|
|
|
+ // ── ② MissingRealFact_DoesNotBecomeBaselineFact ──
|
|
|
+
|
|
|
+ it('空的 currentStatus 不得兜底成 green(否则每个真实订单都显示“正常”)', () => {
|
|
|
+ const row = mapDomainOrderListItem(realListDto() as never) as SalesOrderExecution;
|
|
|
+ expect(row.nodeStatus).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('后端真的下发评级时仍按原值透传', () => {
|
|
|
+ for (const v of ['green', 'yellow', 'red'] as const) {
|
|
|
+ const row = mapDomainOrderListItem(realListDto({ currentStatus: v }) as never) as SalesOrderExecution;
|
|
|
+ expect(row.nodeStatus).toBe(v);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('无法识别的评级值判为未知,而不是悄悄变成 green', () => {
|
|
|
+ const row = mapDomainOrderListItem(realListDto({ currentStatus: 'BOGUS' }) as never) as SalesOrderExecution;
|
|
|
+ expect(row.nodeStatus).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('三项时间 KPI 全无 Authority 时必须是 null,不得是 0', () => {
|
|
|
+ const orders = [
|
|
|
+ { responseDisplayMinutes: null, currentProcessingMinutes: null, totalLossTime: null },
|
|
|
+ { responseDisplayMinutes: null, currentProcessingMinutes: null, totalLossTime: null },
|
|
|
+ ] as unknown as SalesOrderExecution[];
|
|
|
+
|
|
|
+ const kpi = computeOrderExecutionKpis(orders);
|
|
|
+ expect(kpi.avgResponse).toBeNull();
|
|
|
+ expect(kpi.avgProcessing).toBeNull();
|
|
|
+ expect(kpi.avgLoss).toBeNull();
|
|
|
+ });
|
|
|
+
|
|
|
+ it('有真实数值时仍照常求平均(不因本次修复而失真)', () => {
|
|
|
+ const orders = [
|
|
|
+ { responseDisplayMinutes: 10, currentProcessingMinutes: 4, totalLossTime: 100 },
|
|
|
+ { responseDisplayMinutes: 20, currentProcessingMinutes: 6, totalLossTime: 200 },
|
|
|
+ ] as unknown as SalesOrderExecution[];
|
|
|
+
|
|
|
+ const kpi = computeOrderExecutionKpis(orders);
|
|
|
+ expect(kpi.avgResponse).toBe(15);
|
|
|
+ expect(kpi.avgProcessing).toBe(5);
|
|
|
+ expect(kpi.avgLoss).toBe(150);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('getAverage 对空集合本就返回 null —— 该语义不得再被 ?? 0 吃掉', () => {
|
|
|
+ expect(getAverage([])).toBeNull();
|
|
|
+ expect(getAverage([2, 4])).toBe(3);
|
|
|
+ });
|
|
|
+
|
|
|
+ // ── ③ BaselineDuration_IsNotRenderedAsActualDuration ──
|
|
|
+ // ── ④ BaselineTimestamp_IsNotRenderedAsActualTimestamp ──
|
|
|
+
|
|
|
+ it('没有真实完成时间/耗时的阶段不得被标成已完成', () => {
|
|
|
+ const node = computeNodeStatus(stage({ targetDate: '2026-08-01 12:00', actualDays: 5 }));
|
|
|
+ expect(node.label).not.toContain('已完成');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('仅有目标时间(计划值)不产生任何实际达成结论', () => {
|
|
|
+ const planOnly = computeNodeStatus(stage({ targetDate: '2026-08-01 12:00' }));
|
|
|
+ const nothing = computeNodeStatus(stage());
|
|
|
+ expect(planOnly.label).toBe(nothing.label);
|
|
|
+ });
|
|
|
+
|
|
|
+ // ── ⑥ Stage3_DataState_RemainsBackendAuthority ──
|
|
|
+
|
|
|
+ it('DataState 优先于任何时间形态推断', () => {
|
|
|
+ // 即便带着「看起来已完成」的时间,DataState 仍必须赢
|
|
|
+ const withTimes = stage({
|
|
|
+ dataState: 'NO_DATA',
|
|
|
+ actualStartedAt: '2026-08-01 09:00',
|
|
|
+ actualReachedAt: '2026-08-09 18:00',
|
|
|
+ });
|
|
|
+ expect(computeNodeStatus(withTimes).label).toBe('暂无已归属数据');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('DataState 三态均不得出现「已完成」语义', () => {
|
|
|
+ for (const s of ['IN_PROGRESS', 'NO_DATA', 'UNKNOWN'] as const) {
|
|
|
+ expect(computeNodeStatus(stage({ dataState: s })).label).not.toContain('已完成');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('已归属计数只在 IN_PROGRESS 且确有计数时出现', () => {
|
|
|
+ expect(computeStageDataHint(stage({ dataState: 'NO_DATA', knownItemCount: 0 }))).toBeNull();
|
|
|
+ expect(computeStageDataHint(stage({ dataState: 'UNKNOWN' }))).toBeNull();
|
|
|
+ const hint = computeStageDataHint(
|
|
|
+ stage({ dataState: 'IN_PROGRESS', knownItemCount: 8, completedItemCount: 3 }),
|
|
|
+ );
|
|
|
+ expect(hint).toContain('8');
|
|
|
+ expect(hint).toContain('3');
|
|
|
+ });
|
|
|
+});
|