Просмотр исходного кода

fix(s8): persist order review substep duration rollup

S8-ORDER-EXECUTION-ORDER-REVIEW-SUBSTEP-PERSIST-1

为 20 单 ORDER_REVIEW_PLAN_CALC 阶段重写 L2 / L3 落库数据,使其满足
"plannedDays × 8 = L2 piHours 合计 / actualDays × 8 = L2 actualHours 合计"
回卷不变量;L3 责任单元 piHours / actualHours 合计严格等于父 L2 同字段。

- substep seed:移除旧 Ppt001L2 / L2Buckets 派生,按 stage 主节点
  plannedDays / actualDays / status 经 PI 权重(20/30/20/25/5)+
  延误权重(35/25/15/20/5)回卷生成 20 单 × 5 L2 = 100 行;
  actualDays=null 全部 pending;末位尾差修正保证合计严格相等。
- substep_unit seed:L3 从仅 OPINION_REVIEW × 4 扩展为 5 L2 × N 责任单元
  (4+3+3+3+2 = 15 / 单);20 单 × 15 = 300 行;按部门权重严格回卷父 L2;
  unit_code 新增 MARKETING / RND(部门名复用既有真实业务术语)。
- UpdateScripts/1.0.121.sql:按 order_flow_code='ORDER_REVIEW_PLAN_CALC'
  集合先删 unit 再删 substep,不依赖固定 Id 段,等下次 IncreSeed
  重新插入新值。
- csproj:版本号 1.0.120 → 1.0.121 同步 patch +1。

前端零改动。其它 4 个一级阶段(PRODUCT_DESIGN / MATERIAL_PURCHASE /
BODY_PRODUCTION / FINAL_ASSEMBLY_DELIVERY)的 L2/L3 不在本批次范围。
YY968XX 1 неделя назад
Родитель
Сommit
b0147138aa

+ 6 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.120</AssemblyVersion>
-    <FileVersion>1.0.120</FileVersion>
-    <Version>1.0.120</Version>
+    <AssemblyVersion>1.0.121</AssemblyVersion>
+    <FileVersion>1.0.121</FileVersion>
+    <Version>1.0.121</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -61,6 +61,9 @@
     <None Update="UpdateScripts\1.0.111.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.121.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 35 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.121.sql

@@ -0,0 +1,35 @@
+-- 1.0.121.sql
+-- S8-ORDER-EXECUTION-ORDER-REVIEW-SUBSTEP-PERSIST-1
+-- 为 20 单 ORDER_REVIEW_PLAN_CALC 阶段重写 L2 / L3 落库数据:让 5 个 L2 子节点 + 各自 L3 责任单元的
+-- piHours / actualHours 合计严格回卷到主节点 plannedDays × 8 / actualDays × 8。
+-- 老库现有的 L2 / L3 行(substep 100 条 + unit 80 条)由旧 seed 写入,piHours/actualHours 与新回卷规则
+-- 不一致;本脚本按 substep 集合先删 unit 再删 substep,给下次启动的 [IncreSeed] 让位重新插入新值。
+-- 执行入口:AutoVersionUpdate.UseAutoVersionUpdate(),csproj Version=1.0.121 主节点首次启动时触发。
+-- 幂等性:
+--   * 两条 DELETE 语句按 order_flow_code='ORDER_REVIEW_PLAN_CALC' 范围删除;重复执行删 0 行不报错。
+--   * 不依赖固定 Id 段(不写死 1329909120001..1329909120100 等区间),避免 L3 总行数从 80 扩展到 300
+--     之后旧固定 Id 段不全的问题。
+--   * 不依赖临时表,不读写其它任何表。
+-- 安全边界:
+--   * 仅清理 order_flow_code='ORDER_REVIEW_PLAN_CALC' 的 substep 与对应 unit;
+--   * 不动 ado_s8_order_flow_stage(主节点真值)、ado_s8_order_flow_order(订单主档);
+--   * 不动其它 4 个一级阶段(PRODUCT_DESIGN / MATERIAL_PURCHASE / BODY_PRODUCTION /
+--     FINAL_ASSEMBLY_DELIVERY),它们当前无 substep / unit 数据,本脚本也不会扩散到它们;
+--   * 无 ALTER;无 schema 变更;无全表 UPDATE / DELETE。
+-- 关联前置:
+--   * S8OrderFlowSubstepSeedData.cs 同提交内重写为按 BuildL2Rows 回卷生成 100 行;
+--   * S8OrderFlowSubstepUnitSeedData.cs 同提交内重写为 5 L2 × 部门权重 = 300 行。
+-- Rollback: 不可逆(旧 Ppt001L2 / Ppt001L3 / L2Buckets / L3Buckets 派生值已被新 seed 取代);
+--   如需回滚,需 git revert 同提交并重新跑 IncreSeed。
+-- 2026-05-21
+
+-- 1) 先按 substep 集合删除其下所有 unit(含旧 80 行 OPINION_REVIEW unit 与未来扩展的其它 L2 unit)
+DELETE u
+  FROM ado_s8_order_flow_substep_unit u
+  INNER JOIN ado_s8_order_flow_substep s
+    ON u.substep_id = s.id
+ WHERE s.order_flow_code = 'ORDER_REVIEW_PLAN_CALC';
+
+-- 2) 再删除目标 substep(旧 100 行 ORDER_REVIEW_PLAN_CALC 子节点)
+DELETE FROM ado_s8_order_flow_substep
+ WHERE order_flow_code = 'ORDER_REVIEW_PLAN_CALC';

