|
|
@@ -0,0 +1,1477 @@
|
|
|
+/* eslint-disable */
|
|
|
+/**
|
|
|
+ * ORDER-FLOW-FIXTURE-STORE-1: 从 ecc-sandbox/frontend/src/mock/orderExecutionMock.ts 等价复刻;EXECUTION_MOCK_CUSTOMERS = 5 customers / 105 orders。
|
|
|
+ * Use src/services/orderChainService.ts instead.
|
|
|
+ */
|
|
|
+import type { DeliveryOrder, ExceptionEvent, OrderNodeExecution, OrderNodeKey } from './types';
|
|
|
+export type {
|
|
|
+ ExecutionSubStepDetail,
|
|
|
+ ExecutionDesignTaskDetail,
|
|
|
+ ExecutionProcurementFact,
|
|
|
+ ExecutionManufacturingFactor,
|
|
|
+ ExecutionManufacturingProcessDetail,
|
|
|
+ ExecutionManufacturingLossDetail,
|
|
|
+ ExecutionManufacturingFactorDetail,
|
|
|
+ ExecutionManufacturingOperatorDetail,
|
|
|
+ ExecutionManufacturingStepDetail,
|
|
|
+ ExecutionRawNodeDetail,
|
|
|
+ ExecutionRawWorkOrder,
|
|
|
+ ExecutionRawOrder,
|
|
|
+ ExecutionRawCustomer,
|
|
|
+} from './types';
|
|
|
+import type {
|
|
|
+ ExecutionSubStepDetail,
|
|
|
+ ExecutionDesignTaskDetail,
|
|
|
+ ExecutionProcurementFact,
|
|
|
+ ExecutionManufacturingFactor,
|
|
|
+ ExecutionManufacturingLossDetail,
|
|
|
+ ExecutionManufacturingStepDetail,
|
|
|
+ ExecutionRawNodeDetail,
|
|
|
+ ExecutionRawWorkOrder,
|
|
|
+ ExecutionRawOrder,
|
|
|
+ ExecutionRawCustomer,
|
|
|
+} from './types';
|
|
|
+
|
|
|
+type FamilyTemplate = {
|
|
|
+ id: string;
|
|
|
+ count: number;
|
|
|
+ workflowStatus: 'completed' | 'in_progress';
|
|
|
+ productType: '常规' | '非标';
|
|
|
+ priorityPool: Array<'P1' | 'P2' | 'P3'>;
|
|
|
+ supplierRisk: 'low' | 'medium' | 'high';
|
|
|
+ manufacturingLoad: 'low' | 'medium' | 'high';
|
|
|
+ focusNodeKey: OrderNodeKey;
|
|
|
+ currentNodeKey: OrderNodeKey;
|
|
|
+ offsets: Record<OrderNodeKey, [number, number]>;
|
|
|
+ exceptionTemplateIds: string[];
|
|
|
+ minExceptions: number;
|
|
|
+ maxExceptions: number;
|
|
|
+};
|
|
|
+
|
|
|
+type ExceptionTemplate = {
|
|
|
+ category: 'T' | 'R' | 'V';
|
|
|
+ title: string;
|
|
|
+ nodeKey: OrderNodeKey;
|
|
|
+ waitRange: [number, number];
|
|
|
+ responseRange: [number, number];
|
|
|
+ handleRange: [number, number];
|
|
|
+};
|
|
|
+
|
|
|
+type CustomerProfile = {
|
|
|
+ id: string;
|
|
|
+ name: string;
|
|
|
+ type: 'KA' | 'SMB' | 'MICRO';
|
|
|
+};
|
|
|
+
|
|
|
+const OBSERVED_AT = new Date(2026, 2, 11, 20, 42, 0, 0);
|
|
|
+const DAY_MINUTES = 24 * 60;
|
|
|
+
|
|
|
+const NODE_SEQUENCE: Array<{ key: OrderNodeKey; name: string; kpiDays: number }> = [
|
|
|
+ { key: 'order_review', name: '订单评审', kpiDays: 5 },
|
|
|
+ { key: 'product_design', name: '产品设计', kpiDays: 3 },
|
|
|
+ { key: 'material_procurement', name: '材料采购', kpiDays: 14 },
|
|
|
+ { key: 'body_production', name: '本体生产', kpiDays: 6 },
|
|
|
+ { key: 'final_assembly_shipping', name: '总装发货', kpiDays: 3 },
|
|
|
+];
|
|
|
+
|
|
|
+const KPI_DAYS: Record<OrderNodeKey, number> = Object.fromEntries(
|
|
|
+ NODE_SEQUENCE.map((node) => [node.key, node.kpiDays]),
|
|
|
+) as Record<OrderNodeKey, number>;
|
|
|
+
|
|
|
+const NODE_NAME_MAP: Record<OrderNodeKey, string> = Object.fromEntries(
|
|
|
+ NODE_SEQUENCE.map((node) => [node.key, node.name]),
|
|
|
+) as Record<OrderNodeKey, string>;
|
|
|
+
|
|
|
+const NODE_KEY_BY_NAME: Record<string, OrderNodeKey> = Object.fromEntries(
|
|
|
+ NODE_SEQUENCE.map((node) => [node.name, node.key]),
|
|
|
+) as Record<string, OrderNodeKey>;
|
|
|
+
|
|
|
+const CUSTOMER_PROFILES: CustomerProfile[] = [
|
|
|
+ { id: 'C001', name: '电气设备厂A', type: 'KA' },
|
|
|
+ { id: 'C002', name: '电气设备厂B', type: 'KA' },
|
|
|
+ { id: 'C003', name: '电气设备厂C', type: 'KA' },
|
|
|
+ { id: 'C004', name: '电气设备厂D', type: 'SMB' },
|
|
|
+ { id: 'C005', name: '电气设备厂E', type: 'MICRO' },
|
|
|
+];
|
|
|
+
|
|
|
+const CUSTOMER_PICKER = [
|
|
|
+ CUSTOMER_PROFILES[0],
|
|
|
+ CUSTOMER_PROFILES[1],
|
|
|
+ CUSTOMER_PROFILES[2],
|
|
|
+ CUSTOMER_PROFILES[3],
|
|
|
+ CUSTOMER_PROFILES[0],
|
|
|
+ CUSTOMER_PROFILES[4],
|
|
|
+];
|
|
|
+
|
|
|
+const PRODUCT_LINES = ['A产品线', 'B产品线', 'C产品线'] as const;
|
|
|
+const REGIONS = ['华东', '华南', '华北'] as const;
|
|
|
+
|
|
|
+const PRODUCT_NAME_LIBRARY: Record<(typeof PRODUCT_LINES)[number], string[]> = {
|
|
|
+ A产品线: ['高压接线盒', '驱动总成支架', '控制器外壳', '一体化支撑件'],
|
|
|
+ B产品线: ['转向机总成', '减速器壳体', '冷却模块框架', '动力舱隔板'],
|
|
|
+ C产品线: ['模组支架', '电池箱横梁', '试制急单件', '项目定制骨架'],
|
|
|
+};
|
|
|
+
|
|
|
+const DESIGN_OWNER_POOL = ['张工', '李工', '王工', '赵工', '刘工', '陈工', '周工', '吴工'];
|
|
|
+
|
|
|
+const MATERIAL_CODE_BY_PRODUCT_LINE: Record<(typeof PRODUCT_LINES)[number], 'XX' | 'YY' | 'ZZ'> = {
|
|
|
+ A产品线: 'XX',
|
|
|
+ B产品线: 'YY',
|
|
|
+ C产品线: 'ZZ',
|
|
|
+};
|
|
|
+
|
|
|
+const SUPPLIER_GROUPS: Array<'A' | 'B' | 'C'> = ['A', 'B', 'C'];
|
|
|
+const PROCUREMENT_SPEC_CODES = ['L4.5*11.2', 'MT2*6.3', 'MT1*5.4'] as const;
|
|
|
+const MANUFACTURING_FACTORS: ExecutionManufacturingFactor[] = ['材料影响', '设备影响', '质量影响', '作业效率损失'];
|
|
|
+const MANUFACTURING_STEP_TEMPLATES = [
|
|
|
+ { code: '10' as const, name: '备料转序', plannedRatio: 0.15, operatorPool: ['周强', '李森'] },
|
|
|
+ { code: '20' as const, name: '高压线预装', plannedRatio: 0.25, operatorPool: ['王涛', '陈凯', '韩旭'] },
|
|
|
+ { code: '30' as const, name: '压接装配', plannedRatio: 0.25, operatorPool: ['赵峰', '刘杰', '彭勇'] },
|
|
|
+ { code: '40' as const, name: '耐压检测', plannedRatio: 0.2, operatorPool: ['孙宁', '吴珊'] },
|
|
|
+ { code: '50' as const, name: '终检转序', plannedRatio: 0.15, operatorPool: ['郑辉', '何亮'] },
|
|
|
+] as const;
|
|
|
+const MANUFACTURING_PROCESS_TEMPLATES: Record<ExecutionManufacturingFactor, Array<{ processName: string; issueTitle: string }>> = {
|
|
|
+ 材料影响: [
|
|
|
+ { processName: '高压线束预装', issueTitle: '关键线材待料补齐' },
|
|
|
+ { processName: '绝缘组件装配', issueTitle: '绝缘件到料窗口波动' },
|
|
|
+ { processName: '端子压接准备', issueTitle: '端子组件齐套延迟' },
|
|
|
+ ],
|
|
|
+ 设备影响: [
|
|
|
+ { processName: '压接工位', issueTitle: '压接设备换模等待' },
|
|
|
+ { processName: '耐压测试工位', issueTitle: '测试台停机恢复偏慢' },
|
|
|
+ { processName: '工装校准工位', issueTitle: '关键工装校准时长增加' },
|
|
|
+ ],
|
|
|
+ 质量影响: [
|
|
|
+ { processName: '绝缘复检工位', issueTitle: '绝缘项复检占用节拍' },
|
|
|
+ { processName: '端子复核工位', issueTitle: '压接质量复核返工' },
|
|
|
+ { processName: '耐压复测工位', issueTitle: '高压工序复测轮次增加' },
|
|
|
+ ],
|
|
|
+ 作业效率损失: [
|
|
|
+ { processName: '班组切换工位', issueTitle: '班组切换衔接损失' },
|
|
|
+ { processName: '节拍平衡工位', issueTitle: '高压工序节拍平衡不足' },
|
|
|
+ { processName: '工装准备工位', issueTitle: '工装准备与转序等待' },
|
|
|
+ ],
|
|
|
+};
|
|
|
+
|
|
|
+const buildManufacturingPlannedStepDays = (
|
|
|
+ seed: string,
|
|
|
+ family: FamilyTemplate,
|
|
|
+ productLine: (typeof PRODUCT_LINES)[number],
|
|
|
+ productName: string,
|
|
|
+) => {
|
|
|
+ const plannedWeights = MANUFACTURING_STEP_TEMPLATES.map((step, index) => {
|
|
|
+ let weight = step.plannedRatio * 10;
|
|
|
+
|
|
|
+ if (productLine === 'A产品线') {
|
|
|
+ if (step.code === '20') {
|
|
|
+ weight += 0.6;
|
|
|
+ }
|
|
|
+ if (step.code === '30') {
|
|
|
+ weight += 0.5;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (productLine === 'B产品线') {
|
|
|
+ if (step.code === '30') {
|
|
|
+ weight += 0.7;
|
|
|
+ }
|
|
|
+ if (step.code === '40') {
|
|
|
+ weight += 0.5;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (productLine === 'C产品线') {
|
|
|
+ if (step.code === '10') {
|
|
|
+ weight += 0.4;
|
|
|
+ }
|
|
|
+ if (step.code === '40') {
|
|
|
+ weight += 0.3;
|
|
|
+ }
|
|
|
+ if (step.code === '50') {
|
|
|
+ weight += 0.4;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (family.productType === '非标') {
|
|
|
+ if (step.code === '20' || step.code === '40') {
|
|
|
+ weight += 0.5;
|
|
|
+ }
|
|
|
+ if (step.code === '50') {
|
|
|
+ weight += 0.2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (family.manufacturingLoad === 'high' && step.code === '30') {
|
|
|
+ weight += 0.5;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (family.supplierRisk === 'high' && step.code === '10') {
|
|
|
+ weight += 0.5;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (productName.includes('高压') && step.code === '20') {
|
|
|
+ weight += 0.4;
|
|
|
+ }
|
|
|
+ if ((productName.includes('壳') || productName.includes('框架')) && step.code === '30') {
|
|
|
+ weight += 0.4;
|
|
|
+ }
|
|
|
+ if ((productName.includes('试制') || productName.includes('定制')) && step.code === '40') {
|
|
|
+ weight += 0.5;
|
|
|
+ }
|
|
|
+
|
|
|
+ weight += randomRange(seed, `manufacturing-planned-weight-${step.code}-${index}`, -0.12, 0.18);
|
|
|
+ return Number(Math.max(weight, 0.25).toFixed(2));
|
|
|
+ });
|
|
|
+
|
|
|
+ return allocateValueByWeights(KPI_DAYS.body_production, plannedWeights);
|
|
|
+};
|
|
|
+
|
|
|
+const buildParallelOperatorActualDays = (
|
|
|
+ seed: string,
|
|
|
+ stepCode: ExecutionManufacturingStepDetail['code'],
|
|
|
+ stepActualDays: number,
|
|
|
+ weights: number[],
|
|
|
+) => {
|
|
|
+ if (weights.length === 0 || stepActualDays <= 0) {
|
|
|
+ return weights.map(() => 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ const maxWeight = Math.max(...weights);
|
|
|
+ const leadOperatorIndex = weights.findIndex((weight) => weight === maxWeight);
|
|
|
+
|
|
|
+ return weights.map((weight, operatorIndex) => {
|
|
|
+ if (operatorIndex === leadOperatorIndex) {
|
|
|
+ return Number(stepActualDays.toFixed(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ const normalizedWeight = maxWeight > 0 ? weight / maxWeight : 0.5;
|
|
|
+ const ratio = Math.min(
|
|
|
+ 0.96,
|
|
|
+ Math.max(
|
|
|
+ 0.58,
|
|
|
+ 0.58 + normalizedWeight * 0.22 + randomRange(seed, `${stepCode}-parallel-operator-${operatorIndex}`, -0.06, 0.1),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ return Number((stepActualDays * ratio).toFixed(1));
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const REVIEW_SUBSTEP_BASELINES = [
|
|
|
+ { name: '意见评审', piHours: 8 },
|
|
|
+ { name: '意见反馈', piHours: 12 },
|
|
|
+ { name: '二次评审', piHours: 8 },
|
|
|
+ { name: '领导意见', piHours: 10 },
|
|
|
+ { name: '合同盖章', piHours: 2 },
|
|
|
+] as const;
|
|
|
+
|
|
|
+const OPINION_REVIEW_DEPARTMENT_BASELINES = [
|
|
|
+ { name: '法律事务部', piHours: 2 },
|
|
|
+ { name: '技术售前组', piHours: 3 },
|
|
|
+ { name: '综合主计划', piHours: 1 },
|
|
|
+ { name: '试验站', piHours: 2 },
|
|
|
+] as const;
|
|
|
+
|
|
|
+const OPINION_REVIEW_PI_HOURS = OPINION_REVIEW_DEPARTMENT_BASELINES.reduce((sum, item) => sum + item.piHours, 0);
|
|
|
+
|
|
|
+const EXCEPTION_TEMPLATES: Record<string, ExceptionTemplate> = {
|
|
|
+ E01: { category: 'T', title: '资料缺失复核', nodeKey: 'order_review', waitRange: [40, 180], responseRange: [20, 90], handleRange: [60, 360] },
|
|
|
+ E02: { category: 'T', title: '非标图纸返工', nodeKey: 'product_design', waitRange: [120, 480], responseRange: [30, 120], handleRange: [240, 1440] },
|
|
|
+ E03: { category: 'R', title: '设计资源排队', nodeKey: 'product_design', waitRange: [60, 240], responseRange: [30, 180], handleRange: [180, 960] },
|
|
|
+ E04: { category: 'T', title: '供应商交期延迟', nodeKey: 'material_procurement', waitRange: [180, 720], responseRange: [30, 240], handleRange: [480, 2880] },
|
|
|
+ E05: { category: 'V', title: '来料规格不符', nodeKey: 'material_procurement', waitRange: [120, 480], responseRange: [20, 120], handleRange: [240, 1440] },
|
|
|
+ E06: { category: 'R', title: '备选料切换', nodeKey: 'material_procurement', waitRange: [60, 300], responseRange: [15, 90], handleRange: [180, 720] },
|
|
|
+ E07: { category: 'R', title: '排产切换等待', nodeKey: 'body_production', waitRange: [120, 420], responseRange: [20, 120], handleRange: [240, 960] },
|
|
|
+ E08: { category: 'V', title: '设备停机故障', nodeKey: 'body_production', waitRange: [120, 480], responseRange: [10, 60], handleRange: [120, 1440] },
|
|
|
+ E09: { category: 'T', title: '质量返工', nodeKey: 'body_production', waitRange: [90, 420], responseRange: [20, 90], handleRange: [240, 1200] },
|
|
|
+ E10: { category: 'R', title: '发货窗口调整', nodeKey: 'final_assembly_shipping', waitRange: [30, 120], responseRange: [10, 60], handleRange: [60, 360] },
|
|
|
+};
|
|
|
+
|
|
|
+const FAMILY_TEMPLATES: FamilyTemplate[] = [
|
|
|
+ { id: 'C01', count: 20, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2', 'P3'], supplierRisk: 'low', manufacturingLoad: 'low', focusNodeKey: 'final_assembly_shipping', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-2.5, 1.3], product_design: [-0.9, -0.1], material_procurement: [-4, -1], body_production: [-1.5, -0.3], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E06'], minExceptions: 0, maxExceptions: 1 },
|
|
|
+ { id: 'C02', count: 10, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'low', focusNodeKey: 'material_procurement', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-2.0, -0.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.8], body_production: [-1.3, -0.3], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E04'], minExceptions: 1, maxExceptions: 1 },
|
|
|
+ { id: 'C03', count: 8, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'body_production', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.5, 0.5], product_design: [-0.9, -0.1], material_procurement: [-4, 0.1], body_production: [-1, 0.15], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E07'], minExceptions: 1, maxExceptions: 1 },
|
|
|
+ { id: 'C04', count: 8, workflowStatus: 'completed', productType: '非标', priorityPool: ['P1', 'P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'product_design', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-0.3, 3.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.2, -0.1], final_assembly_shipping: [-0.7, -0.1] }, exceptionTemplateIds: ['E02', 'E03'], minExceptions: 1, maxExceptions: 2 },
|
|
|
+ { id: 'C05', count: 6, workflowStatus: 'completed', productType: '非标', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'low', focusNodeKey: 'product_design', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [3.0, 7.0], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.5, -0.2], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E02'], minExceptions: 1, maxExceptions: 1 },
|
|
|
+ { id: 'C06', count: 8, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'high', manufacturingLoad: 'medium', focusNodeKey: 'material_procurement', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.5, 0.3], product_design: [-0.9, -0.1], material_procurement: [15, 27], body_production: [-1.3, -0.1], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E04', 'E05'], minExceptions: 1, maxExceptions: 2 },
|
|
|
+ { id: 'C07', count: 5, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2', 'P3'], supplierRisk: 'high', manufacturingLoad: 'medium', focusNodeKey: 'material_procurement', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-0.5, 1.8], product_design: [-0.6, 0.3], material_procurement: [25.3, 44.2], body_production: [9, 21], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E04', 'E06'], minExceptions: 2, maxExceptions: 2 },
|
|
|
+ { id: 'C08', count: 6, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'body_production', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.8, 0.2], product_design: [-0.9, -0.1], material_procurement: [-4, -0.5], body_production: [-0.9, 0.1], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E07'], minExceptions: 1, maxExceptions: 1 },
|
|
|
+ { id: 'C09', count: 3, workflowStatus: 'completed', productType: '常规', priorityPool: ['P1'], supplierRisk: 'medium', manufacturingLoad: 'high', focusNodeKey: 'body_production', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.2, 0.8], product_design: [-0.8, 0.1], material_procurement: [-4, -0.3], body_production: [17, 29], final_assembly_shipping: [4, 12] }, exceptionTemplateIds: ['E08', 'E09'], minExceptions: 2, maxExceptions: 2 },
|
|
|
+ { id: 'C10', count: 6, workflowStatus: 'completed', productType: '常规', priorityPool: ['P2'], supplierRisk: 'low', manufacturingLoad: 'low', focusNodeKey: 'order_review', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [9, 16], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.3, -0.1], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E01'], minExceptions: 1, maxExceptions: 1 },
|
|
|
+ { id: 'C11', count: 5, workflowStatus: 'completed', productType: '非标', priorityPool: ['P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'product_design', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [8, 15], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.2, -0.1], final_assembly_shipping: [-0.6, -0.1] }, exceptionTemplateIds: ['E01', 'E02'], minExceptions: 2, maxExceptions: 2 },
|
|
|
+ { id: 'C12', count: 10, workflowStatus: 'completed', productType: '常规', priorityPool: ['P1', 'P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'final_assembly_shipping', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-1.5, 0.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.3], body_production: [-1.5, -0.1], final_assembly_shipping: [-0.8, -0.1] }, exceptionTemplateIds: ['E04', 'E07'], minExceptions: 1, maxExceptions: 2 },
|
|
|
+ { id: 'I01', count: 2, workflowStatus: 'in_progress', productType: '常规', priorityPool: ['P2'], supplierRisk: 'low', manufacturingLoad: 'low', focusNodeKey: 'order_review', currentNodeKey: 'order_review', offsets: { order_review: [0, 2.5], product_design: [0, 0], material_procurement: [0, 0], body_production: [0, 0], final_assembly_shipping: [0, 0] }, exceptionTemplateIds: ['E01'], minExceptions: 1, maxExceptions: 1 },
|
|
|
+ { id: 'I02', count: 2, workflowStatus: 'in_progress', productType: '非标', priorityPool: ['P1', 'P2'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'product_design', currentNodeKey: 'product_design', offsets: { order_review: [-2.0, 0.5], product_design: [1.1, 5.8], material_procurement: [0, 0], body_production: [0, 0], final_assembly_shipping: [-0.8, 0] }, exceptionTemplateIds: ['E02', 'E03'], minExceptions: 1, maxExceptions: 2 },
|
|
|
+ { id: 'I03', count: 3, workflowStatus: 'in_progress', productType: '常规', priorityPool: ['P2'], supplierRisk: 'high', manufacturingLoad: 'medium', focusNodeKey: 'material_procurement', currentNodeKey: 'material_procurement', offsets: { order_review: [-2.0, 0.5], product_design: [-0.9, -0.1], material_procurement: [1.8, 7.2], body_production: [0, 0], final_assembly_shipping: [0, 0] }, exceptionTemplateIds: ['E04', 'E05'], minExceptions: 1, maxExceptions: 2 },
|
|
|
+ { id: 'I04', count: 2, workflowStatus: 'in_progress', productType: '常规', priorityPool: ['P1', 'P2'], supplierRisk: 'medium', manufacturingLoad: 'high', focusNodeKey: 'body_production', currentNodeKey: 'body_production', offsets: { order_review: [-2.0, 0.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.5], body_production: [1.2, 4.9], final_assembly_shipping: [-0.8, 0] }, exceptionTemplateIds: ['E07', 'E08'], minExceptions: 1, maxExceptions: 2 },
|
|
|
+ { id: 'I05', count: 1, workflowStatus: 'in_progress', productType: '常规', priorityPool: ['P1'], supplierRisk: 'medium', manufacturingLoad: 'medium', focusNodeKey: 'final_assembly_shipping', currentNodeKey: 'final_assembly_shipping', offsets: { order_review: [-2.0, 0.5], product_design: [-0.9, -0.1], material_procurement: [-4, -0.5], body_production: [-1.3, 0.3], final_assembly_shipping: [0.0, 2.0] }, exceptionTemplateIds: ['E10'], minExceptions: 1, maxExceptions: 1 },
|
|
|
+];
|
|
|
+
|
|
|
+const hashSeed = (value: string): number => value.split('').reduce((seed, char) => ((seed * 31) + char.charCodeAt(0)) >>> 0, 19);
|
|
|
+
|
|
|
+const randomUnit = (seed: string, salt: string): number => (hashSeed(`${seed}:${salt}`) % 10000) / 10000;
|
|
|
+
|
|
|
+const randomInt = (seed: string, salt: string, min: number, max: number): number =>
|
|
|
+ Math.floor(min + randomUnit(seed, salt) * (max - min + 1));
|
|
|
+
|
|
|
+const randomRange = (seed: string, salt: string, min: number, max: number): number =>
|
|
|
+ min + randomUnit(seed, salt) * (max - min);
|
|
|
+
|
|
|
+const toMinutes = (days: number): number => Math.round(days * DAY_MINUTES);
|
|
|
+
|
|
|
+const addMinutes = (base: Date, minutes: number): Date => {
|
|
|
+ const next = new Date(base);
|
|
|
+ next.setMinutes(next.getMinutes() + minutes);
|
|
|
+ return next;
|
|
|
+};
|
|
|
+
|
|
|
+const formatMonthDay = (date: Date): string => {
|
|
|
+ const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
|
+ const day = String(date.getDate()).padStart(2, '0');
|
|
|
+ return `${month}-${day}`;
|
|
|
+};
|
|
|
+
|
|
|
+const formatMonthDayTime = (date: Date): string => {
|
|
|
+ const hour = String(date.getHours()).padStart(2, '0');
|
|
|
+ const minute = String(date.getMinutes()).padStart(2, '0');
|
|
|
+ return `${formatMonthDay(date)} ${hour}:${minute}`;
|
|
|
+};
|
|
|
+
|
|
|
+const parseMonthDayTime = (value: string | null): Date | null => {
|
|
|
+ if (!value) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ const match = value.match(/^(\d{2})-(\d{2}) (\d{2}):(\d{2})$/);
|
|
|
+ if (!match) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ const [, month, day, hour, minute] = match;
|
|
|
+ return new Date(2026, Number(month) - 1, Number(day), Number(hour), Number(minute), 0, 0);
|
|
|
+};
|
|
|
+
|
|
|
+const getStageStatus = (actualDays: number, plannedDays: number): 'green' | 'yellow' | 'red' => {
|
|
|
+ if (actualDays <= plannedDays) {
|
|
|
+ return 'green';
|
|
|
+ }
|
|
|
+ return (actualDays - plannedDays) / plannedDays <= 0.2 ? 'yellow' : 'red';
|
|
|
+};
|
|
|
+
|
|
|
+const buildExceptionEvent = (seed: string, orderId: string, index: number, templateId: string): ExceptionEvent => {
|
|
|
+ const template = EXCEPTION_TEMPLATES[templateId];
|
|
|
+ const waitMinutes = randomInt(seed, `${templateId}-${index}-wait`, template.waitRange[0], template.waitRange[1]);
|
|
|
+ const responseMinutes = randomInt(seed, `${templateId}-${index}-response`, template.responseRange[0], template.responseRange[1]);
|
|
|
+ const handleMinutes = randomInt(seed, `${templateId}-${index}-handle`, template.handleRange[0], template.handleRange[1]);
|
|
|
+ const totalMinutes = waitMinutes + responseMinutes + handleMinutes;
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: `EX-${orderId.slice(-3)}-${String(index + 1).padStart(2, '0')}`,
|
|
|
+ category: template.category,
|
|
|
+ title: template.title,
|
|
|
+ waitMinutes,
|
|
|
+ responseMinutes,
|
|
|
+ handleMinutes,
|
|
|
+ lossDays: Number((totalMinutes / DAY_MINUTES).toFixed(1)),
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const roundHourListToTotal = (values: number[], totalHours: number): number[] => {
|
|
|
+ if (values.length === 0) {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ const rounded = values.map((value) => Number(Math.max(value, 0).toFixed(1)));
|
|
|
+ const currentTotal = rounded.reduce((sum, value) => sum + value, 0);
|
|
|
+ const diff = Number((totalHours - currentTotal).toFixed(1));
|
|
|
+ rounded[rounded.length - 1] = Number(Math.max(rounded[rounded.length - 1] + diff, 0).toFixed(1));
|
|
|
+ return rounded;
|
|
|
+};
|
|
|
+
|
|
|
+const allocateValueByWeights = (totalValue: number, weights: number[]): number[] => {
|
|
|
+ if (weights.length === 0 || totalValue <= 0) {
|
|
|
+ return weights.map(() => 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ const normalizedWeights = weights.map((value) => Math.max(value, 0.1));
|
|
|
+ const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0);
|
|
|
+ return roundHourListToTotal(
|
|
|
+ normalizedWeights.map((value) => (totalValue * value) / weightTotal),
|
|
|
+ totalValue,
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const allocateCountByWeights = (totalCount: number, weights: number[], minEach = 0): number[] => {
|
|
|
+ if (weights.length === 0 || totalCount <= 0) {
|
|
|
+ return weights.map(() => 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ const guaranteed = Math.min(minEach * weights.length, totalCount);
|
|
|
+ const distributable = totalCount - guaranteed;
|
|
|
+ const normalizedWeights = weights.map((value) => Math.max(value, 0.1));
|
|
|
+ const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0);
|
|
|
+ const rawCounts = normalizedWeights.map((value) => (distributable * value) / weightTotal);
|
|
|
+ const baseCounts = rawCounts.map((value) => Math.floor(value));
|
|
|
+ let remainder = distributable - baseCounts.reduce((sum, value) => sum + value, 0);
|
|
|
+
|
|
|
+ const fractions = rawCounts
|
|
|
+ .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
|
|
|
+ .sort((left, right) => right.fraction - left.fraction);
|
|
|
+
|
|
|
+ for (let index = 0; index < fractions.length && remainder > 0; index += 1) {
|
|
|
+ baseCounts[fractions[index].index] += 1;
|
|
|
+ remainder -= 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ return baseCounts.map((value) => value + (guaranteed >= weights.length * minEach ? minEach : 0));
|
|
|
+};
|
|
|
+
|
|
|
+const allocateHoursByWeights = (totalHours: number, weights: number[]): number[] => {
|
|
|
+ if (weights.length === 0 || totalHours <= 0) {
|
|
|
+ return weights.map(() => 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ const normalizedWeights = weights.map((value) => Math.max(value, 0.1));
|
|
|
+ const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0);
|
|
|
+ return roundHourListToTotal(
|
|
|
+ normalizedWeights.map((value) => (totalHours * value) / weightTotal),
|
|
|
+ totalHours,
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const getHourStatus = (actualHours: number, piHours: number): ExecutionSubStepDetail['status'] => {
|
|
|
+ if (actualHours <= piHours) {
|
|
|
+ return 'green';
|
|
|
+ }
|
|
|
+ return (actualHours - piHours) / piHours <= 0.2 ? 'yellow' : 'red';
|
|
|
+};
|
|
|
+
|
|
|
+const allocateHoursByPi = (totalHours: number, piHours: number[], extraWeights: number[]): number[] => {
|
|
|
+ if (totalHours <= 0) {
|
|
|
+ return piHours.map(() => 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ const totalPi = piHours.reduce((sum, value) => sum + value, 0);
|
|
|
+ if (totalHours <= totalPi) {
|
|
|
+ const ratio = totalHours / totalPi;
|
|
|
+ return roundHourListToTotal(piHours.map((value) => value * ratio), totalHours);
|
|
|
+ }
|
|
|
+
|
|
|
+ const extraHours = totalHours - totalPi;
|
|
|
+ const normalizedWeights = extraWeights.map((value, index) => Math.max(value, piHours[index] * 0.4, 0.1));
|
|
|
+ const weightTotal = normalizedWeights.reduce((sum, value) => sum + value, 0);
|
|
|
+ return roundHourListToTotal(
|
|
|
+ piHours.map((value, index) => value + (extraHours * normalizedWeights[index]) / weightTotal),
|
|
|
+ totalHours,
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const consumeSequentialHours = (
|
|
|
+ templates: ReadonlyArray<{ name: string; piHours: number }>,
|
|
|
+ finalHours: number[],
|
|
|
+ consumedHours: number,
|
|
|
+): ExecutionSubStepDetail[] => {
|
|
|
+ let remaining = Math.max(consumedHours, 0);
|
|
|
+
|
|
|
+ return templates.map((template, index) => {
|
|
|
+ if (remaining <= 0.05) {
|
|
|
+ return {
|
|
|
+ name: template.name,
|
|
|
+ piHours: template.piHours,
|
|
|
+ actualHours: null,
|
|
|
+ status: 'pending',
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ const usedHours = Number(Math.min(finalHours[index], remaining).toFixed(1));
|
|
|
+ remaining = Number(Math.max(remaining - usedHours, 0).toFixed(1));
|
|
|
+
|
|
|
+ return {
|
|
|
+ name: template.name,
|
|
|
+ piHours: template.piHours,
|
|
|
+ actualHours: usedHours,
|
|
|
+ status: getHourStatus(usedHours, template.piHours),
|
|
|
+ };
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const getElapsedBusinessHours = (startAt: Date | null, endAt: Date | null): number => {
|
|
|
+ if (!startAt || !endAt) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ const elapsedDays = (endAt.getTime() - startAt.getTime()) / (DAY_MINUTES * 60 * 1000);
|
|
|
+ return Number(Math.max(elapsedDays * 8, 0).toFixed(1));
|
|
|
+};
|
|
|
+
|
|
|
+const getElapsedBusinessDays = (startAt: Date | null, endAt: Date | null): number => {
|
|
|
+ return Number((getElapsedBusinessHours(startAt, endAt) / 8).toFixed(1));
|
|
|
+};
|
|
|
+
|
|
|
+const getReviewStepDelayWeights = (
|
|
|
+ seed: string,
|
|
|
+ family: FamilyTemplate,
|
|
|
+ priority: 'P1' | 'P2' | 'P3',
|
|
|
+ reviewExceptionCount: number,
|
|
|
+): number[] => {
|
|
|
+ const weights = REVIEW_SUBSTEP_BASELINES.map((step, index) => {
|
|
|
+ let weight = step.piHours;
|
|
|
+
|
|
|
+ if (step.name === '意见评审') {
|
|
|
+ weight += reviewExceptionCount * 1.2;
|
|
|
+ if (family.productType === '非标') {
|
|
|
+ weight += 1.6;
|
|
|
+ }
|
|
|
+ if (family.focusNodeKey === 'order_review') {
|
|
|
+ weight += 1.1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (step.name === '意见反馈') {
|
|
|
+ weight += reviewExceptionCount * 0.9;
|
|
|
+ if (family.productType === '非标') {
|
|
|
+ weight += 0.8;
|
|
|
+ }
|
|
|
+ if (family.focusNodeKey === 'order_review') {
|
|
|
+ weight += 0.4;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (step.name === '二次评审') {
|
|
|
+ if (family.productType === '非标') {
|
|
|
+ weight += 1.3;
|
|
|
+ }
|
|
|
+ if (reviewExceptionCount > 0) {
|
|
|
+ weight += 0.5;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (step.name === '领导意见') {
|
|
|
+ if (priority === 'P1') {
|
|
|
+ weight += 1.6;
|
|
|
+ }
|
|
|
+ if (family.focusNodeKey === 'order_review') {
|
|
|
+ weight += 0.7;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (step.name === '合同盖章') {
|
|
|
+ if (priority === 'P1') {
|
|
|
+ weight += 0.2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ weight += randomRange(seed, `review-step-weight-${index}`, -0.2, 0.25);
|
|
|
+ return Number(Math.max(weight, 0.3).toFixed(2));
|
|
|
+ });
|
|
|
+
|
|
|
+ return weights;
|
|
|
+};
|
|
|
+
|
|
|
+const getOpinionDepartmentDelayWeights = (
|
|
|
+ seed: string,
|
|
|
+ family: FamilyTemplate,
|
|
|
+ priority: 'P1' | 'P2' | 'P3',
|
|
|
+ reviewExceptionCount: number,
|
|
|
+): number[] => {
|
|
|
+ return OPINION_REVIEW_DEPARTMENT_BASELINES.map((department, index) => {
|
|
|
+ let weight = department.piHours;
|
|
|
+
|
|
|
+ if (department.name === '法律事务部') {
|
|
|
+ weight += reviewExceptionCount * 0.3;
|
|
|
+ if (priority === 'P1') {
|
|
|
+ weight += 0.2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (department.name === '技术售前组') {
|
|
|
+ if (family.productType === '非标') {
|
|
|
+ weight += 1.9;
|
|
|
+ }
|
|
|
+ if (family.focusNodeKey === 'order_review' || family.focusNodeKey === 'product_design') {
|
|
|
+ weight += 0.6;
|
|
|
+ }
|
|
|
+ weight += reviewExceptionCount * 0.5;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (department.name === '综合主计划') {
|
|
|
+ if (priority === 'P1') {
|
|
|
+ weight += 1.1;
|
|
|
+ }
|
|
|
+ if (family.supplierRisk === 'high') {
|
|
|
+ weight += 0.3;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (department.name === '试验站') {
|
|
|
+ if (family.productType === '非标') {
|
|
|
+ weight += 0.7;
|
|
|
+ }
|
|
|
+ if (family.manufacturingLoad === 'high') {
|
|
|
+ weight += 0.2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ weight += randomRange(seed, `opinion-department-weight-${index}`, -0.15, 0.2);
|
|
|
+ return Number(Math.max(weight, 0.2).toFixed(2));
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const buildOrderReviewSubSteps = (
|
|
|
+ seed: string,
|
|
|
+ family: FamilyTemplate,
|
|
|
+ priority: 'P1' | 'P2' | 'P3',
|
|
|
+ finalReviewDays: number,
|
|
|
+ consumedReviewDays: number,
|
|
|
+ reviewExceptions: ExceptionEvent[],
|
|
|
+): ExecutionSubStepDetail[] => {
|
|
|
+ const finalReviewHours = Number(Math.max(finalReviewDays * 8, 0).toFixed(1));
|
|
|
+ const consumedReviewHours = Number(Math.min(Math.max(consumedReviewDays * 8, 0), finalReviewHours).toFixed(1));
|
|
|
+ const reviewWeights = getReviewStepDelayWeights(seed, family, priority, reviewExceptions.length);
|
|
|
+ const finalStepHours = allocateHoursByPi(
|
|
|
+ finalReviewHours,
|
|
|
+ REVIEW_SUBSTEP_BASELINES.map((item) => item.piHours),
|
|
|
+ reviewWeights,
|
|
|
+ );
|
|
|
+ const displayedSteps = consumeSequentialHours(REVIEW_SUBSTEP_BASELINES, finalStepHours, consumedReviewHours);
|
|
|
+ const opinionStepIndex = REVIEW_SUBSTEP_BASELINES.findIndex((item) => item.name === '意见评审');
|
|
|
+ const opinionFinalHours = finalStepHours[opinionStepIndex] ?? OPINION_REVIEW_PI_HOURS;
|
|
|
+ const opinionConsumedHours = displayedSteps[opinionStepIndex]?.actualHours ?? 0;
|
|
|
+ const opinionDepartmentWeights = getOpinionDepartmentDelayWeights(seed, family, priority, reviewExceptions.length);
|
|
|
+ const finalOpinionHours = allocateHoursByPi(
|
|
|
+ opinionFinalHours,
|
|
|
+ OPINION_REVIEW_DEPARTMENT_BASELINES.map((item) => item.piHours),
|
|
|
+ opinionDepartmentWeights,
|
|
|
+ );
|
|
|
+ const displayedOpinionDetails = consumeSequentialHours(
|
|
|
+ OPINION_REVIEW_DEPARTMENT_BASELINES,
|
|
|
+ finalOpinionHours,
|
|
|
+ opinionConsumedHours,
|
|
|
+ );
|
|
|
+
|
|
|
+ return displayedSteps.map((step) => (
|
|
|
+ step.name === '意见评审'
|
|
|
+ ? { ...step, children: displayedOpinionDetails }
|
|
|
+ : step
|
|
|
+ ));
|
|
|
+};
|
|
|
+
|
|
|
+const buildProductDesignTasks = (
|
|
|
+ seed: string,
|
|
|
+ orderId: string,
|
|
|
+ family: FamilyTemplate,
|
|
|
+ nodeActualDays: number,
|
|
|
+ nodeStatus: ExecutionRawNodeDetail['status'],
|
|
|
+ plannedStartAt: Date,
|
|
|
+ plannedEndAt: Date,
|
|
|
+ actualStartAt: Date | null,
|
|
|
+ actualEndAt: Date | null,
|
|
|
+ designExceptions: ExceptionEvent[],
|
|
|
+): ExecutionDesignTaskDetail[] => {
|
|
|
+ const category: ExecutionDesignTaskDetail['category'] = family.productType === '非标' ? '非标产品' : '常规产品';
|
|
|
+ const taskCountBase = family.productType === '非标'
|
|
|
+ ? randomInt(seed, 'design-task-count', 2, 4)
|
|
|
+ : randomInt(seed, 'design-task-count', 1, 2);
|
|
|
+ const taskCount = family.productType === '非标' && family.focusNodeKey === 'product_design'
|
|
|
+ ? Math.min(taskCountBase + 1, 4)
|
|
|
+ : taskCountBase;
|
|
|
+ const effectiveActualEndAt = actualEndAt ?? (actualStartAt ? OBSERVED_AT : null);
|
|
|
+ const taskKpiStartAt = actualStartAt ?? plannedStartAt;
|
|
|
+ const taskExpectedFinishAt = actualStartAt
|
|
|
+ ? addMinutes(actualStartAt, toMinutes(KPI_DAYS.product_design))
|
|
|
+ : plannedEndAt;
|
|
|
+ const criticalIndex = taskCount === 1
|
|
|
+ ? 0
|
|
|
+ : ((family.productType === '非标' || nodeStatus !== 'green' || designExceptions.length > 0)
|
|
|
+ ? taskCount - 1
|
|
|
+ : hashSeed(`${orderId}-design-critical`) % taskCount);
|
|
|
+ const secondaryRiskIndex = taskCount > 2 && family.productType === '非标'
|
|
|
+ ? Math.max(criticalIndex - 1, 0)
|
|
|
+ : -1;
|
|
|
+ const elapsedDays = actualStartAt && effectiveActualEndAt
|
|
|
+ ? Number(((effectiveActualEndAt.getTime() - actualStartAt.getTime()) / (DAY_MINUTES * 60 * 1000)).toFixed(1))
|
|
|
+ : 0;
|
|
|
+ const projectedDaysByTask = Array.from({ length: taskCount }, (_, index) => {
|
|
|
+ if (index === criticalIndex) {
|
|
|
+ return Number(Math.max(nodeActualDays, 0.4).toFixed(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ const baseMax = family.productType === '非标' ? 3.2 : 2.7;
|
|
|
+ const upperBound = Math.max(Math.min(nodeActualDays - 0.2, baseMax), 0.6);
|
|
|
+ let projected = randomRange(seed, `design-task-${index}-projected`, 0.8, upperBound);
|
|
|
+
|
|
|
+ if (index === secondaryRiskIndex && nodeActualDays > KPI_DAYS.product_design + 0.8) {
|
|
|
+ projected = randomRange(
|
|
|
+ seed,
|
|
|
+ `design-task-${index}-secondary-risk`,
|
|
|
+ 2.6,
|
|
|
+ Math.max(Math.min(nodeActualDays - 0.1, 4.6), 2.8),
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ return Number(Math.max(Math.min(projected, Math.max(nodeActualDays - 0.1, 0.8)), 0.4).toFixed(1));
|
|
|
+ });
|
|
|
+
|
|
|
+ const tasks = Array.from({ length: taskCount }, (_, index) => {
|
|
|
+ const prepareLeadHours = randomRange(
|
|
|
+ seed,
|
|
|
+ `design-task-${index}-prepare-lead`,
|
|
|
+ family.productType === '非标' ? 4 : 2,
|
|
|
+ family.productType === '非标' ? 22 : 10,
|
|
|
+ );
|
|
|
+ const prepareStartAt = addMinutes(taskKpiStartAt, -Math.round(prepareLeadHours * 60));
|
|
|
+ const projectedDays = projectedDaysByTask[index];
|
|
|
+ const projectedFinishAt = addMinutes(taskKpiStartAt, toMinutes(projectedDays));
|
|
|
+
|
|
|
+ let actualDays: number | null = null;
|
|
|
+ let actualFinishDate: string | null = null;
|
|
|
+ let status: ExecutionDesignTaskDetail['status'] = 'pending';
|
|
|
+
|
|
|
+ if (nodeStatus !== 'pending' && actualStartAt && effectiveActualEndAt) {
|
|
|
+ const projectedStatus = getStageStatus(projectedDays, KPI_DAYS.product_design);
|
|
|
+
|
|
|
+ if (projectedFinishAt.getTime() <= effectiveActualEndAt.getTime()) {
|
|
|
+ actualDays = projectedDays;
|
|
|
+ actualFinishDate = formatMonthDayTime(projectedFinishAt);
|
|
|
+ status = projectedStatus;
|
|
|
+ } else {
|
|
|
+ actualDays = Number(Math.max(elapsedDays, 0).toFixed(1));
|
|
|
+ actualFinishDate = null;
|
|
|
+ status = projectedStatus;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: `${orderId.replace('ORD-2026-', 'A')}-${String(index + 1).padStart(2, '0')}`,
|
|
|
+ owner: DESIGN_OWNER_POOL[hashSeed(`${orderId}-design-owner-${index}`) % DESIGN_OWNER_POOL.length],
|
|
|
+ category,
|
|
|
+ prepareStartDate: formatMonthDayTime(prepareStartAt),
|
|
|
+ kpiStartDate: formatMonthDayTime(taskKpiStartAt),
|
|
|
+ expectedFinishDate: formatMonthDayTime(taskExpectedFinishAt),
|
|
|
+ actualFinishDate,
|
|
|
+ actualDays,
|
|
|
+ projectedDays: status === 'pending' ? null : projectedDays,
|
|
|
+ status,
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ return tasks.sort((left, right) => hashSeed(left.id) - hashSeed(right.id));
|
|
|
+};
|
|
|
+
|
|
|
+const buildProcurementFacts = (
|
|
|
+ seed: string,
|
|
|
+ materialCode: 'XX' | 'YY' | 'ZZ',
|
|
|
+ supplierGroup: 'A' | 'B' | 'C',
|
|
|
+ nodeActualDays: number,
|
|
|
+ nodeStatus: ExecutionRawNodeDetail['status'],
|
|
|
+ actualStartAt: Date | null,
|
|
|
+ actualEndAt: Date | null,
|
|
|
+): ExecutionProcurementFact[] => {
|
|
|
+ const materials: Array<'XX' | 'YY' | 'ZZ'> = ['XX', 'YY', 'ZZ'];
|
|
|
+ const suppliers: Array<'A' | 'B' | 'C'> = ['A', 'B', 'C'];
|
|
|
+ const specs = [...PROCUREMENT_SPEC_CODES];
|
|
|
+ const anchoredMaterialBias: Record<'XX' | 'YY' | 'ZZ', number> = {
|
|
|
+ XX: materialCode === 'XX' ? 0.45 : -0.15,
|
|
|
+ YY: materialCode === 'YY' ? 0.5 : -0.1,
|
|
|
+ ZZ: materialCode === 'ZZ' ? 0.35 : -0.2,
|
|
|
+ };
|
|
|
+ const anchoredSupplierBias: Record<'A' | 'B' | 'C', number> = {
|
|
|
+ A: supplierGroup === 'A' ? 0.15 : -0.2,
|
|
|
+ B: supplierGroup === 'B' ? 0.35 : 0.05,
|
|
|
+ C: supplierGroup === 'C' ? 0.4 : 0.1,
|
|
|
+ };
|
|
|
+ const specBias: Record<typeof PROCUREMENT_SPEC_CODES[number], number> = {
|
|
|
+ 'L4.5*11.2': -0.2,
|
|
|
+ 'MT2*6.3': 0.55,
|
|
|
+ 'MT1*5.4': 0.2,
|
|
|
+ };
|
|
|
+
|
|
|
+ const rawFacts = materials.flatMap((material) => (
|
|
|
+ suppliers.flatMap((supplier) => (
|
|
|
+ specs.map((spec) => {
|
|
|
+ let rawCycle = nodeActualDays
|
|
|
+ + anchoredMaterialBias[material]
|
|
|
+ + anchoredSupplierBias[supplier]
|
|
|
+ + specBias[spec]
|
|
|
+ + randomRange(seed, `procurement-${material}-${supplier}-${spec}-noise`, -0.35, 0.35);
|
|
|
+
|
|
|
+ if (supplier === 'B' && (spec === 'MT2*6.3' || spec === 'MT1*5.4')) {
|
|
|
+ rawCycle += 0.35;
|
|
|
+ }
|
|
|
+ if (supplier === 'C' && (spec === 'MT2*6.3' || spec === 'MT1*5.4')) {
|
|
|
+ rawCycle += 0.75;
|
|
|
+ }
|
|
|
+ if (material === 'YY') {
|
|
|
+ rawCycle += 0.2;
|
|
|
+ }
|
|
|
+ if (material === 'ZZ' && spec === 'L4.5*11.2') {
|
|
|
+ rawCycle -= 0.15;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ materialCode: material,
|
|
|
+ supplierGroup: supplier,
|
|
|
+ specCode: spec,
|
|
|
+ rawCycle,
|
|
|
+ };
|
|
|
+ })
|
|
|
+ ))
|
|
|
+ ));
|
|
|
+
|
|
|
+ const averageRawCycle = rawFacts.reduce((sum, fact) => sum + fact.rawCycle, 0) / rawFacts.length;
|
|
|
+ const normalizationDelta = nodeActualDays - averageRawCycle;
|
|
|
+ const kpiStartDate = actualStartAt ? formatMonthDayTime(actualStartAt) : null;
|
|
|
+ const expectedFinishDate = actualStartAt ? formatMonthDayTime(addMinutes(actualStartAt, toMinutes(KPI_DAYS.material_procurement))) : null;
|
|
|
+
|
|
|
+ return rawFacts.map((fact, index) => {
|
|
|
+ const cycleDays = Number(Math.max(fact.rawCycle + normalizationDelta, 8.5).toFixed(1));
|
|
|
+ const impactedUnits = randomInt(seed, `procurement-impact-${fact.materialCode}-${fact.supplierGroup}-${fact.specCode}-${index}`, 1, 3);
|
|
|
+ const actualFinishDate = actualEndAt
|
|
|
+ ? formatMonthDayTime(addMinutes(actualEndAt, Math.round((cycleDays - nodeActualDays) * DAY_MINUTES)))
|
|
|
+ : null;
|
|
|
+ const isOnTimeKit = actualEndAt ? cycleDays <= KPI_DAYS.material_procurement : null;
|
|
|
+ const status = nodeStatus === 'pending'
|
|
|
+ ? 'pending'
|
|
|
+ : getStageStatus(cycleDays, KPI_DAYS.material_procurement);
|
|
|
+
|
|
|
+ return {
|
|
|
+ materialCode: fact.materialCode,
|
|
|
+ specCode: fact.specCode,
|
|
|
+ supplierGroup: fact.supplierGroup,
|
|
|
+ kpiStartDate,
|
|
|
+ expectedFinishDate,
|
|
|
+ actualFinishDate,
|
|
|
+ cycleDays,
|
|
|
+ impactedUnits,
|
|
|
+ isOnTimeKit,
|
|
|
+ status,
|
|
|
+ };
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const getManufacturingLossStatus = (lossHours: number): 'green' | 'yellow' | 'red' => {
|
|
|
+ if (lossHours <= 2) {
|
|
|
+ return 'green';
|
|
|
+ }
|
|
|
+ return lossHours <= 4 ? 'yellow' : 'red';
|
|
|
+};
|
|
|
+
|
|
|
+const buildManufacturingLosses = (
|
|
|
+ seed: string,
|
|
|
+ orderId: string,
|
|
|
+ family: FamilyTemplate,
|
|
|
+ nodeActualDays: number,
|
|
|
+ nodeStatus: ExecutionRawNodeDetail['status'],
|
|
|
+ bodyExceptions: ExceptionEvent[],
|
|
|
+ procurementExceptions: ExceptionEvent[],
|
|
|
+): ExecutionManufacturingLossDetail[] => {
|
|
|
+ if (nodeStatus === 'pending') {
|
|
|
+ return MANUFACTURING_FACTORS.map((factor) => ({
|
|
|
+ factor,
|
|
|
+ impactCount: 0,
|
|
|
+ lossHours: null,
|
|
|
+ status: 'pending',
|
|
|
+ details: [],
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ const bodyExceptionTitles = new Set(bodyExceptions.map((item) => item.title));
|
|
|
+ const factorWeights = MANUFACTURING_FACTORS.map((factor, index) => {
|
|
|
+ let weight = 1;
|
|
|
+
|
|
|
+ if (factor === '材料影响') {
|
|
|
+ weight += family.supplierRisk === 'high' ? 1.5 : family.supplierRisk === 'medium' ? 0.8 : 0.2;
|
|
|
+ weight += procurementExceptions.length * 0.7;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (factor === '设备影响') {
|
|
|
+ if (bodyExceptionTitles.has('设备停机故障')) {
|
|
|
+ weight += 2.2;
|
|
|
+ }
|
|
|
+ weight += family.manufacturingLoad === 'high' ? 1.4 : family.manufacturingLoad === 'medium' ? 0.6 : 0.2;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (factor === '质量影响') {
|
|
|
+ if (bodyExceptionTitles.has('质量返工')) {
|
|
|
+ weight += 2;
|
|
|
+ }
|
|
|
+ if (family.productType === '非标') {
|
|
|
+ weight += 0.5;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (factor === '作业效率损失') {
|
|
|
+ weight += family.manufacturingLoad === 'high' ? 1.3 : family.manufacturingLoad === 'medium' ? 0.8 : 0.4;
|
|
|
+ if (bodyExceptions.length === 0) {
|
|
|
+ weight += 0.4;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ weight += randomRange(seed, `manufacturing-factor-weight-${factor}-${index}`, -0.2, 0.35);
|
|
|
+ return Number(Math.max(weight, 0.2).toFixed(2));
|
|
|
+ });
|
|
|
+
|
|
|
+ const delayHours = Math.max(nodeActualDays - KPI_DAYS.body_production, 0) * 8;
|
|
|
+ const exceptionHours = bodyExceptions.reduce((sum, item) => sum + (item.lossDays * 8), 0);
|
|
|
+ const baselineLossHours = nodeStatus === 'green'
|
|
|
+ ? randomRange(seed, 'manufacturing-loss-green', 2.2, 4.8)
|
|
|
+ : nodeStatus === 'yellow'
|
|
|
+ ? randomRange(seed, 'manufacturing-loss-yellow', 6.2, 9.6)
|
|
|
+ : randomRange(seed, 'manufacturing-loss-red', 10.8, 15.8);
|
|
|
+ const totalLossHours = Number(
|
|
|
+ Math.max(
|
|
|
+ baselineLossHours,
|
|
|
+ delayHours + (exceptionHours * 0.35) + randomRange(seed, 'manufacturing-loss-buffer', 0.8, 3.4),
|
|
|
+ ).toFixed(1),
|
|
|
+ );
|
|
|
+ const totalImpactCount = (() => {
|
|
|
+ const baseCount = nodeStatus === 'green'
|
|
|
+ ? randomInt(seed, 'manufacturing-impact-green', 14, 22)
|
|
|
+ : nodeStatus === 'yellow'
|
|
|
+ ? randomInt(seed, 'manufacturing-impact-yellow', 22, 34)
|
|
|
+ : randomInt(seed, 'manufacturing-impact-red', 30, 42);
|
|
|
+ return baseCount + bodyExceptions.length + procurementExceptions.length;
|
|
|
+ })();
|
|
|
+ const factorImpactCounts = allocateCountByWeights(totalImpactCount, factorWeights, 1);
|
|
|
+ const factorLossHours = allocateHoursByWeights(totalLossHours, factorWeights);
|
|
|
+
|
|
|
+ return MANUFACTURING_FACTORS.map((factor, index) => {
|
|
|
+ const impactCount = factorImpactCounts[index] ?? 0;
|
|
|
+ const lossHours = Number((factorLossHours[index] ?? 0).toFixed(1));
|
|
|
+ const templates = MANUFACTURING_PROCESS_TEMPLATES[factor];
|
|
|
+ const detailCount = impactCount >= 10 ? 3 : impactCount >= 4 ? 2 : 1;
|
|
|
+ const detailTemplates = Array.from({ length: detailCount }, (_, detailIndex) => {
|
|
|
+ return templates[(hashSeed(`${seed}-${factor}-detail-start`) + detailIndex) % templates.length];
|
|
|
+ });
|
|
|
+ const detailWeights = Array.from({ length: detailCount }, (_, detailIndex) => (
|
|
|
+ Number(Math.max(randomRange(seed, `${factor}-detail-weight-${detailIndex}`, 0.8, 1.4), 0.2).toFixed(2))
|
|
|
+ ));
|
|
|
+ const detailImpactCounts = allocateCountByWeights(impactCount, detailWeights, impactCount >= detailCount ? 1 : 0);
|
|
|
+ const detailLossHours = allocateHoursByWeights(lossHours, detailWeights);
|
|
|
+ const details = detailTemplates.map((template, detailIndex) => {
|
|
|
+ const detailLoss = Number((detailLossHours[detailIndex] ?? 0).toFixed(1));
|
|
|
+ return {
|
|
|
+ id: `${orderId}-${factor}-${detailIndex + 1}`,
|
|
|
+ processName: template.processName,
|
|
|
+ issueTitle: template.issueTitle,
|
|
|
+ impactCount: detailImpactCounts[detailIndex] ?? 0,
|
|
|
+ lossHours: detailLoss,
|
|
|
+ status: getManufacturingLossStatus(detailLoss),
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ factor,
|
|
|
+ impactCount,
|
|
|
+ lossHours,
|
|
|
+ status: getManufacturingLossStatus(lossHours),
|
|
|
+ details,
|
|
|
+ };
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const buildManufacturingSteps = (
|
|
|
+ seed: string,
|
|
|
+ orderId: string,
|
|
|
+ family: FamilyTemplate,
|
|
|
+ productLine: (typeof PRODUCT_LINES)[number],
|
|
|
+ productName: string,
|
|
|
+ nodeActualDays: number | null,
|
|
|
+ nodeStatus: ExecutionRawNodeDetail['status'],
|
|
|
+ bodyExceptions: ExceptionEvent[],
|
|
|
+ procurementExceptions: ExceptionEvent[],
|
|
|
+): ExecutionManufacturingStepDetail[] => {
|
|
|
+ const bodyExceptionTitles = new Set(bodyExceptions.map((item) => item.title));
|
|
|
+ const stepPlannedDays = buildManufacturingPlannedStepDays(seed, family, productLine, productName);
|
|
|
+
|
|
|
+ if (nodeActualDays === null || nodeStatus === 'pending') {
|
|
|
+ return MANUFACTURING_STEP_TEMPLATES.map((step, stepIndex) => ({
|
|
|
+ code: step.code,
|
|
|
+ name: step.name,
|
|
|
+ plannedDays: Number((stepPlannedDays[stepIndex] ?? 0).toFixed(1)),
|
|
|
+ actualDays: null,
|
|
|
+ status: 'pending',
|
|
|
+ operators: step.operatorPool.map((name, index) => ({
|
|
|
+ id: `${orderId}-${step.code}-${index + 1}`,
|
|
|
+ name,
|
|
|
+ plannedDays: Number((((stepPlannedDays[stepIndex] ?? 0) / step.operatorPool.length)).toFixed(1)),
|
|
|
+ actualDays: null,
|
|
|
+ status: 'pending' as const,
|
|
|
+ factors: MANUFACTURING_FACTORS.map((factor) => ({
|
|
|
+ factor,
|
|
|
+ plannedDays: Number(((((stepPlannedDays[stepIndex] ?? 0) / step.operatorPool.length) / MANUFACTURING_FACTORS.length)).toFixed(1)),
|
|
|
+ actualDays: null,
|
|
|
+ status: 'pending' as const,
|
|
|
+ })),
|
|
|
+ })),
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ const stepWeights = MANUFACTURING_STEP_TEMPLATES.map((step, index) => {
|
|
|
+ let weight = (stepPlannedDays[index] ?? 0.1) * 1.7;
|
|
|
+
|
|
|
+ if (step.code === '10') {
|
|
|
+ weight += family.supplierRisk === 'high' ? 1.5 : family.supplierRisk === 'medium' ? 0.7 : 0.2;
|
|
|
+ weight += procurementExceptions.length * 0.5;
|
|
|
+ }
|
|
|
+ if (step.code === '20') {
|
|
|
+ weight += family.productType === '非标' ? 0.6 : 0.2;
|
|
|
+ weight += family.supplierRisk === 'high' ? 0.4 : 0;
|
|
|
+ }
|
|
|
+ if (step.code === '30') {
|
|
|
+ weight += family.manufacturingLoad === 'high' ? 1.8 : family.manufacturingLoad === 'medium' ? 0.8 : 0.3;
|
|
|
+ if (bodyExceptionTitles.has('设备停机故障')) {
|
|
|
+ weight += 1.1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (step.code === '40') {
|
|
|
+ if (bodyExceptionTitles.has('质量返工')) {
|
|
|
+ weight += 1.2;
|
|
|
+ }
|
|
|
+ weight += family.productType === '非标' ? 0.3 : 0.1;
|
|
|
+ }
|
|
|
+ if (step.code === '50') {
|
|
|
+ weight += family.manufacturingLoad === 'high' ? 0.6 : 0.2;
|
|
|
+ }
|
|
|
+
|
|
|
+ weight += randomRange(seed, `manufacturing-step-weight-${step.code}-${index}`, -0.15, 0.2);
|
|
|
+ return Number(Math.max(weight, 0.3).toFixed(2));
|
|
|
+ });
|
|
|
+
|
|
|
+ const stepActualDays = allocateValueByWeights(nodeActualDays, stepWeights);
|
|
|
+
|
|
|
+ return MANUFACTURING_STEP_TEMPLATES.map((step, stepIndex) => {
|
|
|
+ const plannedDays = Number((stepPlannedDays[stepIndex] ?? 0).toFixed(1));
|
|
|
+ const actualDays = Number((stepActualDays[stepIndex] ?? 0).toFixed(1));
|
|
|
+ const operatorWeights = step.operatorPool.map((_, operatorIndex) => {
|
|
|
+ let weight = 1;
|
|
|
+
|
|
|
+ if (step.code === '10') {
|
|
|
+ weight += operatorIndex === 0 ? 0.2 : 0;
|
|
|
+ }
|
|
|
+ if (step.code === '20') {
|
|
|
+ weight += operatorIndex === 1 ? 0.3 : 0.1;
|
|
|
+ }
|
|
|
+ if (step.code === '30') {
|
|
|
+ weight += operatorIndex === 0 ? 0.4 : 0.2;
|
|
|
+ }
|
|
|
+ if (step.code === '40') {
|
|
|
+ weight += operatorIndex === 1 ? 0.35 : 0.15;
|
|
|
+ }
|
|
|
+
|
|
|
+ weight += randomRange(seed, `${step.code}-operator-weight-${operatorIndex}`, -0.1, 0.15);
|
|
|
+ return Number(Math.max(weight, 0.2).toFixed(2));
|
|
|
+ });
|
|
|
+ const operatorPlannedDays = allocateValueByWeights(plannedDays, operatorWeights);
|
|
|
+ const operatorActualDays = buildParallelOperatorActualDays(seed, step.code, actualDays, operatorWeights);
|
|
|
+
|
|
|
+ const operators = step.operatorPool.map((name, operatorIndex) => {
|
|
|
+ const operatorPlanned = Number((operatorPlannedDays[operatorIndex] ?? 0).toFixed(1));
|
|
|
+ const operatorActual = Number((operatorActualDays[operatorIndex] ?? 0).toFixed(1));
|
|
|
+ const factorWeights = MANUFACTURING_FACTORS.map((factor, factorIndex) => {
|
|
|
+ let weight = 1;
|
|
|
+
|
|
|
+ if (factor === '材料影响') {
|
|
|
+ weight += step.code === '10' ? 1.2 : step.code === '20' ? 0.6 : 0.2;
|
|
|
+ weight += family.supplierRisk === 'high' ? 0.7 : 0.2;
|
|
|
+ }
|
|
|
+ if (factor === '设备影响') {
|
|
|
+ weight += step.code === '30' ? 1.1 : step.code === '40' ? 0.6 : 0.2;
|
|
|
+ if (bodyExceptionTitles.has('设备停机故障')) {
|
|
|
+ weight += 0.9;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (factor === '质量影响') {
|
|
|
+ weight += step.code === '40' ? 1 : step.code === '30' ? 0.4 : 0.2;
|
|
|
+ if (bodyExceptionTitles.has('质量返工')) {
|
|
|
+ weight += 0.8;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (factor === '作业效率损失') {
|
|
|
+ weight += step.code === '50' ? 0.9 : step.code === '30' ? 0.6 : 0.3;
|
|
|
+ weight += family.manufacturingLoad === 'high' ? 0.6 : 0.2;
|
|
|
+ }
|
|
|
+
|
|
|
+ weight += randomRange(seed, `${step.code}-${name}-${factor}-weight-${factorIndex}`, -0.08, 0.12);
|
|
|
+ return Number(Math.max(weight, 0.2).toFixed(2));
|
|
|
+ });
|
|
|
+
|
|
|
+ const factorPlannedDays = allocateValueByWeights(operatorPlanned, factorWeights);
|
|
|
+ const factorActualDays = allocateValueByWeights(operatorActual, factorWeights);
|
|
|
+ const factors = MANUFACTURING_FACTORS.map((factor, factorIndex) => ({
|
|
|
+ factor,
|
|
|
+ plannedDays: Number((factorPlannedDays[factorIndex] ?? 0).toFixed(1)),
|
|
|
+ actualDays: Number((factorActualDays[factorIndex] ?? 0).toFixed(1)),
|
|
|
+ status: getStageStatus(
|
|
|
+ Number((factorActualDays[factorIndex] ?? 0).toFixed(1)),
|
|
|
+ Math.max(Number((factorPlannedDays[factorIndex] ?? 0).toFixed(1)), 0.1),
|
|
|
+ ),
|
|
|
+ }));
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: `${orderId}-${step.code}-${operatorIndex + 1}`,
|
|
|
+ name,
|
|
|
+ plannedDays: operatorPlanned,
|
|
|
+ actualDays: operatorActual,
|
|
|
+ status: getStageStatus(operatorActual, Math.max(operatorPlanned, 0.1)),
|
|
|
+ factors,
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ code: step.code,
|
|
|
+ name: step.name,
|
|
|
+ plannedDays,
|
|
|
+ actualDays,
|
|
|
+ status: getStageStatus(actualDays, Math.max(plannedDays, 0.1)),
|
|
|
+ operators,
|
|
|
+ };
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const pickPriority = (family: FamilyTemplate, seed: string): 'P1' | 'P2' | 'P3' =>
|
|
|
+ family.priorityPool[randomInt(seed, 'priority', 0, family.priorityPool.length - 1)];
|
|
|
+
|
|
|
+const createNodeOffsets = (family: FamilyTemplate, seed: string): Record<OrderNodeKey, number> => {
|
|
|
+ return NODE_SEQUENCE.reduce((result, node) => {
|
|
|
+ const [minOffset, maxOffset] = family.offsets[node.key];
|
|
|
+ const offset = randomRange(seed, `${node.key}-offset`, minOffset, maxOffset);
|
|
|
+ result[node.key] = Number(offset.toFixed(1));
|
|
|
+ return result;
|
|
|
+ }, {} as Record<OrderNodeKey, number>);
|
|
|
+};
|
|
|
+
|
|
|
+const createNodeExceptions = (
|
|
|
+ family: FamilyTemplate,
|
|
|
+ orderId: string,
|
|
|
+ seed: string,
|
|
|
+): Record<OrderNodeKey, ExceptionEvent[]> => {
|
|
|
+ const desiredCount = family.exceptionTemplateIds.length === 0
|
|
|
+ ? 0
|
|
|
+ : randomInt(seed, 'exception-count', family.minExceptions, family.maxExceptions);
|
|
|
+ const selectedTemplateIds = family.exceptionTemplateIds.slice(0, desiredCount === 0 ? 0 : desiredCount);
|
|
|
+
|
|
|
+ return selectedTemplateIds.reduce((result, templateId, index) => {
|
|
|
+ const event = buildExceptionEvent(seed, orderId, index, templateId);
|
|
|
+ const nodeKey = EXCEPTION_TEMPLATES[templateId].nodeKey;
|
|
|
+ result[nodeKey] = [...(result[nodeKey] ?? []), event];
|
|
|
+ return result;
|
|
|
+ }, {} as Record<OrderNodeKey, ExceptionEvent[]>);
|
|
|
+};
|
|
|
+
|
|
|
+const createPlannedAndActualDates = (
|
|
|
+ family: FamilyTemplate,
|
|
|
+ seed: string,
|
|
|
+ actualDurations: Record<OrderNodeKey, number>,
|
|
|
+): {
|
|
|
+ releaseAt: Date;
|
|
|
+ expectedStartAtByNode: Record<OrderNodeKey, Date>;
|
|
|
+ expectedEndAtByNode: Record<OrderNodeKey, Date>;
|
|
|
+ actualStartAtByNode: Record<OrderNodeKey, Date | null>;
|
|
|
+ actualEndAtByNode: Record<OrderNodeKey, Date | null>;
|
|
|
+} => {
|
|
|
+ const expectedStartAtByNode = {} as Record<OrderNodeKey, Date>;
|
|
|
+ const expectedEndAtByNode = {} as Record<OrderNodeKey, Date>;
|
|
|
+ const actualStartAtByNode = {} as Record<OrderNodeKey, Date | null>;
|
|
|
+ const actualEndAtByNode = {} as Record<OrderNodeKey, Date | null>;
|
|
|
+
|
|
|
+ if (family.workflowStatus === 'completed') {
|
|
|
+ const completionGapDays = randomRange(seed, 'completion-gap-days', 0.3, 6.5);
|
|
|
+ let stageEndAt = addMinutes(OBSERVED_AT, -toMinutes(completionGapDays));
|
|
|
+
|
|
|
+ for (let index = NODE_SEQUENCE.length - 1; index >= 0; index -= 1) {
|
|
|
+ const node = NODE_SEQUENCE[index];
|
|
|
+ const stageStartAt = addMinutes(stageEndAt, -toMinutes(actualDurations[node.key]));
|
|
|
+ actualStartAtByNode[node.key] = stageStartAt;
|
|
|
+ actualEndAtByNode[node.key] = stageEndAt;
|
|
|
+ stageEndAt = stageStartAt;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const currentNodeIndex = NODE_SEQUENCE.findIndex((node) => node.key === family.currentNodeKey);
|
|
|
+ const progressRatio = randomRange(seed, 'current-progress', 0.45, 0.92);
|
|
|
+ const currentElapsedMinutes = Math.round(toMinutes(actualDurations[family.currentNodeKey]) * progressRatio);
|
|
|
+ const currentStartAt = addMinutes(OBSERVED_AT, -currentElapsedMinutes);
|
|
|
+ actualStartAtByNode[family.currentNodeKey] = currentStartAt;
|
|
|
+ actualEndAtByNode[family.currentNodeKey] = null;
|
|
|
+
|
|
|
+ let previousEndAt = currentStartAt;
|
|
|
+ for (let index = currentNodeIndex - 1; index >= 0; index -= 1) {
|
|
|
+ const node = NODE_SEQUENCE[index];
|
|
|
+ const stageStartAt = addMinutes(previousEndAt, -toMinutes(actualDurations[node.key]));
|
|
|
+ actualStartAtByNode[node.key] = stageStartAt;
|
|
|
+ actualEndAtByNode[node.key] = previousEndAt;
|
|
|
+ previousEndAt = stageStartAt;
|
|
|
+ }
|
|
|
+
|
|
|
+ for (let index = currentNodeIndex + 1; index < NODE_SEQUENCE.length; index += 1) {
|
|
|
+ const node = NODE_SEQUENCE[index];
|
|
|
+ actualStartAtByNode[node.key] = null;
|
|
|
+ actualEndAtByNode[node.key] = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const releaseAt = actualStartAtByNode[NODE_SEQUENCE[0].key] ?? addMinutes(OBSERVED_AT, -toMinutes(31));
|
|
|
+ let plannedStartAt = new Date(releaseAt);
|
|
|
+
|
|
|
+ NODE_SEQUENCE.forEach((node) => {
|
|
|
+ expectedStartAtByNode[node.key] = new Date(plannedStartAt);
|
|
|
+ const plannedEndAt = addMinutes(plannedStartAt, toMinutes(node.kpiDays));
|
|
|
+ expectedEndAtByNode[node.key] = plannedEndAt;
|
|
|
+ plannedStartAt = plannedEndAt;
|
|
|
+ });
|
|
|
+
|
|
|
+ return {
|
|
|
+ releaseAt,
|
|
|
+ expectedStartAtByNode,
|
|
|
+ expectedEndAtByNode,
|
|
|
+ actualStartAtByNode,
|
|
|
+ actualEndAtByNode,
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const createRawOrder = (family: FamilyTemplate, runningIndex: number): ExecutionRawOrder => {
|
|
|
+ const seed = `${family.id}-${runningIndex}`;
|
|
|
+ const customerProfile = CUSTOMER_PICKER[(runningIndex - 1) % CUSTOMER_PICKER.length];
|
|
|
+ const productLine = PRODUCT_LINES[(runningIndex - 1) % PRODUCT_LINES.length];
|
|
|
+ const materialCode = MATERIAL_CODE_BY_PRODUCT_LINE[productLine];
|
|
|
+ const region = REGIONS[(runningIndex + 1) % REGIONS.length];
|
|
|
+ const productOptions = PRODUCT_NAME_LIBRARY[productLine];
|
|
|
+ const productName = productOptions[randomInt(seed, 'product-name', 0, productOptions.length - 1)];
|
|
|
+ const priority = pickPriority(family, seed);
|
|
|
+ const supplierGroup = SUPPLIER_GROUPS[randomInt(seed, 'supplier-group', 0, SUPPLIER_GROUPS.length - 1)];
|
|
|
+ const orderId = `ORD-2026-${String(runningIndex).padStart(3, '0')}`;
|
|
|
+ const actualOffsets = createNodeOffsets(family, seed);
|
|
|
+ const actualDurations = Object.fromEntries(
|
|
|
+ NODE_SEQUENCE.map((node) => [
|
|
|
+ node.key,
|
|
|
+ Number(Math.max(KPI_DAYS[node.key] + actualOffsets[node.key], 0.4).toFixed(1)),
|
|
|
+ ]),
|
|
|
+ ) as Record<OrderNodeKey, number>;
|
|
|
+ const exceptionMap = createNodeExceptions(family, orderId, seed);
|
|
|
+ const timeline = createPlannedAndActualDates(family, seed, actualDurations);
|
|
|
+ const currentActualDurations = Object.fromEntries(
|
|
|
+ NODE_SEQUENCE.map((node) => {
|
|
|
+ const actualEndAt = timeline.actualEndAtByNode[node.key];
|
|
|
+ const actualStartAt = timeline.actualStartAtByNode[node.key];
|
|
|
+ const currentActualDays = actualEndAt
|
|
|
+ ? actualDurations[node.key]
|
|
|
+ : (actualStartAt ? getElapsedBusinessDays(actualStartAt, OBSERVED_AT) : null);
|
|
|
+ return [node.key, currentActualDays];
|
|
|
+ }),
|
|
|
+ ) as Record<OrderNodeKey, number | null>;
|
|
|
+ const reviewStartAt = timeline.actualStartAtByNode.order_review;
|
|
|
+ const reviewEndAt = timeline.actualEndAtByNode.order_review;
|
|
|
+ const reviewConsumedDays = reviewEndAt
|
|
|
+ ? actualDurations.order_review
|
|
|
+ : getElapsedBusinessDays(reviewStartAt, OBSERVED_AT);
|
|
|
+ const designNodeStatus: ExecutionRawNodeDetail['status'] = currentActualDurations.product_design !== null
|
|
|
+ ? getStageStatus(currentActualDurations.product_design, KPI_DAYS.product_design)
|
|
|
+ : 'pending';
|
|
|
+ const reviewSubSteps = buildOrderReviewSubSteps(
|
|
|
+ seed,
|
|
|
+ family,
|
|
|
+ priority,
|
|
|
+ actualDurations.order_review,
|
|
|
+ reviewConsumedDays,
|
|
|
+ exceptionMap.order_review ?? [],
|
|
|
+ );
|
|
|
+ const designSubTasks = buildProductDesignTasks(
|
|
|
+ seed,
|
|
|
+ orderId,
|
|
|
+ family,
|
|
|
+ actualDurations.product_design,
|
|
|
+ designNodeStatus,
|
|
|
+ timeline.expectedStartAtByNode.product_design,
|
|
|
+ timeline.expectedEndAtByNode.product_design,
|
|
|
+ timeline.actualStartAtByNode.product_design,
|
|
|
+ timeline.actualEndAtByNode.product_design,
|
|
|
+ exceptionMap.product_design ?? [],
|
|
|
+ );
|
|
|
+ const procurementFacts = buildProcurementFacts(
|
|
|
+ seed,
|
|
|
+ materialCode,
|
|
|
+ supplierGroup,
|
|
|
+ currentActualDurations.material_procurement ?? actualDurations.material_procurement,
|
|
|
+ currentActualDurations.material_procurement !== null
|
|
|
+ ? getStageStatus(currentActualDurations.material_procurement, KPI_DAYS.material_procurement)
|
|
|
+ : 'pending',
|
|
|
+ timeline.actualStartAtByNode.material_procurement,
|
|
|
+ timeline.actualEndAtByNode.material_procurement,
|
|
|
+ );
|
|
|
+ const manufacturingNodeStatus: ExecutionRawNodeDetail['status'] = currentActualDurations.body_production !== null
|
|
|
+ ? getStageStatus(currentActualDurations.body_production, KPI_DAYS.body_production)
|
|
|
+ : 'pending';
|
|
|
+ const manufacturingLosses = buildManufacturingLosses(
|
|
|
+ seed,
|
|
|
+ orderId,
|
|
|
+ family,
|
|
|
+ currentActualDurations.body_production ?? actualDurations.body_production,
|
|
|
+ manufacturingNodeStatus,
|
|
|
+ exceptionMap.body_production ?? [],
|
|
|
+ exceptionMap.material_procurement ?? [],
|
|
|
+ );
|
|
|
+ const manufacturingSteps = buildManufacturingSteps(
|
|
|
+ seed,
|
|
|
+ orderId,
|
|
|
+ family,
|
|
|
+ productLine,
|
|
|
+ productName,
|
|
|
+ currentActualDurations.body_production,
|
|
|
+ manufacturingNodeStatus,
|
|
|
+ exceptionMap.body_production ?? [],
|
|
|
+ exceptionMap.material_procurement ?? [],
|
|
|
+ );
|
|
|
+
|
|
|
+ const lifecycle = NODE_SEQUENCE.map((node) => {
|
|
|
+ const actualEndAt = timeline.actualEndAtByNode[node.key];
|
|
|
+ const actualStartAt = timeline.actualStartAtByNode[node.key];
|
|
|
+ const actualDays = currentActualDurations[node.key];
|
|
|
+ const status: ExecutionRawNodeDetail['status'] = actualDays !== null
|
|
|
+ ? getStageStatus(actualDays, node.kpiDays)
|
|
|
+ : 'pending';
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: node.key,
|
|
|
+ name: node.name,
|
|
|
+ plannedDays: node.kpiDays,
|
|
|
+ actualDays,
|
|
|
+ expectedDate: formatMonthDay(timeline.expectedEndAtByNode[node.key]),
|
|
|
+ actualStartDate: actualStartAt ? formatMonthDayTime(actualStartAt) : null,
|
|
|
+ actualDate: actualEndAt ? formatMonthDayTime(actualEndAt) : null,
|
|
|
+ status,
|
|
|
+ exceptions: exceptionMap[node.key] ?? [],
|
|
|
+ subSteps: node.key === 'order_review'
|
|
|
+ ? reviewSubSteps
|
|
|
+ : undefined,
|
|
|
+ designTasks: node.key === 'product_design'
|
|
|
+ ? designSubTasks
|
|
|
+ : undefined,
|
|
|
+ manufacturingLosses: node.key === 'body_production'
|
|
|
+ ? manufacturingLosses
|
|
|
+ : undefined,
|
|
|
+ manufacturingSteps: node.key === 'body_production'
|
|
|
+ ? manufacturingSteps
|
|
|
+ : undefined,
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ const orderExceptions = lifecycle.flatMap((node) => node.exceptions);
|
|
|
+ // R-01/R-02: no exceptions → responseTime and processingTime must be null
|
|
|
+ const averageResponse = orderExceptions.length > 0
|
|
|
+ ? Math.round(orderExceptions.reduce((sum, item) => sum + item.responseMinutes, 0) / orderExceptions.length)
|
|
|
+ : null;
|
|
|
+ const averageHandle = orderExceptions.length > 0
|
|
|
+ ? Math.round(orderExceptions.reduce((sum, item) => sum + item.handleMinutes, 0) / orderExceptions.length)
|
|
|
+ : null;
|
|
|
+ const currentNodeName = NODE_NAME_MAP[family.currentNodeKey];
|
|
|
+ const currentNodeStatus = lifecycle.find((node) => node.key === family.currentNodeKey)?.status ?? 'green';
|
|
|
+ const responseTime = family.workflowStatus === 'in_progress' && randomUnit(seed, 'awaiting-response') < 0.25
|
|
|
+ ? null
|
|
|
+ : averageResponse;
|
|
|
+ const processingTime = family.workflowStatus === 'completed' ? averageHandle : null;
|
|
|
+ const workOrderStatus: ExecutionRawWorkOrder['status'] = family.workflowStatus === 'completed'
|
|
|
+ ? (responseTime !== null && processingTime !== null && (responseTime > 60 || processingTime > 120 || currentNodeStatus === 'red') ? '超时' : '已完成')
|
|
|
+ : (responseTime === null ? '待响应' : '进行中');
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: orderId,
|
|
|
+ productName,
|
|
|
+ productLine,
|
|
|
+ materialCode,
|
|
|
+ customer: customerProfile.name,
|
|
|
+ region,
|
|
|
+ productType: family.productType,
|
|
|
+ priority,
|
|
|
+ supplierGroup,
|
|
|
+ supplierRisk: family.supplierRisk,
|
|
|
+ manufacturingLoad: family.manufacturingLoad,
|
|
|
+ focusNodeKey: family.focusNodeKey,
|
|
|
+ focusNode: NODE_NAME_MAP[family.focusNodeKey],
|
|
|
+ releaseAt: formatMonthDayTime(timeline.releaseAt),
|
|
|
+ workflowStatus: family.workflowStatus,
|
|
|
+ currentNodeKey: family.currentNodeKey,
|
|
|
+ procurementFacts,
|
|
|
+ workOrders: [
|
|
|
+ {
|
|
|
+ id: `WO-${orderId.slice(-3)}`,
|
|
|
+ node: currentNodeName,
|
|
|
+ nodeStatus: currentNodeStatus === 'pending' ? 'yellow' : currentNodeStatus,
|
|
|
+ responseTime,
|
|
|
+ processingTime,
|
|
|
+ status: workOrderStatus,
|
|
|
+ lifecycle,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ };
|
|
|
+};
|
|
|
+
|
|
|
+const buildExecutionOrders = (): ExecutionRawOrder[] => {
|
|
|
+ let orderIndex = 1;
|
|
|
+ return FAMILY_TEMPLATES.flatMap((family) => Array.from({ length: family.count }, () => {
|
|
|
+ const order = createRawOrder(family, orderIndex);
|
|
|
+ orderIndex += 1;
|
|
|
+ return order;
|
|
|
+ }));
|
|
|
+};
|
|
|
+
|
|
|
+const groupOrdersByCustomer = (orders: ExecutionRawOrder[]): ExecutionRawCustomer[] => {
|
|
|
+ return CUSTOMER_PROFILES.map((profile) => ({
|
|
|
+ ...profile,
|
|
|
+ orders: orders.filter((order) => order.customer === profile.name),
|
|
|
+ })).filter((profile) => profile.orders.length > 0);
|
|
|
+};
|
|
|
+
|
|
|
+const getElapsedDays = (startDate: string | null, endDate: string | null): number => {
|
|
|
+ const start = parseMonthDayTime(startDate);
|
|
|
+ const end = parseMonthDayTime(endDate);
|
|
|
+ if (!start || !end) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ return Number(((end.getTime() - start.getTime()) / (DAY_MINUTES * 60 * 1000)).toFixed(1));
|
|
|
+};
|
|
|
+
|
|
|
+const buildDeliveryOrders = (orders: ExecutionRawOrder[]): DeliveryOrder[] => {
|
|
|
+ return orders.map((order) => {
|
|
|
+ const nodes: OrderNodeExecution[] = order.workOrders[0].lifecycle.map((node, index, lifecycle) => {
|
|
|
+ const isCurrent = node.key === order.currentNodeKey;
|
|
|
+ const previousEndAt = index > 0 ? lifecycle[index - 1].actualDate : order.releaseAt;
|
|
|
+ const elapsedDays = node.actualDate
|
|
|
+ ? getElapsedDays(node.actualStartDate ?? previousEndAt, node.actualDate)
|
|
|
+ : (isCurrent ? getElapsedDays(node.actualStartDate ?? previousEndAt, formatMonthDayTime(OBSERVED_AT)) : 0);
|
|
|
+
|
|
|
+ return {
|
|
|
+ key: node.key,
|
|
|
+ name: node.name,
|
|
|
+ plannedDays: node.plannedDays,
|
|
|
+ actualDays: node.actualDate
|
|
|
+ ? elapsedDays
|
|
|
+ : (isCurrent ? Number(Math.max(elapsedDays, 0).toFixed(1)) : 0),
|
|
|
+ status: node.actualDate ? 'completed' : (isCurrent ? 'processing' : 'pending'),
|
|
|
+ exceptions: node.exceptions,
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ const totalPlannedDays = nodes.reduce((sum, node) => sum + node.plannedDays, 0);
|
|
|
+ const totalActualDays = nodes.reduce((sum, node) => sum + node.actualDays, 0);
|
|
|
+ const isLate = nodes.some((node) => node.status !== 'pending' && node.actualDays > node.plannedDays);
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: order.id,
|
|
|
+ productLine: order.productLine,
|
|
|
+ customer: order.customer,
|
|
|
+ region: order.region,
|
|
|
+ isLate,
|
|
|
+ totalPlannedDays,
|
|
|
+ totalActualDays,
|
|
|
+ nodes,
|
|
|
+ };
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+export const EXECUTION_MOCK_ORDERS = buildExecutionOrders();
|
|
|
+export const EXECUTION_MOCK_CUSTOMERS = groupOrdersByCustomer(EXECUTION_MOCK_ORDERS);
|
|
|
+export const EXECUTION_DELIVERY_ORDERS = buildDeliveryOrders(EXECUTION_MOCK_ORDERS);
|
|
|
+export const EXECUTION_OBSERVED_AT = formatMonthDayTime(OBSERVED_AT);
|
|
|
+export const EXECUTION_SUMMARY = {
|
|
|
+ total: EXECUTION_MOCK_ORDERS.length,
|
|
|
+ completed: EXECUTION_MOCK_ORDERS.filter((order) => order.workflowStatus === 'completed').length,
|
|
|
+ inProgress: EXECUTION_MOCK_ORDERS.filter((order) => order.workflowStatus === 'in_progress').length,
|
|
|
+};
|
|
|
+export const EXECUTION_NODE_NAME_BY_KEY = NODE_NAME_MAP;
|
|
|
+export const EXECUTION_NODE_KEY_BY_NAME = NODE_KEY_BY_NAME;
|