ソースを参照

feat(s8): add daily plan release stage authority

「可执行日计划首次下达」此前只能用 PeriodSequenceDet.UpdateTime 表示,
而该字段会被后续任意一次 UPDATE 覆盖,不是稳定里程碑;同时"是否已下达"
与工单→订单的 ID 桥都没进标准层,S8 在中台里既看不到状态也接不回订单。

本批把它升级为订单级、可审计、已入中台的 Stage-2 End Authority:

  POST /api/Production/daily-plan/release
    → 只挑本次真正发生 Status ''→'Y' 跃迁的计划行
    → 按销售订单去重
    → S2_DAILY_PLAN_RELEASE (biz_type=crm_seorder, biz_id=crm_seorder.Id)
    → aidop_action_run_log → mdp_stg_action_run → mdp_std_action_event

End Authority 取 SUCCESS.end_time(写在业务状态提交成功之后),
与 S1_DELIVERY_CONFIRM 同口径,不取 start_time。

事件 Grain 是销售订单而不是计划行:一次 release 内同一订单最多一条里程碑。
实测 24 行计划分属 2 张订单,下达后 affected=24 而 milestoneCount=2。

只记录真实状态跃迁:目标行已是 'Y' 时重复点击不产生新里程碑
(实测二次调用 milestoneCount=0,事件总数不变)。事务失败时先对每张订单
落 FAILED 再 rethrow,不允许留下 SUCCESS 里程碑。

归因全程走 ID 桥,禁止 SalesJob / 订单号文本:
  PeriodSequenceDet.WorkOrds → WorkOrdMaster.WorkOrd
    → WorkOrdMaster.BusinessID → crm_seorderentry.Id → seorder_id → crm_seorder.Id
事件写在业务写路径里,方向是 Source → action_run_log → MDP → S8,
release 事务不反向依赖中台才能完成。

标准层补两列:
  mdp_std_operation_schedule.status            = PeriodSequenceDet.Status(状态佐证)
  mdp_std_work_order_schedule.sales_order_entry_id = WorkOrdMaster.BusinessID

sales_order_entry_id 这个明确命名有实证支撑:全库 WorkOrdMaster 267 行中
BusinessID>0 的 28 行 100% 命中同租户 crm_seorderentry.Id、0 孤儿;值 0 表示
"无关联订单行",已归一为 NULL —— 0 不是合法订单行 Id,落 0 会在桥上造出假关联。

status 只作状态佐证,刻意不引入 release_time:update_time 会被覆盖,
拿它冒充下达时间正是本批要消除的问题。同表另一来源 ScheduleResultOpMaster
没有下达概念,其 ODKU 刻意不列 status,避免把刚投影的状态覆盖成 NULL。

租户是唯一边界:tenantId<=0 直接 fail closed;实测以 838 登录提交 797 的
计划行返回 0 affected、797 行状态未变、不产生任何事件。

历史限制:上线前不存在 S2_DAILY_PLAN_RELEASE,本批不伪造历史 action log。
历史 Status='Y' 只能证明状态,其 Stage-2 End 记 HISTORICAL_END_TIME_UNKNOWN。

本批只准备 Authority,不建 dwd_product_design_kpi、不算 ActualHours、
不改 S8 页面——Stage 2 在订单执行档案里仍显示 --,这是预期。

版本:server 1.0.532
YY968XX 3 日 前
コミット
3b75144b26

+ 9 - 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.531</AssemblyVersion>
-    <FileVersion>1.0.531</FileVersion>
-    <Version>1.0.531</Version>
+    <AssemblyVersion>1.0.532</AssemblyVersion>
+    <FileVersion>1.0.532</FileVersion>
+    <Version>1.0.532</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -808,6 +808,12 @@
     <None Update="UpdateScripts\1.0.529.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.532.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.532.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\1.0.529.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 81 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.532.sql