+ 116 - 52
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8OrderFlowSubstepSeedData.cs

@@ -3,9 +3,11 @@ using Admin.NET.Plugin.AiDOP.Entity.S8.OrderFlow;
 namespace Admin.NET.Plugin.AiDOP.SeedData;
 
 /// <summary>
-/// ORDER-FLOW-S8-INTEGRATED-DOMAIN-RESET-1 t2b:ORDER_REVIEW_PLAN_CALC 阶段 L2 子节点种子。
-/// 20 单 × 5 子节点 = 100 行;SO-2026-001 按 PPT 第 2 页真值;其余 19 单按 (orderIdx, substepIdx)
-/// 派生 multiplier,确定性、可重入,无随机数 / DateTime.Now。
+/// S8-ORDER-EXECUTION-ORDER-REVIEW-SUBSTEP-PERSIST-1:ORDER_REVIEW_PLAN_CALC 阶段 L2 子节点种子。
+/// 20 单 × 5 子节点 = 100 行;每单的 L2 由该单 ORDER_REVIEW_PLAN_CALC 主节点 plannedDays / actualDays 严格回卷生成:
+///   - L2 piHours 合计 = plannedDays × 8
+///   - L2 actualHours 合计 = actualDays × 8(actualDays = null 时全部 pending)
+///   - 状态由 actualHours 与 piHours 比较得出
 /// </summary>
 [IncreSeed]
 public class S8OrderFlowSubstepSeedData : ISqlSugarEntitySeedData<AdoS8OrderFlowSubstep>
@@ -18,7 +20,10 @@ internal static class S8OrderFlowSubstepDataset
     internal const long SubstepIdBase = 1329909120000L;
     internal const string FlowCode = "ORDER_REVIEW_PLAN_CALC";
 
-    /// <summary>L2 五子节点固定顺序与 PI 基线工时。</summary>
+    /// <summary>1 工作日 = 8 工时(业务规则常量)。</summary>
+    internal const decimal HoursPerDay = 8m;
+
+    /// <summary>L2 五子节点固定顺序与 PI 基线工时(plannedDays=5 时合计 40h 与主节点对齐)。</summary>
     internal static readonly (string Code, string Name, decimal PiHours)[] L2Def =
     {
         ("OPINION_REVIEW",   "意见评审",  8m),
@@ -28,29 +33,14 @@ internal static class S8OrderFlowSubstepDataset
         ("CONTRACT_SEAL",    "合同盖章",  2m),
     };
 
-    /// <summary>SO-2026-001 PPT 第 2 页 L2 真值:actual_hours / status,与 L2Def 同序。</summary>
-    private static readonly (decimal Actual, string Status)[] Ppt001L2 =
-    {
-        (14.4m, "red"),
-        (14.2m, "yellow"),
-        ( 7.0m, "green"),
-        (12.0m, "yellow"),
-        ( 2.0m, "green"),
-    };
+    /// <summary>L2 PI 权重(合计 1.00;用于 plannedHours 拆分)。</summary>
+    private static readonly decimal[] PiWeight = { 0.20m, 0.30m, 0.20m, 0.25m, 0.05m };
 
-    /// <summary>
-    /// 19 个非 PPT 单的 L2 派生 multiplier 桶。
-    /// bucketIdx = (idx - 2 + substepIdx) mod 5,对应 5 种 (multiplier, status) 组合。
-    /// status 满足:multiplier ≤ 1.00 = green;1.00 &lt; multiplier ≤ 1.25 = yellow;&gt; 1.25 = red。
-    /// </summary>
-    private static readonly (decimal Mult, string Status)[] L2Buckets =
-    {
-        (0.90m, "green"),
-        (1.10m, "yellow"),
-        (1.40m, "red"),
-        (1.00m, "green"),
-        (1.20m, "yellow"),
-    };
+    /// <summary>L2 延误权重(合计 1.00;当 actualHours &gt; plannedHours 时把 delay 分配到各子节点)。</summary>
+    private static readonly decimal[] DelayWeight = { 0.35m, 0.25m, 0.15m, 0.20m, 0.05m };
+
+    /// <summary>L2 行回卷结果,供同批次 unit 种子复用同一份真值。</summary>
+    internal readonly record struct L2Row(decimal PiHours, decimal? ActualHours, string Status);
 
     public static IEnumerable<AdoS8OrderFlowSubstep> BuildSubsteps()
     {
@@ -58,27 +48,13 @@ internal static class S8OrderFlowSubstepDataset
         foreach (var spec in S8OrderFlowDataset.Specs)
         {
             var orderId = S8OrderFlowDataset.OrderIdBase + spec.Idx;
-            var isPpt = spec.OrderCode == "SO-2026-001";
-            var scenario = isPpt ? "PPT" : "DEMO";
+            var scenario = spec.OrderCode == "SO-2026-001" ? "PPT" : "DEMO";
+            var rows = BuildL2Rows(spec);
 
             for (var i = 0; i < L2Def.Length; i++)
             {
-                var (code, name, piHours) = L2Def[i];
-                decimal actual;
-                string status;
-
-                if (isPpt)
-                {
-                    actual = Ppt001L2[i].Actual;
-                    status = Ppt001L2[i].Status;
-                }
-                else
-                {
-                    var bucketIdx = ((spec.Idx - 2) + i) % L2Buckets.Length;
-                    var bucket = L2Buckets[bucketIdx];
-                    actual = decimal.Round(piHours * bucket.Mult, 1);
-                    status = bucket.Status;
-                }
+                var (code, name, _) = L2Def[i];
+                var row = rows[i];
 
                 yield return new AdoS8OrderFlowSubstep
                 {
@@ -88,9 +64,9 @@ internal static class S8OrderFlowSubstepDataset
                     OrderFlowCode = FlowCode,
                     SubstepCode = code,
                     SubstepName = name,
-                    PiHours = piHours,
-                    ActualHours = actual,
-                    Status = status,
+                    PiHours = row.PiHours,
+                    ActualHours = row.ActualHours,
+                    Status = row.Status,
                     SortNo = i + 1,
                     ScenarioCode = scenario,
                     DataSource = "SEED",
@@ -104,12 +80,100 @@ internal static class S8OrderFlowSubstepDataset
         }
     }
 
-    /// <summary>OPINION_REVIEW 在 100 行 substep 中的全局 substep_id(供 unit seed 反查 FK)。</summary>
+    /// <summary>
+    /// 按订单 ORDER_REVIEW_PLAN_CALC 主节点回卷生成 5 个 L2 行。
+    /// 输入:spec → BuildLifecycleValues(spec)[0] = order_review 一级阶段真值。
+    /// 输出:5 个 L2Row,piHours/actualHours/status 严格满足合计回卷。
+    /// </summary>
+    internal static L2Row[] BuildL2Rows(S8OrderFlowDataset.OrderSpec spec)
+    {
+        var stage = S8OrderFlowStageDataset.BuildLifecycleValues(spec)[0];
+        var plannedHours = stage.kpi * HoursPerDay;
+        decimal? actualHours = stage.status == "pending"
+            ? (decimal?)null
+            : stage.actualDays * HoursPerDay;
+
+        var piHoursArr = AllocateByWeight(plannedHours, PiWeight);
+
+        if (actualHours == null)
+        {
+            return piHoursArr
+                .Select(pi => new L2Row(pi, null, "pending"))
+                .ToArray();
+        }
+
+        decimal[] actualHoursArr;
+        if (actualHours.Value > plannedHours)
+        {
+            var delay = actualHours.Value - plannedHours;
+            var delayShare = AllocateByWeight(delay, DelayWeight);
+            actualHoursArr = new decimal[L2Def.Length];
+            for (var i = 0; i < L2Def.Length; i++)
+                actualHoursArr[i] = decimal.Round(piHoursArr[i] + delayShare[i], 1);
+            // 末位尾差修正,保证合计严格等于 actualHours
+            FixTail(actualHoursArr, actualHours.Value);
+        }
+        else if (actualHours.Value == plannedHours)
+        {
+            actualHoursArr = (decimal[])piHoursArr.Clone();
+        }
+        else
+        {
+            // actualHours < plannedHours:按 PI 权重等比例压缩
+            actualHoursArr = AllocateByWeight(actualHours.Value, PiWeight);
+        }
+
+        return Enumerable.Range(0, L2Def.Length)
+            .Select(i => new L2Row(piHoursArr[i], actualHoursArr[i], ClassifyByPi(actualHoursArr[i], piHoursArr[i])))
+            .ToArray();
+    }
+
+    /// <summary>
+    /// 按权重把 total 拆成与 weights 等长的 decimal 数组:
+    ///   - 前 N-1 项 = Round(total × weights[i], 1)
+    ///   - 末项 = total - 前 N-1 项之和(保留 1 位)
+    /// 保证返回数组合计严格等于 total。
+    /// </summary>
+    internal static decimal[] AllocateByWeight(decimal total, decimal[] weights)
+    {
+        var result = new decimal[weights.Length];
+        decimal headSum = 0m;
+        for (var i = 0; i < weights.Length - 1; i++)
+        {
+            var v = decimal.Round(total * weights[i], 1);
+            result[i] = v;
+            headSum += v;
+        }
+        result[weights.Length - 1] = decimal.Round(total - headSum, 1);
+        return result;
+    }
+
+    /// <summary>把末项调整为 total - 前 N-1 项之和,吸收 Round 累积误差。</summary>
+    private static void FixTail(decimal[] arr, decimal total)
+    {
+        decimal headSum = 0m;
+        for (var i = 0; i < arr.Length - 1; i++) headSum += arr[i];
+        arr[arr.Length - 1] = decimal.Round(total - headSum, 1);
+    }
+
+    /// <summary>状态判定:≤PI=green / 超出 ≤20%=yellow / 否则 red。</summary>
+    internal static string ClassifyByPi(decimal actual, decimal pi)
+    {
+        if (pi <= 0m) return "green";
+        if (actual <= pi) return "green";
+        return (actual - pi) / pi <= 0.20m ? "yellow" : "red";
+    }
+
+    /// <summary>按 (orderIdx, substepIdx) 反查 substep_id(与 BuildSubsteps 的 seq 编号严格一致)。</summary>
+    public static long SubstepIdAt(int orderIdx, int substepIdx)
+    {
+        // 每单 5 个 substep,按 spec.Idx 顺序连续编号;spec.Idx 从 1 起。
+        return SubstepIdBase + (orderIdx - 1) * L2Def.Length + substepIdx + 1;
+    }
+
+    /// <summary>OPINION_REVIEW(sort_no=1)在 100 行 substep 中的全局 substep_id。保留以兼容现有调用。</summary>
     public static long OpinionReviewSubstepId(int orderIdx)
     {
-        // 每单 5 个 substep,OPINION_REVIEW 是第 1 个(sort_no=1)。
-        // seq 顺序:(idx=1 的 5 个) → (idx=2 的 5 个) → ...
-        // 第 idx 单的 OPINION_REVIEW seq = (idx - 1) * 5 + 1。
-        return SubstepIdBase + (orderIdx - 1) * 5L + 1L;
+        return SubstepIdAt(orderIdx, 0);
     }
 }

+ 82 - 69
server/Plugins/Admin.NET.Plugin.AiDOP/SeedData/S8OrderFlowSubstepUnitSeedData.cs

@@ -3,9 +3,10 @@ using Admin.NET.Plugin.AiDOP.Entity.S8.OrderFlow;
 namespace Admin.NET.Plugin.AiDOP.SeedData;
 
 /// <summary>
-/// ORDER-FLOW-S8-INTEGRATED-DOMAIN-RESET-1 t2b:OPINION_REVIEW 子节点下 L3 责任单元种子。
-/// 20 单 × 4 责任单元 = 80 行,全部挂在 substep_code = OPINION_REVIEW;SO-2026-001 按 PPT 第 2 页真值,
-/// 其余 19 单按 (orderIdx, unitIdx) 派生 multiplier,确定性、可重入。
+/// S8-ORDER-EXECUTION-ORDER-REVIEW-SUBSTEP-PERSIST-1:ORDER_REVIEW_PLAN_CALC 阶段 L3 责任单元种子。
+/// 20 单 × 5 L2 × N 责任单元(4+3+3+3+2 = 15 / 单)= 300 行;每条 L3 由父 L2 的 piHours / actualHours 按
+/// 部门权重严格回卷生成(合计 = 父 L2 同字段),actualHours 缺失时全部 pending。
+/// 责任单元仅使用既有真实业务部门:法律事务部 / 技术售前组 / 综合主计划 / 试验站 / 市场部 / 研发中心。
 /// </summary>
 [IncreSeed]
 public class S8OrderFlowSubstepUnitSeedData : ISqlSugarEntitySeedData<AdoS8OrderFlowSubstepUnit>
@@ -17,37 +18,47 @@ internal static class S8OrderFlowSubstepUnitDataset
 {
     internal const long UnitIdBase = 1329909130000L;
     internal const string FlowCode = "ORDER_REVIEW_PLAN_CALC";
-    internal const string SubstepCode = "OPINION_REVIEW";
 
-    /// <summary>L3 四方责任单元固定顺序与 PI 基线工时。</summary>
-    internal static readonly (string Code, string Name, decimal PiHours)[] L3Def =
-    {
-        ("LEGAL",        "法律事务部",  2m),
-        ("TECH_PRESALE", "技术售前组",  3m),
-        ("GENERAL_PLAN", "综合主计划",  1m),
-        ("LAB",          "试验站",      2m),
-    };
-
-    /// <summary>SO-2026-001 PPT 第 2 页 L3 真值:actual_hours / status,与 L3Def 同序。</summary>
-    private static readonly (decimal Actual, string Status)[] Ppt001L3 =
-    {
-        (2.0m, "green"),
-        (9.2m, "red"),
-        (1.2m, "yellow"),
-        (2.0m, "green"),
-    };
+    /// <summary>L3 责任单元定义。</summary>
+    internal readonly record struct UnitDef(string Code, string Name, decimal Weight);
 
     /// <summary>
-    /// 19 个非 PPT 单的 L3 派生 multiplier 桶
-    /// bucketIdx = (idx - 2 + unitIdx) mod 4,对应 4 种 (multiplier, status) 组合
-    /// status 满足:multiplier ≤ 1.00 = green;1.00 &lt; multiplier ≤ 1.25 = yellow;&gt; 1.25 = red
+    /// 5 个 L2 子节点的 L3 责任单元模板:每个 substep_code → 责任单元列表(含权重,合计 1.00)。
+    /// UnitCode 既有 4 项(LEGAL / TECH_PRESALE / GENERAL_PLAN / LAB),新增 2 项(MARKETING / RND)。
+    /// 部门名全部来自既有 stage-meta / 既有 seed,未引入新部门。
     /// </summary>
-    private static readonly (decimal Mult, string Status)[] L3Buckets =
+    internal static readonly Dictionary<string, UnitDef[]> L3Template = new()
     {
-        (0.95m, "green"),
-        (1.15m, "yellow"),
-        (1.35m, "red"),
-        (1.05m, "yellow"),
+        ["OPINION_REVIEW"] = new[]
+        {
+            new UnitDef("LEGAL",        "法律事务部", 0.30m),
+            new UnitDef("TECH_PRESALE", "技术售前组", 0.35m),
+            new UnitDef("GENERAL_PLAN", "综合主计划", 0.15m),
+            new UnitDef("LAB",          "试验站",     0.20m),
+        },
+        ["OPINION_FEEDBACK"] = new[]
+        {
+            new UnitDef("MARKETING",    "市场部",     0.40m),
+            new UnitDef("TECH_PRESALE", "技术售前组", 0.35m),
+            new UnitDef("GENERAL_PLAN", "综合主计划", 0.25m),
+        },
+        ["SECOND_REVIEW"] = new[]
+        {
+            new UnitDef("TECH_PRESALE", "技术售前组", 0.40m),
+            new UnitDef("RND",          "研发中心",   0.35m),
+            new UnitDef("GENERAL_PLAN", "综合主计划", 0.25m),
+        },
+        ["LEADER_REVIEW"] = new[]
+        {
+            new UnitDef("MARKETING",    "市场部",     0.35m),
+            new UnitDef("RND",          "研发中心",   0.40m),
+            new UnitDef("GENERAL_PLAN", "综合主计划", 0.25m),
+        },
+        ["CONTRACT_SEAL"] = new[]
+        {
+            new UnitDef("LEGAL",        "法律事务部", 0.70m),
+            new UnitDef("MARKETING",    "市场部",     0.30m),
+        },
     };
 
     public static IEnumerable<AdoS8OrderFlowSubstepUnit> BuildUnits()
@@ -56,51 +67,53 @@ internal static class S8OrderFlowSubstepUnitDataset
         foreach (var spec in S8OrderFlowDataset.Specs)
         {
             var orderId = S8OrderFlowDataset.OrderIdBase + spec.Idx;
-            var substepId = S8OrderFlowSubstepDataset.OpinionReviewSubstepId(spec.Idx);
-            var isPpt = spec.OrderCode == "SO-2026-001";
-            var scenario = isPpt ? "PPT" : "DEMO";
+            var scenario = spec.OrderCode == "SO-2026-001" ? "PPT" : "DEMO";
+            var l2Rows = S8OrderFlowSubstepDataset.BuildL2Rows(spec);
 
-            for (var i = 0; i < L3Def.Length; i++)
+            for (var substepIdx = 0; substepIdx < S8OrderFlowSubstepDataset.L2Def.Length; substepIdx++)
             {
-                var (code, name, piHours) = L3Def[i];
-                decimal actual;
-                string status;
+                var (substepCode, _, _) = S8OrderFlowSubstepDataset.L2Def[substepIdx];
+                var template = L3Template[substepCode];
+                var substepId = S8OrderFlowSubstepDataset.SubstepIdAt(spec.Idx, substepIdx);
+                var l2Row = l2Rows[substepIdx];
 
-                if (isPpt)
-                {
-                    actual = Ppt001L3[i].Actual;
-                    status = Ppt001L3[i].Status;
-                }
-                else
-                {
-                    var bucketIdx = ((spec.Idx - 2) + i) % L3Buckets.Length;
-                    var bucket = L3Buckets[bucketIdx];
-                    actual = decimal.Round(piHours * bucket.Mult, 1);
-                    status = bucket.Status;
-                }
+                var weights = template.Select(u => u.Weight).ToArray();
+                var piHoursArr = S8OrderFlowSubstepDataset.AllocateByWeight(l2Row.PiHours, weights);
+                decimal[]? actualHoursArr = l2Row.ActualHours == null
+                    ? null
+                    : S8OrderFlowSubstepDataset.AllocateByWeight(l2Row.ActualHours.Value, weights);
 
-                yield return new AdoS8OrderFlowSubstepUnit
+                for (var i = 0; i < template.Length; i++)
                 {
-                    Id = UnitIdBase + (++seq),
-                    SubstepId = substepId,
-                    OrderId = orderId,
-                    OrderCode = spec.OrderCode,
-                    OrderFlowCode = FlowCode,
-                    SubstepCode = SubstepCode,
-                    UnitCode = code,
-                    UnitName = name,
-                    PiHours = piHours,
-                    ActualHours = actual,
-                    Status = status,
-                    SortNo = i + 1,
-                    ScenarioCode = scenario,
-                    DataSource = "SEED",
-                    TenantId = 1,
-                    FactoryId = 1,
-                    CreatedAt = S8OrderFlowDataset.CreatedAt,
-                    UpdatedAt = null,
-                    IsDeleted = false,
-                };
+                    var unit = template[i];
+                    var actual = actualHoursArr?[i];
+                    var status = actual == null
+                        ? "pending"
+                        : S8OrderFlowSubstepDataset.ClassifyByPi(actual.Value, piHoursArr[i]);
+
+                    yield return new AdoS8OrderFlowSubstepUnit
+                    {
+                        Id = UnitIdBase + (++seq),
+                        SubstepId = substepId,
+                        OrderId = orderId,
+                        OrderCode = spec.OrderCode,
+                        OrderFlowCode = FlowCode,
+                        SubstepCode = substepCode,
+                        UnitCode = unit.Code,
+                        UnitName = unit.Name,
+                        PiHours = piHoursArr[i],
+                        ActualHours = actual,
+                        Status = status,
+                        SortNo = i + 1,
+                        ScenarioCode = scenario,
+                        DataSource = "SEED",
+                        TenantId = 1,
+                        FactoryId = 1,
+                        CreatedAt = S8OrderFlowDataset.CreatedAt,
+                        UpdatedAt = null,
+                        IsDeleted = false,
+                    };
+                }
             }
         }
     }