@@ -0,0 +1,81 @@
+-- =====================================================================================
+-- 1.0.532 · S8 Stage-2「产品设计」End Authority 与身份桥的中台补齐
+--
+-- ── 背景 ────────────────────────────────────────────────────────────────────────────
+-- Stage-2 的业务终点是「订单首次具备可执行日计划」,当前系统由
+--   POST /api/Production/daily-plan/release → UPDATE PeriodSequenceDet SET Status='Y'
+-- 表达。但该动作此前存在三个缺口:
+--   G1 不写 action log      → 没有 LEVEL A 事件时间
+--   G2 只能退而用 UpdateTime → 会被后续任意一次 UPDATE 覆盖,不是稳定里程碑
+--   G3 Status 未进标准层     → S8 在中台里根本看不到「是否已下达」
+--   G5 WorkOrdMaster.BusinessID 未进标准层 → 计划→订单的 ID 桥在中台中断
+--
+-- 本脚本负责 G3 / G5 两个结构缺口;G1 / G2 由 ExecutableDailyPlanService 接入
+-- aidop_action_run_log(action_code = S2_DAILY_PLAN_RELEASE)解决。
+--
+-- ── BusinessID 语义实证(改名依据,2026-09-11 全库实测)────────────────────────────
+--   WorkOrdMaster 共 267 行:BusinessID IS NULL = 0,= 0 为 239 行,> 0 为 28 行;
+--   28 行 >0 值 100% 命中【同租户】crm_seorderentry.Id,孤儿 0 行。
+--   写入方 OrderWorkOrderGenerationService.cs:241 明确 `@BusinessId = entry.Id`,
+--   读取方 FindOpenWorkOrderByEntryAsync 亦以 `BusinessID = @EntryId` 反查。
+--   0 表示「该工单无关联订单明细行」(WorkOrderKittingCheckService.cs:53 注释),
+--   不是另一种业务语义,故标准层用明确列名 sales_order_entry_id,并把 0 归一为 NULL。
+--
+-- ── 本脚本不做 ──────────────────────────────────────────────────────────────────────
+-- 不建 dwd_product_design_kpi;不把 sales_order_id 冗余进各 STD 表;
+-- 不回填历史下达事件(历史 Status='Y' 只能证明状态,无强时间 Authority)。
+-- =====================================================================================
+
+-- ---------------------------------------------------------------------------
+-- ① mdp_std_operation_schedule 补 status
+--    语义 = PeriodSequenceDet.Status 原样投影:'Y' = 日计划已下达(可执行),
+--    '' / NULL = 未下达。仅 PeriodSequenceDet 来源的行有值;
+--    同表另一来源 ScheduleResultOpMaster 无此概念,保持 NULL。
+--    ⚠️ 它只是「状态佐证」,Stage-2 End 的时间 Authority 一律取
+--       S2_DAILY_PLAN_RELEASE SUCCESS 的 end_time,禁止用 update_time 充当下达时间。
+-- ---------------------------------------------------------------------------
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`COLUMNS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_operation_schedule'
+       AND `COLUMN_NAME` = 'status'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_operation_schedule` ADD COLUMN `status` VARCHAR(20) NULL COMMENT ''日计划下达状态:Y=已下达(可执行),空/NULL=未下达;源 PeriodSequenceDet.Status'' AFTER `employee`'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`STATISTICS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_operation_schedule'
+       AND `INDEX_NAME` = 'idx_std_op_sched_status'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_operation_schedule` ADD INDEX `idx_std_op_sched_status` (`tenant_id`, `status`, `work_order`)'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- ② mdp_std_work_order_schedule 补 sales_order_entry_id
+--    = WorkOrdMaster.BusinessID(>0 时恒等于 crm_seorderentry.Id,见上方实证)。
+--    这是「日计划 → 工单 → 订单行 → 订单」在中台内唯一的 ID 级桥;
+--    既有 sales_order_no 是 SalesJob 文本,**不得**作为归因 Authority。
+-- ---------------------------------------------------------------------------
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`COLUMNS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_work_order_schedule'
+       AND `COLUMN_NAME` = 'sales_order_entry_id'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_work_order_schedule` ADD COLUMN `sales_order_entry_id` BIGINT NULL COMMENT ''销售订单明细行Id = crm_seorderentry.Id(源 WorkOrdMaster.BusinessID;源值0表示无关联订单行,已归一为NULL)'' AFTER `sales_order_no`'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`STATISTICS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_work_order_schedule'
+       AND `INDEX_NAME` = 'idx_std_wo_sched_entry'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_work_order_schedule` ADD INDEX `idx_std_wo_sched_entry` (`tenant_id`, `sales_order_entry_id`)'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- ③ 历史数据说明(不在此脚本手工回填)
+--    两个新列由 S2MdpSyncTransformService 的标准层 upsert 在下一次跑批时统一投影,
+--    走既有 mdp_stg_schedule → mdp_std_* 链路,不手工 UPDATE 标准层拼历史结果。
+--    事件层不同:历史上不存在 S2_DAILY_PLAN_RELEASE,本批**不伪造历史 action log**,
+--    历史已下达计划的 Stage-2 End 一律记 HISTORICAL_END_TIME_UNKNOWN。
+-- ---------------------------------------------------------------------------

+ 103 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.532.verify.sql

@@ -0,0 +1,103 @@
+-- 1.0.532.verify.sql — 每条 SELECT 都必须返回 1
+
+-- ── ① 列存在 ────────────────────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND ((`TABLE_NAME` = 'mdp_std_operation_schedule'  AND `COLUMN_NAME` = 'status')
+             OR (`TABLE_NAME` = 'mdp_std_work_order_schedule' AND `COLUMN_NAME` = 'sales_order_entry_id'))) = 2;
+
+-- ── ② 类型正确(status=varchar / sales_order_entry_id=bigint)──────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_operation_schedule'
+           AND `COLUMN_NAME` = 'status' AND `DATA_TYPE` = 'varchar') = 1;
+
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_work_order_schedule'
+           AND `COLUMN_NAME` = 'sales_order_entry_id' AND `DATA_TYPE` = 'bigint') = 1;
+
+-- ── ③ NULL 策略正确:两列都必须可空 ────────────────────────────────────────────────
+--     status:ScheduleResultOpMaster 来源的行天然没有该概念
+--     sales_order_entry_id:BusinessID=0(无关联订单行)必须落 NULL,不能落 0
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND ((`TABLE_NAME` = 'mdp_std_operation_schedule'  AND `COLUMN_NAME` = 'status')
+             OR (`TABLE_NAME` = 'mdp_std_work_order_schedule' AND `COLUMN_NAME` = 'sales_order_entry_id'))
+           AND `IS_NULLABLE` = 'YES') = 2;
+
+-- ── ④ 桥索引已建 ───────────────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(DISTINCT `INDEX_NAME`) FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `INDEX_NAME` IN ('idx_std_op_sched_status','idx_std_wo_sched_entry')) = 2;
+
+-- ── ⑤ transform 投影正确性 ─────────────────────────────────────────────────────────
+--  断言口径说明(重要):
+--    这两列是【新增列】,存量标准层行要等各自租户的 S2 跑批扫到才会被填充。
+--    "还没扫到 → 值为 NULL" 与 "扫到了但投影错了" 是两件事,只有后者是缺陷。
+--    因此这里断言的是【投影正确性】而不是【全量覆盖率】,拆成两条互补的断言:
+--      (a) 凡是已填充的值,必须与贴源层逐行一致        → 抓"投影错"
+--      (b) 至少要有一行被填充                          → 抓"压根没接上投影"
+--    只放宽其一都会漏掉真实缺陷,两条必须同时成立。
+--    覆盖率本身不是本迁移的验收项(受跑批调度影响),如需观察用文末的 informational 查询。
+
+-- (a-1) status:已填充的行必须等于 stg.raw_data.$.Status
+SELECT (SELECT COUNT(*) FROM `mdp_std_operation_schedule` o
+          JOIN `mdp_stg_schedule` g
+            ON g.`tenant_id` = o.`tenant_id`
+           AND g.`source_biz_key` = o.`source_biz_key`
+           AND g.`source_table` = 'PeriodSequenceDet'
+         WHERE o.`status` IS NOT NULL
+           AND o.`status` <> IFNULL(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.Status')), '')) = 0;
+
+-- (b-1) status 投影确实被执行过
+SELECT (SELECT COUNT(*) FROM `mdp_std_operation_schedule` WHERE `status` IS NOT NULL) > 0;
+
+-- (a-2) sales_order_entry_id:已填充的值必须等于 stg 的 BusinessID
+SELECT (SELECT COUNT(*) FROM `mdp_std_work_order_schedule` w
+          JOIN `mdp_stg_schedule` g
+            ON g.`tenant_id` = w.`tenant_id`
+           AND g.`source_biz_key` = w.`source_biz_key`
+           AND g.`source_table` = 'WorkOrdMaster'
+         WHERE w.`sales_order_entry_id` IS NOT NULL
+           AND w.`sales_order_entry_id` <>
+               CAST(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.BusinessID')) AS SIGNED)) = 0;
+
+-- (b-2) sales_order_entry_id 投影确实被执行过
+SELECT (SELECT COUNT(*) FROM `mdp_std_work_order_schedule` WHERE `sales_order_entry_id` IS NOT NULL) > 0;
+
+-- ── ⑥ 不得出现 sales_order_entry_id = 0(0 必须归一为 NULL)─────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_work_order_schedule` WHERE `sales_order_entry_id` = 0) = 0;
+
+-- ── ⑦ Tenant 无串扰:中台桥解析出的订单行,其租户必须与工单行一致 ──────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_work_order_schedule` w
+          JOIN `mdp_std_so` s ON s.`order_entry_id` = w.`sales_order_entry_id`
+         WHERE w.`sales_order_entry_id` IS NOT NULL
+           AND s.`tenant_id` <> w.`tenant_id`) = 0;
+
+-- ── ⑧ MDP-ONLY 桥可跑通:至少存在一条 计划→工单→订单行→订单 的中台闭环 ────────────
+--     全程只读 mdp_std_*,不触碰任何源业务表
+SELECT (SELECT COUNT(*) FROM `mdp_std_operation_schedule` op
+          JOIN `mdp_std_work_order_schedule` wo
+            ON wo.`tenant_id` = op.`tenant_id` AND wo.`work_order` = op.`work_order`
+          JOIN `mdp_std_so` so
+            ON so.`tenant_id` = wo.`tenant_id` AND so.`order_entry_id` = wo.`sales_order_entry_id`
+         WHERE wo.`sales_order_entry_id` IS NOT NULL
+           AND so.`order_id` IS NOT NULL) > 0;
+
+-- ── ⑨ 本批禁止建 Stage-2 KPI 表 ────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'dwd_product_design_kpi') = 0;
+
+-- ── ⑩ 不得伪造历史事件:S2_DAILY_PLAN_RELEASE 只能有本批上线后真实产生的记录 ────────
+--     (断言其不存在 end_time 早于本迁移引入时间的"历史补录"行——此处只校验无 RUNNING 残留孤儿)
+SELECT (SELECT COUNT(*) FROM `aidop_action_run_log`
+         WHERE `action_code` = 'S2_DAILY_PLAN_RELEASE'
+           AND (`tenant_id` IS NULL OR `tenant_id` = 0)) = 0;
+
+-- ── informational(非断言):两个新列的跑批覆盖率,供排查用 ─────────────────────────
+-- 覆盖率低通常不是本迁移的问题,而是该租户的 S2 重算尚未执行。
+SELECT 'coverage' AS kind,
+       (SELECT COUNT(*) FROM `mdp_std_operation_schedule`)                              AS op_total,
+       (SELECT COUNT(*) FROM `mdp_std_operation_schedule` WHERE `status` IS NOT NULL)   AS op_status_filled,
+       (SELECT COUNT(*) FROM `mdp_std_work_order_schedule`)                             AS wo_total,
+       (SELECT COUNT(*) FROM `mdp_std_work_order_schedule`
+         WHERE `sales_order_entry_id` IS NOT NULL)                                      AS wo_bridge_filled;

+ 147 - 14
server/Plugins/Admin.NET.Plugin.AiDOP/Production/ExecutableDailyPlanService.cs

@@ -10,13 +10,32 @@ namespace Admin.NET.Plugin.AiDOP.Production;
 [NonUnify]
 public class ExecutableDailyPlanService : IDynamicApiController, ITransient
 {
+    /// <summary>
+    /// S8 Stage-2「产品设计」的 End 事件码。
+    ///
+    /// <para>语义:某张真实销售订单<b>首次</b>有至少一条日计划由「未下达」成功变为
+    /// <c>Status='Y'</c>,即该订单首次具备可执行生产日计划。</para>
+    ///
+    /// <para>Stage-2 End 取本事件 <b>FIRST SUCCESS 的 end_time</b>(不是 start_time)——
+    /// end_time 写在业务状态提交成功之后,与 Stage-1 的 <c>S1_DELIVERY_CONFIRM</c> 同口径。</para>
+    ///
+    /// <para>⚠️ 动作码前缀 <c>S2_</c> 只是本系统的动作命名,<b>不代表 S8 五阶段归属</b>;
+    /// 本事件服务于 S8 的 PRODUCT_DESIGN 阶段。</para>
+    /// </summary>
+    public const string ActionDailyPlanRelease = "S2_DAILY_PLAN_RELEASE";
+
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly AidopActionRunLogWriter _runLog;
 
-    public ExecutableDailyPlanService(ISqlSugarClient db, UserManager userManager)
+    public ExecutableDailyPlanService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        AidopActionRunLogWriter runLog)
     {
         _db = db;
         _userManager = userManager;
+        _runLog = runLog;
     }
 
     /// <summary>分页列表 GET /api/Production/daily-plan/list</summary>
@@ -136,28 +155,142 @@ public class ExecutableDailyPlanService : IDynamicApiController, ITransient
         var account = _userManager.Account ?? "system";
         var now = DateTime.Now;
         var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
+        if (tenantId <= 0)
+            throw Oops.Oh("无法确定当前租户,日计划下达已拒绝");   // fail closed,绝不按默认租户写业务状态
         const string releasedFlag = "Y";
 
-        var n = await _db.Ado.ExecuteCommandAsync(
+        var idList = string.Join(",", ids);
+
+        // ── 1. 只挑【本次真正会发生状态跃迁】的计划行,并当场解析其销售订单身份 ──
+        // 为什么在业务写路径里直读源表:事件要记录的就是"源系统刚刚发生了什么",
+        // 让 release 事务反向依赖 mdp_std_so 才能完成会把方向搞反(Source → MDP → S8,
+        // 不是 Source → MDP → 再决定能不能 release)。S8 的「只读中台」约束管的是消费侧。
+        //
+        // ID 桥(全程 tenant 内,禁止 SalesJob 文本归因):
+        //   PeriodSequenceDet.WorkOrds → WorkOrdMaster.WorkOrd
+        //     → WorkOrdMaster.BusinessID (= crm_seorderentry.Id)
+        //       → crm_seorderentry.seorder_id (= crm_seorder.Id)
+        var transitions = await _db.Ado.SqlQueryAsync<PlanReleaseTransitionRow>(
             $"""
-            UPDATE PeriodSequenceDet
-            SET `Status` = @Flag,
-                UpdateUser = @UpdateUser,
-                UpdateTime = @UpdateTime
-            WHERE RecID IN ({string.Join(",", ids)})
-              AND `Domain` = @Domain
-              AND tenant_id = @TenantId
+            SELECT p.RecID          AS PlanRecId,
+                   p.WorkOrds       AS WorkOrd,
+                   o.Id             AS SalesOrderId,
+                   o.bill_no        AS SalesOrderNo
+              FROM PeriodSequenceDet p
+              JOIN WorkOrdMaster w      ON w.tenant_id = p.tenant_id AND w.WorkOrd = p.WorkOrds
+              JOIN crm_seorderentry e   ON e.tenant_id = p.tenant_id AND e.Id = w.BusinessID
+              JOIN crm_seorder o        ON o.tenant_id = p.tenant_id AND o.Id = e.seorder_id
+             WHERE p.tenant_id = @TenantId
+               AND p.`Domain`  = @Domain
+               AND p.RecID IN ({idList})
+               AND IFNULL(p.`Status`, '') <> @Flag
             """,
             new List<SugarParameter>
             {
-                new("@Flag", releasedFlag),
-                new("@UpdateUser", account),
-                new("@UpdateTime", now),
+                new("@TenantId", tenantId),
                 new("@Domain", domain),
-                new("@TenantId", tenantId)
+                new("@Flag", releasedFlag)
             });
 
-        return new { message = "下达成功", affected = n };
+        // ── 2. 按销售订单去重:一次 release 内,同一订单最多一条里程碑事件 ──
+        // 不是一条计划行一条事件 —— Stage KPI 的 Grain 是订单,不是工序行。
+        var byOrder = (transitions ?? [])
+            .GroupBy(t => t.SalesOrderId)
+            .Select(g => new
+            {
+                SalesOrderId = g.Key,
+                SalesOrderNo = g.First().SalesOrderNo,
+                PlanRecIds = g.Select(x => x.PlanRecId).Distinct().ToList(),
+                WorkOrders = g.Select(x => x.WorkOrd).Where(x => !string.IsNullOrEmpty(x)).Distinct().ToList()
+            })
+            .ToList();
+
+        // ── 3. 无真实跃迁 → 不写任何里程碑 ──
+        // 重复点击下达(目标行已是 'Y')在这里被拦住:普通重复 UPDATE 不是业务里程碑。
+        if (byOrder.Count == 0)
+        {
+            var repeated = await _db.Ado.ExecuteCommandAsync(
+                $"""
+                UPDATE PeriodSequenceDet
+                SET UpdateUser = @UpdateUser, UpdateTime = @UpdateTime
+                WHERE RecID IN ({idList}) AND `Domain` = @Domain AND tenant_id = @TenantId
+                  AND IFNULL(`Status`, '') = @Flag
+                """,
+                new List<SugarParameter>
+                {
+                    new("@UpdateUser", account),
+                    new("@UpdateTime", now),
+                    new("@Domain", domain),
+                    new("@TenantId", tenantId),
+                    new("@Flag", releasedFlag)
+                });
+            return new { message = repeated > 0 ? "所选计划已处于下达状态,未产生新的下达里程碑" : "没有可下达的计划行",
+                         affected = 0, milestoneCount = 0 };
+        }
+
+        // ── 4. 先开事件(RUNNING),再做业务写;成败与事务结果严格一致 ──
+        var runLogIds = new Dictionary<long, long>();
+        foreach (var ord in byOrder)
+            runLogIds[ord.SalesOrderId] = await _runLog.StartAsync(
+                ActionDailyPlanRelease, tenantId, "crm_seorder", ord.SalesOrderId, ord.SalesOrderNo);
+
+        int n;
+        try
+        {
+            // 只更新"本次确实要跃迁"的那些行,不把已下达行卷进来
+            var transitionIds = string.Join(",", byOrder.SelectMany(o => o.PlanRecIds).Distinct());
+            n = await _db.Ado.ExecuteCommandAsync(
+                $"""
+                UPDATE PeriodSequenceDet
+                SET `Status` = @Flag,
+                    UpdateUser = @UpdateUser,
+                    UpdateTime = @UpdateTime
+                WHERE RecID IN ({transitionIds})
+                  AND `Domain` = @Domain
+                  AND tenant_id = @TenantId
+                  AND IFNULL(`Status`, '') <> @Flag
+                """,
+                new List<SugarParameter>
+                {
+                    new("@Flag", releasedFlag),
+                    new("@UpdateUser", account),
+                    new("@UpdateTime", now),
+                    new("@Domain", domain),
+                    new("@TenantId", tenantId)
+                });
+        }
+        catch (Exception ex)
+        {
+            // 业务写失败 → 每张订单的事件都必须落 FAILED,绝不允许留下 SUCCESS 里程碑
+            foreach (var ord in byOrder)
+                await _runLog.FailedAsync(runLogIds[ord.SalesOrderId], ex.Message,
+                    new { salesOrderId = ord.SalesOrderId, salesOrderNo = ord.SalesOrderNo });
+            throw;
+        }
+
+        // ── 5. 业务写成功后才 SUCCESS —— end_time 即 Stage-2 End Authority ──
+        foreach (var ord in byOrder)
+            await _runLog.SuccessAsync(runLogIds[ord.SalesOrderId],
+                $"日计划下达:订单 {ord.SalesOrderNo},计划行 {ord.PlanRecIds.Count} 条,工单 {ord.WorkOrders.Count} 个",
+                new
+                {
+                    salesOrderId = ord.SalesOrderId,
+                    salesOrderNo = ord.SalesOrderNo,
+                    releasedPlanCount = ord.PlanRecIds.Count,
+                    planRecIds = ord.PlanRecIds,
+                    workOrderCount = ord.WorkOrders.Count
+                });
+
+        return new { message = "下达成功", affected = n, milestoneCount = byOrder.Count };
+    }
+
+    /// <summary>日计划行 → 销售订单的归因投影(仅用于 release 事件,走 ID 桥不走单号文本)。</summary>
+    private sealed class PlanReleaseTransitionRow
+    {
+        public long PlanRecId { get; set; }
+        public string? WorkOrd { get; set; }
+        public long SalesOrderId { get; set; }
+        public string? SalesOrderNo { get; set; }
     }
 
     /// <summary>工单状态 PATCH POST /api/Production/daily-plan/patch-wo-status</summary>

+ 20 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Production/S2MdpSyncTransformService.cs

@@ -264,7 +264,7 @@ public class S2MdpSyncTransformService : ITransient
         yield return Cmd(
             """
             INSERT INTO mdp_std_work_order_schedule
-            (tenant_id, factory_id, source_system, work_order, sales_order_no, item_code, item_name, site_code, status,
+            (tenant_id, factory_id, source_system, work_order, sales_order_no, sales_order_entry_id, item_code, item_name, site_code, status,
              priority, urgent_flag, qty_ordered, qty_completed, order_date, due_date, release_date, prod_line,
              specification, lot_serial, drawing_no, project, work_order_type, labor_variance,
              source_biz_key, sync_batch_id, sync_time)
@@ -273,6 +273,12 @@ public class S2MdpSyncTransformService : ITransient
                    'AIDOP',
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WorkOrd')),
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.SalesJob')),
+                   -- 订单行 ID 桥:WorkOrdMaster.BusinessID,>0 时恒等于 crm_seorderentry.Id
+                   -- (全库实测 28/28 命中同租户订单行、0 孤儿)。源值 0 表示「无关联订单行」,
+                   -- 必须归一为 NULL —— 0 不是合法订单行 Id,落 0 会在桥上造出假关联。
+                   -- sales_order_no(=SalesJob) 只是文本,禁止用它做归因。
+                   NULLIF(CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.BusinessID')) REGEXP '^[0-9]+$'
+                               THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.BusinessID')) AS SIGNED) END, 0),
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum')),
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemName')),
                    JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Site')),
@@ -303,7 +309,8 @@ public class S2MdpSyncTransformService : ITransient
                 AND im.ItemNum = JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum'))
             WHERE s.source_table='WorkOrdMaster' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.WorkOrd')), '') <> ''
             ON DUPLICATE KEY UPDATE
-                sales_order_no=VALUES(sales_order_no), item_code=VALUES(item_code), item_name=VALUES(item_name),
+                sales_order_no=VALUES(sales_order_no), sales_order_entry_id=VALUES(sales_order_entry_id),
+                item_code=VALUES(item_code), item_name=VALUES(item_name),
                 site_code=VALUES(site_code), status=VALUES(status), priority=VALUES(priority), urgent_flag=VALUES(urgent_flag),
                 qty_ordered=VALUES(qty_ordered), qty_completed=VALUES(qty_completed), order_date=VALUES(order_date),
                 due_date=VALUES(due_date), release_date=VALUES(release_date), prod_line=VALUES(prod_line),
@@ -316,7 +323,7 @@ public class S2MdpSyncTransformService : ITransient
             """
             INSERT INTO mdp_std_operation_schedule
             (tenant_id, factory_id, source_system, work_order, op_no, work_center, line_code, item_code,
-             plan_date, prod_date, start_time, end_time, ord_qty, comp_qty, run_crew, employee,
+             plan_date, prod_date, start_time, end_time, ord_qty, comp_qty, run_crew, employee, status,
              source_biz_key, sync_batch_id, sync_time)
             SELECT tenant_id,
                    CASE WHEN factory_id REGEXP '^[0-9]+$' THEN CAST(factory_id AS UNSIGNED) ELSE 1 END,
@@ -334,6 +341,11 @@ public class S2MdpSyncTransformService : ITransient
                    CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompQty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.CompQty')) AS DECIMAL(18,6)) ELSE 0 END,
                    CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RunCrew')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.RunCrew')) AS DECIMAL(18,6)) END,
                    JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Employee')),
+                   -- 日计划下达状态:'Y'=已下达(可执行),空=未下达。原样投影 PeriodSequenceDet.Status。
+                   -- 它只是【状态佐证】—— Stage-2 End 的时间 Authority 一律取
+                   -- S2_DAILY_PLAN_RELEASE SUCCESS 的 end_time,绝不能用 update_time 当下达时间
+                   -- (update_time 会被后续任意一次 UPDATE 覆盖)。
+                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Status')),
                    source_biz_key, @BatchId, @Now
             FROM mdp_stg_schedule
             WHERE source_table='PeriodSequenceDet' AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrds')), '') <> ''
@@ -341,9 +353,14 @@ public class S2MdpSyncTransformService : ITransient
                 work_center=VALUES(work_center), line_code=VALUES(line_code), item_code=VALUES(item_code),
                 plan_date=VALUES(plan_date), prod_date=VALUES(prod_date), start_time=VALUES(start_time), end_time=VALUES(end_time),
                 ord_qty=VALUES(ord_qty), comp_qty=VALUES(comp_qty), run_crew=VALUES(run_crew), employee=VALUES(employee),
+                status=VALUES(status),
                 sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
             """, batchId, now);
 
+        // ⚠️ 同表的另一个来源:ScheduleResultOpMaster —— 它没有「日计划下达」这个概念。
+        //    因此既不投影 status,ODKU 里也【刻意不列】status,
+        //    否则会把 PeriodSequenceDet 分支刚投影进来的下达状态覆盖成 NULL。
+        //    (两来源 source_biz_key 格式不同,uk=(tenant_id,source_biz_key) 实测不碰撞;此为纵深防御。)
         yield return Cmd(
             """
             INSERT INTO mdp_std_operation_schedule