Selaa lähdekoodia

feat(t8-kpi): add S5/S6/S7 T8 KPI refresh foundation

S5-S6-S7-T8-KPI-DATA-FOUNDATION-REFRESH-1 + FIX-1 + FIX-2

- Add t8_v5 SqlServer read-only ConfigId for T8 cross-DB query.
- Add 1.0.178.sql with 9 dwd_t8_* tables, 9 KPI master upserts, and mdp_source/mdp_entity registration for T8.
- Add S5/S6/S7 manual refresh routes in AidopKanbanController.
- Add MaterialWarehouse, Manufacturing, and FinishedWarehouse transform services.
- Run Fang v5.4 KPI SQL through SqlSugar t8_v5 and upsert DWD/KPI values into DOP MySQL.
- Preserve empty denominators as NULL with denominator_status, avoiding fake zero values.
- Fix Snowflake id insertion for ado_s9_kpi_value_l1_day.
- Fix monthly KPI biz_date idempotency by normalizing DateTime to date.
- Bump backend version to 1.0.178 so AutoVersionUpdate applies 1.0.178.sql.

Smoke:
- dotnet build 0 Error.
- AutoVersionUpdate applied 1.0.178.sql.
- S5/S6/S7 refresh APIs returned 200 and run_log SUCCESS.
- Repeat refresh idempotency verified.
- S5/S6/S7 kanban pages verified with 0 console errors and 0 network 5xx/404.
YY968XX 1 päivä sitten
vanhempi
commit
6226439dd4

+ 25 - 0
server/Admin.NET.Application/Configuration/Database.json

@@ -52,6 +52,31 @@
           "EnableInitSeed": false, // 启用种子初始化(需要初始化字典/菜单数据时改 true)
           "EnableIncreSeed": false // 启用种子增量更新(只更新贴了特性[IncreSeed]的种子表)
         }
+      },
+      // T8 源库(SQL Server 只读)— S5/S6/S7 KPI 数据贴源同步用
+      // 接入边界:只读 SELECT;只在 refresh 作业里调用;禁止看板实时查 T8;禁止高频调度
+      // 所有 EnableInit*/EnableIncre* 强制 false:外部源库,DOP 不得对 T8 做任何 DDL / 种子写入
+      // 凭据来源:CLAUDE.md 「T8 源库连接信息(S5 / S6 / S7 KPI 对接用)」节
+      {
+        "ConfigId": "t8_v5",
+        "DbType": "SqlServer",
+        "DbNickName": "T8 源库(只读)",
+        "ConnectionString": "Server=39.105.125.212,29289;Database=t8_V5_09_56;User Id=dopsa;Password=&erp@2013!14.;TrustServerCertificate=true;Encrypt=false;Pooling=true;Min Pool Size=0;Max Pool Size=10;Connect Timeout=15;",
+        "DbSettings": {
+          "EnableInitDb": false, // T8 是外部源库,禁止 DOP 端建库
+          "EnableInitView": false, // 禁止 DOP 端建视图
+          "EnableDiffLog": false,
+          "EnableUnderLine": false,
+          "EnableConnEncrypt": false
+        },
+        "TableSettings": {
+          "EnableInitTable": false, // 禁止 DOP 端对 T8 做表结构 CodeFirst
+          "EnableIncreTable": false
+        },
+        "SeedSettings": {
+          "EnableInitSeed": false,
+          "EnableIncreSeed": false
+        }
       }
       //// 日志独立数据库配置
       //{

+ 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.177</AssemblyVersion>
-    <FileVersion>1.0.177</FileVersion>
-    <Version>1.0.177</Version>
+    <AssemblyVersion>1.0.178</AssemblyVersion>
+    <FileVersion>1.0.178</FileVersion>
+    <Version>1.0.178</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -130,6 +130,9 @@
     <None Update="UpdateScripts\1.0.177.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.178.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 343 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.178.sql

@@ -0,0 +1,343 @@
+-- =============================================================================
+-- 批次:S5-S6-S7-T8-KPI-DATA-FOUNDATION-REFRESH-1
+-- 范围:S5 物料仓储 / S6 生产执行 / S7 成品仓储 — T8 KPI 数据底座
+-- 边界:
+--   1. 只建 DOP 端 DWD 与元数据登记,不建 mdp_stg_t8_*(一期不做贴源层)
+--   2. 不动 T8 源库任何对象
+--   3. metric_value 保留 NULL 语义;分母缺失时禁止伪装 0
+--   4. 所有 INSERT 使用 ON DUPLICATE KEY UPDATE 保证幂等
+-- 上游 SQL:方老师 v5.4 KPI 字段对照表 J 列(DOP-T8字段对照表_v5.4(3) 的副本.xlsx)
+-- 字段反推依据:lwb/对接T8/T8-KPI字段对照表-按SQL重新梳理-v1.md
+-- 阶段 0 实测:lwb/对接T8/T8-KPI开发阶段0证据-v1.md
+-- =============================================================================
+
+-- -----------------------------------------------------------------------------
+-- A. 9 张 DWD 主题宽表(dwd_t8_<业务主题>,统一 t8 前缀以标识源系统)
+-- -----------------------------------------------------------------------------
+
+-- A.1 #16 S5 物料上线周期(日 T+1)
+CREATE TABLE IF NOT EXISTS `dwd_t8_material_online_cycle` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_date` DATE NOT NULL COMMENT '业务日期(refresh 跑数日期)',
+  `source_ztid` VARCHAR(64) NOT NULL COMMENT 'T8 账套(kc_tz_head.ztid)',
+  `item_code` VARCHAR(64) NOT NULL COMMENT 'T8 kc_tz_list.code 物料编码',
+  `online_date` DATETIME NULL COMMENT '配送到产线日期=lbs=生产领料最早 shtime',
+  `receipt_date` DATETIME NULL COMMENT '物料收货日期=lbs=采购入库最早 shtime',
+  `cycle_days` INT NULL COMMENT 'DATEDIFF(day, receipt_date, online_date);任一端 NULL 时 NULL',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_mat_online_cycle` (`tenant_id`,`factory_id`,`biz_date`,`source_ztid`,`item_code`),
+  KEY `idx_t8_mat_online_cycle_biz_date` (`biz_date`),
+  KEY `idx_t8_mat_online_cycle_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #16 物料上线周期 DWD';
+
+-- A.2 #17 S5 物料上线满足率(日 T+1)
+CREATE TABLE IF NOT EXISTS `dwd_t8_material_online_fulfillment` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_date` DATE NOT NULL,
+  `source_ztid` VARCHAR(64) NOT NULL,
+  `work_order_no` VARCHAR(64) NOT NULL COMMENT 'T8 kc_dd_head.noid = kc_tz_list.lynoid',
+  `before_kgdate_rows` INT NOT NULL DEFAULT 0 COMMENT '工单开工日期前完成物料上线行数(分子)',
+  `total_rows` INT NOT NULL DEFAULT 0 COMMENT '工单物料总行数(分母,来自 kc_dd_list_cllist)',
+  `fulfillment_rate` DECIMAL(18,4) NULL COMMENT '分母=0 时 NULL;不伪装 0',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_mat_online_full` (`tenant_id`,`factory_id`,`biz_date`,`source_ztid`,`work_order_no`),
+  KEY `idx_t8_mat_online_full_biz_date` (`biz_date`),
+  KEY `idx_t8_mat_online_full_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #17 物料上线满足率 DWD';
+
+-- A.3 #18 S5 物料仓储人效(月 M+1)
+CREATE TABLE IF NOT EXISTS `dwd_t8_material_warehouse_efficiency` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_month` CHAR(7) NOT NULL COMMENT 'YYYY-MM',
+  `source_ztid` VARCHAR(64) NOT NULL,
+  `period_start` DATE NOT NULL COMMENT '统计区间起(含)',
+  `period_end` DATE NOT NULL COMMENT '统计区间止(含)',
+  `online_qty` DECIMAL(18,4) NULL COMMENT 'SUM(kc_tz_list.slzx) where lbs=生产领料(分子)',
+  `warehouse_headcount` INT NULL COMMENT 'sys_pelist count where gw=仓管 zzzt=在职(分母)',
+  `efficiency` DECIMAL(18,4) NULL COMMENT 'online_qty/warehouse_headcount;分母 0 或 NULL 时 NULL',
+  `denominator_status` VARCHAR(32) NOT NULL DEFAULT 'OK' COMMENT 'OK | NO_HEADCOUNT | NO_NUMERATOR',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_mat_wh_eff` (`tenant_id`,`factory_id`,`biz_month`,`source_ztid`),
+  KEY `idx_t8_mat_wh_eff_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #18 物料仓储人效 DWD(人效类分母缺失保留 NULL)';
+
+-- A.4 #19 S5 品类物料库存周转(月 M+1,来源 TVF Rep_总账_存货_V3)
+CREATE TABLE IF NOT EXISTS `dwd_t8_material_inventory_turnover` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_month` CHAR(7) NOT NULL,
+  `source_ztid` VARCHAR(64) NOT NULL,
+  `period_start_yyyymm` CHAR(6) NOT NULL COMMENT 'TVF 入参起期 YYYYMM',
+  `period_end_yyyymm` CHAR(6) NOT NULL COMMENT 'TVF 入参止期 YYYYMM',
+  `warehouse_code` VARCHAR(64) NULL COMMENT 'TVF.ckcode',
+  `warehouse_name` VARCHAR(200) NULL COMMENT 'TVF.ckname',
+  `item_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'TVF.code',
+  `item_name` VARCHAR(200) NULL COMMENT 'TVF.cname',
+  `category_code` VARCHAR(64) NULL COMMENT 'TVF.pcode',
+  `category_name` VARCHAR(200) NULL COMMENT 'TVF.pname',
+  `avg_inventory_value` DECIMAL(18,4) NULL COMMENT 'TVF.je3 月平均库存金额(D1)',
+  `monthly_outbound_cost` DECIMAL(18,4) NULL COMMENT 'TVF.je2 当月出库成本(D2)',
+  `turnover_days` DECIMAL(18,4) NULL COMMENT 'D1/D2 × 30;D2 NULL 或 0 时 NULL',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_mat_inv_turn` (`tenant_id`,`factory_id`,`biz_month`,`source_ztid`,`warehouse_code`,`item_code`),
+  KEY `idx_t8_mat_inv_turn_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #19 品类物料库存周转 DWD(TVF Rep_总账_存货_V3)';
+
+-- A.5 #22 S6 工单制造满足率(日 T+1)
+CREATE TABLE IF NOT EXISTS `dwd_t8_work_order_mfg_fulfillment` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_date` DATE NOT NULL,
+  `source_ztid` VARCHAR(64) NOT NULL,
+  `order_no` VARCHAR(64) NOT NULL COMMENT 'T8 kc_dd_head.noid 工单号',
+  `task_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'T8 kc_dd_list.rwnoid 任务号',
+  `item_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT 'T8 物料编码',
+  `plan_qty` DECIMAL(18,4) NULL COMMENT 'kc_dd_list.sl 计划数量(分母)',
+  `done_qty_in_window` DECIMAL(18,4) NULL COMMENT 'SUM(d.slzx) 计划完工时间内累计报工数量(分子)',
+  `fulfillment_rate` DECIMAL(18,4) NULL COMMENT 'done/plan;plan<=0 或 NULL 时 NULL',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_wo_mfg_full` (`tenant_id`,`factory_id`,`biz_date`,`source_ztid`,`order_no`,`task_no`,`item_code`),
+  KEY `idx_t8_wo_mfg_full_biz_date` (`biz_date`),
+  KEY `idx_t8_wo_mfg_full_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #22 工单制造满足率 DWD';
+
+-- A.6 #23 S6 工单制造人效(月 M+1)
+CREATE TABLE IF NOT EXISTS `dwd_t8_work_order_mfg_efficiency` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_month` CHAR(7) NOT NULL,
+  `source_ztid` VARCHAR(64) NOT NULL,
+  `period_start` DATE NOT NULL,
+  `period_end` DATE NOT NULL,
+  `done_count` INT NULL COMMENT 'count(*) where lbs=生产入库 且 (slzx>=sl OR gdyn=1)(分子)',
+  `production_headcount` INT NULL COMMENT 'sys_pelist count where gw=生产 zzzt=在职(分母)',
+  `efficiency` DECIMAL(18,4) NULL COMMENT 'done_count/production_headcount;分母 0 或 NULL 时 NULL',
+  `denominator_status` VARCHAR(32) NOT NULL DEFAULT 'OK',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_wo_mfg_eff` (`tenant_id`,`factory_id`,`biz_month`,`source_ztid`),
+  KEY `idx_t8_wo_mfg_eff_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #23 工单制造人效 DWD(人效类分母缺失保留 NULL)';
+
+-- A.7 #25 S7 订单发货周期(日 T+1,5 表 JOIN)
+CREATE TABLE IF NOT EXISTS `dwd_t8_order_shipment_cycle` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_date` DATE NOT NULL,
+  `source_ztid` VARCHAR(64) NOT NULL,
+  `order_no` VARCHAR(64) NOT NULL COMMENT 'T8 kc_dd_head.noid 销售订单号',
+  `cycle_days` INT NULL COMMENT 'datediff(day,min(shdate),max(shtime));仅含 min(wczt)=1 的订单',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_ord_ship_cycle` (`tenant_id`,`factory_id`,`biz_date`,`source_ztid`,`order_no`),
+  KEY `idx_t8_ord_ship_cycle_biz_date` (`biz_date`),
+  KEY `idx_t8_ord_ship_cycle_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #25 订单发货周期 DWD(5 表 JOIN)';
+
+-- A.8 #26 S7 订单发货满足率(日 T+1)
+CREATE TABLE IF NOT EXISTS `dwd_t8_order_shipment_fulfillment` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_date` DATE NOT NULL,
+  `source_ztid` VARCHAR(64) NOT NULL,
+  `order_no` VARCHAR(64) NOT NULL,
+  `total_rows` INT NOT NULL DEFAULT 0 COMMENT '该订单总行数',
+  `in_window_rows` INT NOT NULL DEFAULT 0 COMMENT '在确认交期前发货的行数',
+  `fulfillment_rate` DECIMAL(18,4) NULL COMMENT '(in_window/total)×100;total=0 时 NULL',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_ord_ship_full` (`tenant_id`,`factory_id`,`biz_date`,`source_ztid`,`order_no`),
+  KEY `idx_t8_ord_ship_full_biz_date` (`biz_date`),
+  KEY `idx_t8_ord_ship_full_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #26 订单发货满足率 DWD';
+
+-- A.9 #27 S7 成品仓储人效(月 M+1)
+CREATE TABLE IF NOT EXISTS `dwd_t8_finished_warehouse_efficiency` (
+  `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+  `tenant_id` BIGINT NOT NULL DEFAULT 0,
+  `factory_id` BIGINT NOT NULL DEFAULT 1,
+  `biz_month` CHAR(7) NOT NULL,
+  `source_ztid` VARCHAR(64) NOT NULL,
+  `period_start` DATE NOT NULL,
+  `period_end` DATE NOT NULL,
+  `shipment_qty` DECIMAL(18,4) NULL COMMENT 'SUM(b.slzx) where lbs=销售出库(分子)',
+  `warehouse_headcount` INT NULL COMMENT 'sys_pelist count where gw=仓管 zzzt=在职(分母,与 S5 共用枚举,待业务侧拆物料仓 vs 成品仓)',
+  `efficiency` DECIMAL(18,4) NULL COMMENT 'shipment_qty/warehouse_headcount;分母 0 或 NULL 时 NULL',
+  `denominator_status` VARCHAR(32) NOT NULL DEFAULT 'OK',
+  `batch_id` VARCHAR(100) NOT NULL,
+  `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_time` DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY `uk_t8_fin_wh_eff` (`tenant_id`,`factory_id`,`biz_month`,`source_ztid`),
+  KEY `idx_t8_fin_wh_eff_batch` (`batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='T8 #27 成品仓储人效 DWD(人效类分母缺失保留 NULL)';
+
+-- -----------------------------------------------------------------------------
+-- B. mdp_source 登记 T8 数据源(密码不入库,凭据在 Database.json + CLAUDE.md)
+-- -----------------------------------------------------------------------------
+
+INSERT INTO `mdp_source` (
+  `tenant_id`,`source_code`,`source_name`,`source_type`,`status`,
+  `db_type`,`db_host`,`db_port`,`db_name`,`db_user`,
+  `db_password_enc`,`db_extra_params`,
+  `remark`
+) VALUES (
+  0,'T8_V5_SQLSERVER','T8 一站式系统源库(SQL Server 只读)','DB',1,
+  'SQLServer','39.105.125.212',29289,'t8_V5_09_56','dopsa',
+  NULL,'ConfigId=t8_v5;TrustServerCertificate=true;Encrypt=false',
+  'S5/S6/S7 KPI 数据贴源同步用;只读 SELECT;凭据见 CLAUDE.md「T8 源库连接信息」与 Database.json'
+)
+ON DUPLICATE KEY UPDATE
+  `source_name`=VALUES(`source_name`),
+  `db_host`=VALUES(`db_host`),
+  `db_port`=VALUES(`db_port`),
+  `db_name`=VALUES(`db_name`),
+  `db_user`=VALUES(`db_user`),
+  `db_extra_params`=VALUES(`db_extra_params`),
+  `remark`=VALUES(`remark`),
+  `update_time`=CURRENT_TIMESTAMP;
+
+-- -----------------------------------------------------------------------------
+-- C. mdp_entity 登记 9 个 KPI 用到的 T8 表/TVF(target_table_name 一期为 NULL,不贴源)
+-- -----------------------------------------------------------------------------
+
+INSERT INTO `mdp_entity` (
+  `tenant_id`,`source_id`,`entity_code`,`entity_name`,`entity_type`,
+  `source_table_name`,`target_table_name`,`sync_mode`,`batch_size`,`status`,`remark`
+)
+SELECT 0, s.id, v.entity_code, v.entity_name, v.entity_type,
+       v.source_table_name, NULL, 'NONE', 0, 1, v.remark
+FROM `mdp_source` s
+CROSS JOIN (
+  SELECT 'T8_KC_TZ_HEAD'          AS entity_code, 'T8 出入库流水头表'                  AS entity_name, 'TABLE'    AS entity_type, 'kc_tz_head'        AS source_table_name, 'S5/S6/S7 KPI 共用;按 lbs 区分生产领料/采购入库/生产入库/销售出库' AS remark
+  UNION ALL SELECT 'T8_KC_TZ_LIST',          'T8 出入库流水明细', 'TABLE',    'kc_tz_list',        '与 kc_tz_head 关联,含 code/slzx/sl/lynoid 等'
+  UNION ALL SELECT 'T8_KC_DD_HEAD',          'T8 单据头表', 'TABLE',    'kc_dd_head',        'S6/S7 KPI 用;按 lbs 区分生产任务/销售订单'
+  UNION ALL SELECT 'T8_KC_DD_LIST',          'T8 单据明细', 'TABLE',    'kc_dd_list',        '与 kc_dd_head 关联,含 sl/jhdate/rwnoid'
+  UNION ALL SELECT 'T8_KC_DD_LIST_CLLIST',   'T8 单据物料行', 'TABLE',    'kc_dd_list_cllist', 'S5 #17 工单物料总行数'
+  UNION ALL SELECT 'T8_KC_ZJ_LIST',          'T8 检验/质检流水', 'TABLE',    'kc_zj_list',        'S7 #25 FQC 报检关联'
+  UNION ALL SELECT 'T8_CJ_BG_HEAD_REP',      'T8 员工报工头表', 'TABLE',    'Cj_Bg_Head_Rep',    'S5 #17 工单开工时间(kgdate)'
+  UNION ALL SELECT 'T8_SYS_PELIST',          'T8 人员主数据', 'TABLE',    'sys_pelist',        'S5/S6/S7 人效 KPI 分母(gw=仓管/生产;zzzt=在职)'
+  UNION ALL SELECT 'T8_REP_ZONGZHANG_CUNHUO_V3','T8 总账存货月报 TVF', 'TVF', 'Rep_总账_存货_V3', 'S5 #19 品类物料库存周转;返回月均库存金额 je3 与出库成本 je2'
+) v
+WHERE s.source_code='T8_V5_SQLSERVER' AND s.tenant_id=0
+ON DUPLICATE KEY UPDATE
+  `entity_name`=VALUES(`entity_name`),
+  `entity_type`=VALUES(`entity_type`),
+  `source_table_name`=VALUES(`source_table_name`),
+  `remark`=VALUES(`remark`),
+  `update_time`=CURRENT_TIMESTAMP;
+
+-- -----------------------------------------------------------------------------
+-- D. ado_smart_ops_kpi_master 注册 9 个 KPI(L1)
+--   - Direction 沿用既有枚举:lower_is_better / higher_is_better
+--   - YellowThreshold / RedThreshold 一期 NULL(业务侧上线前回填)
+--   - DataSource 普通表填 T8.dbo.<表名>;TVF 填 T8.dbo.Rep_总账_存货_V3
+--   - StatFrequency 沿用既有 "天" / "月" 中文风格
+-- -----------------------------------------------------------------------------
+
+INSERT INTO `ado_smart_ops_kpi_master` (
+  `MetricCode`,`ModuleCode`,`MetricLevel`,`ParentId`,`MetricName`,`Description`,`Formula`,`CalcRule`,
+  `DataSource`,`StatFrequency`,`Department`,`DopFields`,`Unit`,`Direction`,
+  `IsHomePage`,`SortNo`,`Remark`,`IsEnabled`,`TenantId`,`CreatedAt`,
+  `YellowThreshold`,`RedThreshold`
+) VALUES
+  ('S5_L1_001','S5',1,NULL,'物料上线周期',
+   'S5 物料仓储:物料从供应商收货入库到配送上线之间的天数差',
+   '物料上线周期 = 物料配送到生产线日期 - 物料收货日期',
+   '物料配送到生产线日期 = T8 kc_tz_head + kc_tz_list 中 lbs=生产领料的最早 shtime;物料收货日期 = lbs=采购入库的最早 shtime;按 code 分组取 MIN',
+   'T8.dbo.kc_tz_head','天','物料仓储',NULL,'天','lower_is_better',
+   0,510,'方老师 v5.4 KPI #16;T8 SQL 原逻辑直发(路径 A)',1,0,NOW(),
+   NULL,NULL),
+  ('S5_L1_002','S5',1,NULL,'物料上线满足率',
+   'S5 物料仓储:工单开工日期前完成物料上线行数占工单物料总行数比率',
+   'S = 工单开工日期前完成物料上线行数 / 工单物料总行数',
+   '分子=T8 kc_tz_head/list 中 lbs=生产领料 且 shtime <= 工单开工时间 kgdate(Cj_Bg_Head_Rep.noid);分母=kc_dd_head/kc_dd_list_cllist 中 lbs=生产任务 行数',
+   'T8.dbo.kc_tz_head','天','物料仓储',NULL,'%','higher_is_better',
+   0,520,'方老师 v5.4 KPI #17;分母为 0 时 fulfillment_rate 写 NULL',1,0,NOW(),
+   NULL,NULL),
+  ('S5_L1_003','S5',1,NULL,'物料仓储人效',
+   'S5 物料仓储:单位仓储人员完成的上线物料数量',
+   'H = 完成上线的工单物料数量 / 物料仓储人数',
+   '分子=统计期 SUM(kc_tz_list.slzx) where lbs=生产领料;分母=sys_pelist count where gw=仓管 AND zzzt=在职',
+   'T8.dbo.kc_tz_head','月','物料仓储',NULL,'个/人','higher_is_better',
+   0,530,'方老师 v5.4 KPI #18;当前 T8 中 gw 全空,分母为 NULL 时 efficiency 写 NULL(不伪装 0),denominator_status=NO_HEADCOUNT',1,0,NOW(),
+   NULL,NULL),
+  ('S5_L1_004','S5',1,NULL,'品类物料库存周转',
+   'S5 物料仓储:按品类的物料库存周转天数',
+   'D1 / D2 × 30',
+   'D1=月平均库存金额=TVF Rep_总账_存货_V3.je3;D2=当月出库成本=TVF Rep_总账_存货_V3.je2;TVF 入参:(账套, 普通, 正常, 起期 YYYYMM, 止期 YYYYMM)',
+   'T8.dbo.Rep_总账_存货_V3','月','物料仓储',NULL,'天','lower_is_better',
+   0,540,'方老师 v5.4 KPI #19;D2 为 0 或 NULL 时 turnover_days 写 NULL',1,0,NOW(),
+   NULL,NULL),
+  ('S6_L1_001','S6',1,NULL,'工单制造满足率',
+   'S6 生产执行:计划完工时间内累计报工数量占工单计划生产数量的比率',
+   '工单制造满足率 = 计划完工时间内累计报工数量 / 工单计划生产数量',
+   '分母=kc_dd_list.sl where lbs=生产任务;分子=SUM(kc_tz_list.slzx) where lbs=生产入库 AND shtime <= jhdate;按 (noid, rwnoid, code) 关联',
+   'T8.dbo.kc_dd_head','天','生产执行',NULL,'%','higher_is_better',
+   0,610,'方老师 v5.4 KPI #22;plan_qty<=0 或 NULL 时 fulfillment_rate 写 NULL',1,0,NOW(),
+   NULL,NULL),
+  ('S6_L1_002','S6',1,NULL,'工单制造人效',
+   'S6 生产执行:单位生产人员完成的工单数量',
+   '工单制造人效 = 统计期内完成制造的工单数量 / 生产人数',
+   '分子=count(*) from kc_tz_head/list where lbs=生产入库 AND date0 在窗口 AND (slzx>=sl OR gdyn=1);分母=sys_pelist count where gw=生产 AND zzzt=在职',
+   'T8.dbo.kc_tz_head','月','生产执行',NULL,'个/人','higher_is_better',
+   0,620,'方老师 v5.4 KPI #23;当前 T8 中 gw 全空,分母为 NULL 时 efficiency 写 NULL(不伪装 0)',1,0,NOW(),
+   NULL,NULL),
+  ('S7_L1_001','S7',1,NULL,'订单发货周期',
+   'S7 成品仓储:订单最后一行物料发货日期与最早一行物料 FQC 报检日期的天数差',
+   '订单发货周期 = 订单最后一行物料发货日期 - 订单最早一行物料 FQC 报检日期',
+   '基于 kc_dd_head/list + kc_tz_head/list + kc_zj_list 5 表 JOIN,按 noid 分组取 datediff(day,min(shdate),max(shtime));仅保留 min(wczt)=1 的订单',
+   'T8.dbo.kc_dd_head','天','成品仓储',NULL,'天','lower_is_better',
+   0,710,'方老师 v5.4 KPI #25;最复杂 5 表 JOIN',1,0,NOW(),
+   NULL,NULL),
+  ('S7_L1_002','S7',1,NULL,'订单发货满足率',
+   'S7 成品仓储:在确认交期前发货的行数占订单总行数的比率',
+   '订单发货满足率 = (在确认交期前发货的行数 / 该订单总行数) × 100%',
+   '按 (noid, rwnoid, code) 关联,wczt = case when SUM(kc_tz_list.slzx where lbs=销售出库 AND shtime<=jhdate) >= kc_dd_list.sl then 1 else 0',
+   'T8.dbo.kc_dd_head','天','成品仓储',NULL,'%','higher_is_better',
+   0,720,'方老师 v5.4 KPI #26;total_rows=0 时 fulfillment_rate 写 NULL',1,0,NOW(),
+   NULL,NULL),
+  ('S7_L1_003','S7',1,NULL,'成品仓储人效',
+   'S7 成品仓储:单位仓储人员完成的发货物料数量',
+   'H = 完成发货的订单物料数量 / 成品仓储人数',
+   '分子=SUM(kc_tz_list.slzx) where lbs=销售出库 AND shtime 在窗口;分母=sys_pelist count where gw=仓管 AND zzzt=在职(与 S5 #18 共用 gw 枚举)',
+   'T8.dbo.kc_tz_head','月','成品仓储',NULL,'个/人','higher_is_better',
+   0,730,'方老师 v5.4 KPI #27;分母 NULL 或 0 时 efficiency 写 NULL(不伪装 0);gw 是否区分物料仓 vs 成品仓待业务侧确认',1,0,NOW(),
+   NULL,NULL)
+ON DUPLICATE KEY UPDATE
+  `MetricName`=VALUES(`MetricName`),
+  `Description`=VALUES(`Description`),
+  `Formula`=VALUES(`Formula`),
+  `CalcRule`=VALUES(`CalcRule`),
+  `DataSource`=VALUES(`DataSource`),
+  `StatFrequency`=VALUES(`StatFrequency`),
+  `Department`=VALUES(`Department`),
+  `Unit`=VALUES(`Unit`),
+  `Direction`=VALUES(`Direction`),
+  `SortNo`=VALUES(`SortNo`),
+  `Remark`=VALUES(`Remark`),
+  `IsEnabled`=VALUES(`IsEnabled`),
+  `UpdatedAt`=NOW();

+ 82 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -1,6 +1,9 @@
 using Admin.NET.Core;
 using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
+using Admin.NET.Plugin.AiDOP.Manufacturing;
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
 using Admin.NET.Plugin.AiDOP.Order;
 using Admin.NET.Plugin.AiDOP.ProcurementExecution;
 using Admin.NET.Plugin.AiDOP.Production;
@@ -21,6 +24,9 @@ public partial class AidopKanbanController : ControllerBase
     private readonly S2MdpSyncTransformService _s2MdpSyncTransformService;
     private readonly S3MdpSyncTransformService _s3MdpSyncTransformService;
     private readonly S4MdpSyncTransformService _s4MdpSyncTransformService;
+    private readonly S5MdpSyncTransformService _s5MdpSyncTransformService;
+    private readonly S6MdpSyncTransformService _s6MdpSyncTransformService;
+    private readonly S7MdpSyncTransformService _s7MdpSyncTransformService;
     private readonly SmartOpsKpiAtomicQueryService _atomicQuery;
 
     public AidopKanbanController(
@@ -29,6 +35,9 @@ public partial class AidopKanbanController : ControllerBase
         S2MdpSyncTransformService s2MdpSyncTransformService,
         S3MdpSyncTransformService s3MdpSyncTransformService,
         S4MdpSyncTransformService s4MdpSyncTransformService,
+        S5MdpSyncTransformService s5MdpSyncTransformService,
+        S6MdpSyncTransformService s6MdpSyncTransformService,
+        S7MdpSyncTransformService s7MdpSyncTransformService,
         SmartOpsKpiAtomicQueryService atomicQuery)
     {
         _db = db;
@@ -36,6 +45,9 @@ public partial class AidopKanbanController : ControllerBase
         _s2MdpSyncTransformService = s2MdpSyncTransformService;
         _s3MdpSyncTransformService = s3MdpSyncTransformService;
         _s4MdpSyncTransformService = s4MdpSyncTransformService;
+        _s5MdpSyncTransformService = s5MdpSyncTransformService;
+        _s6MdpSyncTransformService = s6MdpSyncTransformService;
+        _s7MdpSyncTransformService = s7MdpSyncTransformService;
         _atomicQuery = atomicQuery;
     }
 
@@ -394,6 +406,76 @@ LIMIT 60
         });
     }
 
+    /// <summary>
+    /// S5 物料仓储 KPI 手动刷新(T8 SQL Server 跨库读 → DOP DWD/KPI)。
+    /// 路径 A:方老师 v5.4 KPI #16/#17/#18/#19 原 SQL 直发 T8。
+    /// 边界:只读 SELECT;仅 refresh 内触发;禁止看板实时查 T8;禁止高频调度。
+    /// </summary>
+    [HttpPost("s5-mdp/refresh")]
+    public async Task<IActionResult> RefreshS5Mdp(CancellationToken cancellationToken)
+    {
+        var result = await _s5MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL");
+        return Ok(new
+        {
+            ok = true,
+            result.BatchId,
+            result.StageRows,
+            result.StandardRows,
+            result.DwdRows,
+            result.KpiRows,
+            result.PerKpiDwdRows,
+            result.PerKpiKpiRows,
+            result.KpiDenominatorStatus,
+            result.SourceZtid
+        });
+    }
+
+    /// <summary>
+    /// S6 生产执行 KPI 手动刷新(T8 SQL Server 跨库读 → DOP DWD/KPI)。
+    /// 路径 A:方老师 v5.4 KPI #22/#23 原 SQL 直发 T8。
+    /// </summary>
+    [HttpPost("s6-mdp/refresh")]
+    public async Task<IActionResult> RefreshS6Mdp(CancellationToken cancellationToken)
+    {
+        var result = await _s6MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL");
+        return Ok(new
+        {
+            ok = true,
+            result.BatchId,
+            result.StageRows,
+            result.StandardRows,
+            result.DwdRows,
+            result.KpiRows,
+            result.PerKpiDwdRows,
+            result.PerKpiKpiRows,
+            result.KpiDenominatorStatus,
+            result.SourceZtid
+        });
+    }
+
+    /// <summary>
+    /// S7 成品仓储 KPI 手动刷新(T8 SQL Server 跨库读 → DOP DWD/KPI)。
+    /// 路径 A:方老师 v5.4 KPI #25/#26/#27 原 SQL 直发 T8。
+    /// </summary>
+    [HttpPost("s7-mdp/refresh")]
+    public async Task<IActionResult> RefreshS7Mdp(CancellationToken cancellationToken)
+    {
+        var result = await _s7MdpSyncTransformService.RunFullAsync(cancellationToken, "MANUAL");
+        return Ok(new
+        {
+            ok = true,
+            result.BatchId,
+            result.StageRows,
+            result.StandardRows,
+            result.DwdRows,
+            result.KpiRows,
+            result.PerKpiDwdRows,
+            result.PerKpiKpiRows,
+            result.KpiDenominatorStatus,
+            result.SourceZtid
+        });
+    }
+
     [HttpPost("mdp/refresh/{moduleCode}")]
     public async Task<IActionResult> RefreshModuleMdp(string moduleCode, CancellationToken cancellationToken)
     {

+ 508 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/S7MdpSyncTransformService.cs

@@ -0,0 +1,508 @@
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+
+/// <summary>
+/// S7 成品仓储 — T8 KPI 数据底座与刷新转换服务。
+/// 路径 A:方老师 v5.4 KPI 字段对照表 J 列 SQL 原逻辑直发 T8 SQL Server(ConfigId=t8_v5)。
+/// 包含 KPI:S7_L1_001 订单发货周期 / S7_L1_002 订单发货满足率 / S7_L1_003 成品仓储人效。
+/// </summary>
+public class S7MdpSyncTransformService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private const string JobCode = "S7_MDP_SYNC_TRANSFORM";
+    private const string JobName = "S7 成品仓储 MDP 同步与转换";
+    private const string T8ConfigId = "t8_v5";
+    private const string ModuleCode = "S7";
+
+    public S7MdpSyncTransformService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<S7MdpSyncTransformResult> RunFullAsync(
+        CancellationToken cancellationToken = default,
+        string triggerType = "AUTO",
+        S7MdpRefreshOption? option = null)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        option ??= S7MdpRefreshOption.Default();
+
+        var now = DateTime.Now;
+        var batchId = $"S7_MDP_FULL_{now:yyyyMMddHHmmss}";
+        var normalizedTrigger = NormalizeTriggerType(triggerType);
+        var runLogId = await InsertTransformRunLogAsync(batchId, now, normalizedTrigger);
+
+        var result = new S7MdpSyncTransformResult
+        {
+            BatchId = batchId,
+            RunLogId = runLogId,
+            TriggerType = normalizedTrigger,
+            SourceZtid = option.SourceZtid,
+            BizDate = option.BizDate,
+            BizMonth = option.BizMonth,
+            MonthlyPeriodStart = option.MonthlyPeriodStart,
+            MonthlyPeriodEnd = option.MonthlyPeriodEnd
+        };
+
+        try
+        {
+            result.StageRows = 0;
+            result.StandardRows = 0;
+
+            var sub25 = await BuildS7L1001OrderShipmentCycleAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S7_L1_001", sub25);
+
+            var sub26 = await BuildS7L1002OrderShipmentFulfillmentAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S7_L1_002", sub26);
+
+            var sub27 = await BuildS7L1003FinishedWarehouseEfficiencyAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S7_L1_003", sub27);
+
+            await MarkTransformRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────
+
+    /// <summary>S7_L1_001 订单发货周期 = 最晚发货日期 - 最早 FQC 报检日期(5 表 JOIN)。</summary>
+    private async Task<KpiBuildSubResult> BuildS7L1001OrderShipmentCycleAsync(
+        string batchId, DateTime now, S7MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        const string sql = @"
+select noid as noid,
+       datediff(day, min(shdate), max(shtime)) as scts
+ from (
+   select a.noid as noid, b.code as code,
+          IsNull(c.shdate, b.addtime) as shdate,
+          (case when b.gdyn=1 then b.gdtime else d.shtime end) as shtime,
+          (case when b.gdyn=1 or b.sl<=b.slzx then 1 else 0 end) as wczt
+    from kc_dd_head a with(nolock)
+    left join kc_dd_list b with(nolock) on a.Id=b.idid
+    left join (
+      select min(b.id) as id, b.lynoid as lynoid, b.code as code,
+             max(a.shtime) as shtime, sum(b.slzx) as slzx
+       from kc_tz_head a with(nolock)
+       inner join kc_tz_list b with(nolock) on a.Id=b.idid
+       where a.ztid=@ztid and a.lbs='销售出库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
+       group by b.lynoid, b.code
+    ) d on b.rwnoid=d.lynoid and b.code=d.code
+    left join kc_zj_list c on a.ztid=c.ztid and c.lyid=b.id and c.zjyn=1
+    where a.ztid=@ztid and a.lbs='销售订单' and a.zf=0 and a.shyn=1
+ ) n
+ group by noid
+ having min(wczt)=1";
+
+        var rows = await QueryT8Async<S7CycleRow>(sql, new[] { new SugarParameter("@ztid", option.SourceZtid) });
+        sub.T8Rows = rows.Count;
+
+        var dwdAffected = 0;
+        var cycleList = new List<int>();
+        foreach (var r in rows)
+        {
+            ct.ThrowIfCancellationRequested();
+            if (string.IsNullOrEmpty(r.noid)) continue;
+            if (r.scts.HasValue) cycleList.Add(r.scts.Value);
+
+            dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_order_shipment_cycle
+  (tenant_id, factory_id, biz_date, source_ztid, order_no, cycle_days, batch_id, create_time)
+VALUES
+  (0, 1, @bizDate, @ztid, @orderNo, @cycleDays, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  cycle_days=VALUES(cycle_days),
+  batch_id=VALUES(batch_id), update_time=@now",
+                new SugarParameter("@bizDate", option.BizDate),
+                new SugarParameter("@ztid", option.SourceZtid),
+                new SugarParameter("@orderNo", r.noid),
+                new SugarParameter("@cycleDays", r.scts),
+                new SugarParameter("@batchId", batchId),
+                new SugarParameter("@now", now));
+        }
+        sub.DwdRows = dwdAffected;
+
+        decimal? metricValue = cycleList.Count > 0
+            ? Math.Round((decimal)cycleList.Average(), 4)
+            : null;
+        sub.KpiRows = await UpsertKpiValueAsync("S7_L1_001", option.BizDate, metricValue, now);
+        sub.DenominatorStatus = cycleList.Count > 0 ? "OK" : "NO_COMPLETED_ORDER";
+        return sub;
+    }
+
+    /// <summary>S7_L1_002 订单发货满足率 = (交期前发货行数 / 该订单总行数) × 100%。</summary>
+    private async Task<KpiBuildSubResult> BuildS7L1002OrderShipmentFulfillmentAsync(
+        string batchId, DateTime now, S7MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        const string sql = @"
+select noid as noid,
+       count(noid) as total_rows,
+       sum(wczt) as in_window_rows
+ from (
+   select a.noid as noid, b.rwnoid as rwnoid, b.code as code,
+          (case when sum(d.slzx)>=b.sl then 1 else 0 end) as wczt
+    from kc_dd_head a with(nolock)
+    left join kc_dd_list b with(nolock) on a.Id=b.idid
+    left join (
+      select b.lynoid as lynoid, b.code as code,
+             convert(varchar(10), a.shtime, 23) as shtime, b.slzx as slzx
+       from kc_tz_head a with(nolock)
+       inner join kc_tz_list b with(nolock) on a.Id=b.idid
+       where a.ztid=@ztid and a.lbs='销售出库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
+    ) d on b.rwnoid=d.lynoid and b.code=d.code and d.shtime<=b.jhdate
+    where a.ztid=@ztid and a.lbs='销售订单' and a.zf=0 and a.shyn=1
+    group by a.noid, b.rwnoid, b.code, b.sl
+ ) n
+ group by noid";
+
+        var rows = await QueryT8Async<S7FulfillmentRow>(sql, new[] { new SugarParameter("@ztid", option.SourceZtid) });
+        sub.T8Rows = rows.Count;
+
+        var dwdAffected = 0;
+        var rateList = new List<decimal>();
+        foreach (var r in rows)
+        {
+            ct.ThrowIfCancellationRequested();
+            if (string.IsNullOrEmpty(r.noid)) continue;
+            decimal? rate = (r.total_rows > 0)
+                ? Math.Round((decimal)r.in_window_rows / r.total_rows * 100m, 4)
+                : null;
+            if (rate.HasValue) rateList.Add(rate.Value);
+
+            dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_order_shipment_fulfillment
+  (tenant_id, factory_id, biz_date, source_ztid, order_no,
+   total_rows, in_window_rows, fulfillment_rate, batch_id, create_time)
+VALUES
+  (0, 1, @bizDate, @ztid, @orderNo,
+   @total, @inWindow, @rate, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  total_rows=VALUES(total_rows), in_window_rows=VALUES(in_window_rows),
+  fulfillment_rate=VALUES(fulfillment_rate),
+  batch_id=VALUES(batch_id), update_time=@now",
+                new SugarParameter("@bizDate", option.BizDate),
+                new SugarParameter("@ztid", option.SourceZtid),
+                new SugarParameter("@orderNo", r.noid),
+                new SugarParameter("@total", r.total_rows),
+                new SugarParameter("@inWindow", r.in_window_rows),
+                new SugarParameter("@rate", rate),
+                new SugarParameter("@batchId", batchId),
+                new SugarParameter("@now", now));
+        }
+        sub.DwdRows = dwdAffected;
+
+        decimal? metricValue = rateList.Count > 0
+            ? Math.Round(rateList.Average(), 4)
+            : null;
+        sub.KpiRows = await UpsertKpiValueAsync("S7_L1_002", option.BizDate, metricValue, now);
+        sub.DenominatorStatus = rateList.Count > 0 ? "OK" : "NO_VALID_ORDER";
+        return sub;
+    }
+
+    /// <summary>S7_L1_003 成品仓储人效 = SUM(slzx where lbs=销售出库) / count(gw=仓管)。</summary>
+    private async Task<KpiBuildSubResult> BuildS7L1003FinishedWarehouseEfficiencyAsync(
+        string batchId, DateTime now, S7MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        const string sqlNumer = @"
+select b.lynoid as lynoid, b.code as code,
+       convert(varchar(10), a.shtime, 23) as shtime, b.slzx as slzx
+ from kc_tz_head a with(nolock)
+ inner join kc_tz_list b with(nolock) on a.Id=b.idid
+ where a.ztid=@ztid and a.lbs='销售出库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
+  and convert(varchar(10), a.shtime, 23) between @startDateText and @endDateText";
+        const string sqlDenom = @"
+select count(*) as penum
+ from sys_pelist with(nolock)
+ where ztid=@ztid and zzzt='在职' and gw='仓管'";
+
+        var pNumer = new[]
+        {
+            new SugarParameter("@ztid", option.SourceZtid),
+            new SugarParameter("@startDateText", option.MonthlyPeriodStart.ToString("yyyy-MM-dd")),
+            new SugarParameter("@endDateText", option.MonthlyPeriodEnd.ToString("yyyy-MM-dd"))
+        };
+        var pDenom = new[] { new SugarParameter("@ztid", option.SourceZtid) };
+
+        var numerRows = await QueryT8Async<S7ShipmentDetailRow>(sqlNumer, pNumer);
+        var denomRows = await QueryT8Async<S7PeNumRow>(sqlDenom, pDenom);
+        sub.T8Rows = numerRows.Count + denomRows.Count;
+
+        decimal? shipmentQty = numerRows.Sum(r => r.slzx ?? 0m);
+        if (numerRows.Count == 0) shipmentQty = null;
+
+        int? headcount = denomRows.FirstOrDefault()?.penum;
+
+        decimal? efficiency = null;
+        string denomStatus;
+        if (!headcount.HasValue || headcount.Value <= 0)
+            denomStatus = "NO_HEADCOUNT";
+        else if (!shipmentQty.HasValue)
+            denomStatus = "NO_NUMERATOR";
+        else
+        {
+            efficiency = Math.Round(shipmentQty.Value / headcount.Value, 4);
+            denomStatus = "OK";
+        }
+        sub.DenominatorStatus = denomStatus;
+
+        var dwdAffected = await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_finished_warehouse_efficiency
+  (tenant_id, factory_id, biz_month, source_ztid, period_start, period_end,
+   shipment_qty, warehouse_headcount, efficiency, denominator_status, batch_id, create_time)
+VALUES
+  (0, 1, @bizMonth, @ztid, @periodStart, @periodEnd,
+   @shipmentQty, @headcount, @efficiency, @denomStatus, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  period_start=VALUES(period_start), period_end=VALUES(period_end),
+  shipment_qty=VALUES(shipment_qty), warehouse_headcount=VALUES(warehouse_headcount),
+  efficiency=VALUES(efficiency), denominator_status=VALUES(denominator_status),
+  batch_id=VALUES(batch_id), update_time=@now",
+            new SugarParameter("@bizMonth", option.BizMonth),
+            new SugarParameter("@ztid", option.SourceZtid),
+            new SugarParameter("@periodStart", option.MonthlyPeriodStart),
+            new SugarParameter("@periodEnd", option.MonthlyPeriodEnd),
+            new SugarParameter("@shipmentQty", shipmentQty),
+            new SugarParameter("@headcount", headcount),
+            new SugarParameter("@efficiency", efficiency),
+            new SugarParameter("@denomStatus", denomStatus),
+            new SugarParameter("@batchId", batchId),
+            new SugarParameter("@now", now));
+        sub.DwdRows = dwdAffected;
+
+        sub.KpiRows = await UpsertKpiValueAsync("S7_L1_003", option.MonthlyPeriodEnd, efficiency, now);
+        return sub;
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────
+
+    private async Task<List<T>> QueryT8Async<T>(string sql, SugarParameter[] parameters)
+    {
+        var t8 = _db.AsTenant().GetConnectionScope(T8ConfigId);
+        return await t8.Ado.SqlQueryAsync<T>(sql, parameters);
+    }
+
+    private async Task<int> UpsertKpiValueAsync(string metricCode, DateTime bizDate, decimal? metricValue, DateTime now)
+    {
+        // 沿用 S3 UpsertS3KpiValueAsync 范式:先查现存行 → UPDATE;不存在 → SELECT MAX(id)+1 显式生成 id 后 INSERT。
+        // ado_s9_kpi_value_l1_day.id 为手工分配主键(无 AUTO_INCREMENT),必须显式 set;
+        // metric_value 允许 NULL(分母缺失不得伪装真实 0)。
+        // FIX-2:截断时分秒(月度 KPI 入参可能为 YYYY-MM-DD 23:59:59),保证 SELECT WHERE biz_date=@BizDate 与 DB date 列匹配,避免重复 INSERT。
+        bizDate = bizDate.Date;
+        var existingId = await _db.Ado.GetLongAsync(
+            "SELECT IFNULL((SELECT id FROM ado_s9_kpi_value_l1_day WHERE tenant_id=0 AND factory_id=1 " +
+            "AND module_code=@ModuleCode AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0 " +
+            "ORDER BY id LIMIT 1), 0)",
+            new List<SugarParameter>
+            {
+                new("@ModuleCode", ModuleCode),
+                new("@MetricCode", metricCode),
+                new("@BizDate", bizDate)
+            });
+
+        if (existingId > 0)
+        {
+            return await _db.Ado.ExecuteCommandAsync(
+                "UPDATE ado_s9_kpi_value_l1_day SET metric_value=@MetricValue, calc_time=@Now, " +
+                "update_time=@Now, is_deleted=0, is_active=1 WHERE id=@Id",
+                new SugarParameter("@MetricValue", metricValue),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@Id", existingId));
+        }
+
+        var nextId = await _db.Ado.GetLongAsync(
+            "SELECT COALESCE(MAX(id), 0) + 1 FROM ado_s9_kpi_value_l1_day");
+        return await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO ado_s9_kpi_value_l1_day
+  (id, tenant_id, org_id, company_id, factory_id, status, biz_date,
+   create_time, update_time, is_deleted, is_active,
+   module_code, metric_code, metric_value, calc_time)
+VALUES
+  (@Id, 0, NULL, NULL, 1, NULL, @BizDate,
+   @Now, @Now, 0, 1,
+   @ModuleCode, @MetricCode, @MetricValue, @Now)",
+            new SugarParameter("@Id", nextId),
+            new SugarParameter("@BizDate", bizDate),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@ModuleCode", ModuleCode),
+            new SugarParameter("@MetricCode", metricCode),
+            new SugarParameter("@MetricValue", metricValue));
+    }
+
+    private async Task<long> InsertTransformRunLogAsync(string batchId, DateTime startedAt, string triggerType)
+    {
+        await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO mdp_transform_run_log
+  (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time, stage_rows, standard_rows, dwd_rows, create_time, update_time)
+VALUES
+  (0, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime, 0, 0, 0, @StartTime, @StartTime)",
+            new SugarParameter("@JobCode", JobCode),
+            new SugarParameter("@JobName", JobName),
+            new SugarParameter("@TriggerType", triggerType),
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@StartTime", startedAt));
+        return await _db.Ado.GetLongAsync(
+            "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
+            new List<SugarParameter> { new("@BatchId", batchId) });
+    }
+
+    private async Task MarkTransformRunSuccessAsync(long runLogId, DateTime startedAt, S7MdpSyncTransformResult result)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(@"
+UPDATE mdp_transform_run_log
+SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
+    stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=@DwdRows,
+    summary_json=@SummaryJson, update_time=CURRENT_TIMESTAMP
+WHERE id=@Id",
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@StageRows", result.StageRows),
+            new SugarParameter("@StandardRows", result.StandardRows),
+            new SugarParameter("@DwdRows", result.DwdRows),
+            new SugarParameter("@SummaryJson", JsonSerializer.Serialize(new
+            {
+                batchId = result.BatchId,
+                sourceZtid = result.SourceZtid,
+                bizDate = result.BizDate.ToString("yyyy-MM-dd"),
+                bizMonth = result.BizMonth,
+                dwdRows = result.DwdRows,
+                kpiRows = result.KpiRows,
+                perKpiDwdRows = result.PerKpiDwdRows,
+                perKpiKpiRows = result.PerKpiKpiRows,
+                denominatorStatus = result.KpiDenominatorStatus
+            })),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    {
+        try
+        {
+            var finishedAt = DateTime.Now;
+            await _db.Ado.ExecuteCommandAsync(@"
+UPDATE mdp_transform_run_log
+SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
+    error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
+WHERE id=@Id",
+                new SugarParameter("@EndTime", finishedAt),
+                new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+                new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
+                new SugarParameter("@Id", runLogId));
+        }
+        catch (Exception ex)
+        {
+            Console.Error.WriteLine($"[S7MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
+        }
+    }
+
+    private static string NormalizeTriggerType(string s) =>
+        string.IsNullOrWhiteSpace(s) ? "AUTO" : s.Trim().ToUpperInvariant();
+
+    private static string Truncate(string s, int max) =>
+        string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
+}
+
+// DTO ────────────────────────────────────────────────────────────────────────
+
+public sealed class S7MdpRefreshOption
+{
+    public string SourceZtid { get; set; } = "pbxfxp";
+    public DateTime BizDate { get; set; }
+    public string BizMonth { get; set; } = "";
+    public DateTime MonthlyPeriodStart { get; set; }
+    public DateTime MonthlyPeriodEnd { get; set; }
+
+    public static S7MdpRefreshOption Default()
+    {
+        var today = DateTime.Today;
+        var yesterday = today.AddDays(-1);
+        var lastMonth = today.AddMonths(-1);
+        var monthStart = new DateTime(lastMonth.Year, lastMonth.Month, 1);
+        var monthEnd = monthStart.AddMonths(1).AddDays(-1);
+        return new S7MdpRefreshOption
+        {
+            SourceZtid = "pbxfxp",
+            BizDate = yesterday,
+            BizMonth = lastMonth.ToString("yyyy-MM"),
+            MonthlyPeriodStart = monthStart,
+            MonthlyPeriodEnd = monthEnd.AddDays(1).AddSeconds(-1)
+        };
+    }
+}
+
+public sealed class S7MdpSyncTransformResult
+{
+    public string BatchId { get; set; } = "";
+    public long RunLogId { get; set; }
+    public string TriggerType { get; set; } = "AUTO";
+    public string SourceZtid { get; set; } = "";
+    public DateTime BizDate { get; set; }
+    public string BizMonth { get; set; } = "";
+    public DateTime MonthlyPeriodStart { get; set; }
+    public DateTime MonthlyPeriodEnd { get; set; }
+
+    public int StageRows { get; set; }
+    public int StandardRows { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+
+    public Dictionary<string, int> PerKpiDwdRows { get; } = new();
+    public Dictionary<string, int> PerKpiKpiRows { get; } = new();
+    public List<string> KpiDenominatorStatus { get; } = new();
+
+    public void MergeSub(string kpiCode, KpiBuildSubResult sub)
+    {
+        PerKpiDwdRows[kpiCode] = sub.DwdRows;
+        PerKpiKpiRows[kpiCode] = sub.KpiRows;
+        DwdRows += sub.DwdRows;
+        KpiRows += sub.KpiRows;
+        KpiDenominatorStatus.Add($"{kpiCode}:{sub.DenominatorStatus}");
+    }
+}
+
+public sealed class KpiBuildSubResult
+{
+    public int T8Rows { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+    public string DenominatorStatus { get; set; } = "OK";
+}
+
+// T8 result set 投影类型 ──────────────────────────────────────────────────────
+
+internal sealed class S7CycleRow
+{
+    public string? noid { get; set; }
+    public int? scts { get; set; }
+}
+
+internal sealed class S7FulfillmentRow
+{
+    public string? noid { get; set; }
+    public int total_rows { get; set; }
+    public int in_window_rows { get; set; }
+}
+
+internal sealed class S7ShipmentDetailRow
+{
+    public string? lynoid { get; set; }
+    public string? code { get; set; }
+    public string? shtime { get; set; }
+    public decimal? slzx { get; set; }
+}
+
+internal sealed class S7PeNumRow
+{
+    public int penum { get; set; }
+}

+ 425 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/S6MdpSyncTransformService.cs

@@ -0,0 +1,425 @@
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.Manufacturing;
+
+/// <summary>
+/// S6 生产执行 — T8 KPI 数据底座与刷新转换服务。
+/// 路径 A:方老师 v5.4 KPI 字段对照表 J 列 SQL 原逻辑直发 T8 SQL Server(ConfigId=t8_v5)。
+/// 包含 KPI:S6_L1_001 工单制造满足率 / S6_L1_002 工单制造人效。
+/// </summary>
+public class S6MdpSyncTransformService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private const string JobCode = "S6_MDP_SYNC_TRANSFORM";
+    private const string JobName = "S6 生产执行 MDP 同步与转换";
+    private const string T8ConfigId = "t8_v5";
+    private const string ModuleCode = "S6";
+
+    public S6MdpSyncTransformService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<S6MdpSyncTransformResult> RunFullAsync(
+        CancellationToken cancellationToken = default,
+        string triggerType = "AUTO",
+        S6MdpRefreshOption? option = null)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        option ??= S6MdpRefreshOption.Default();
+
+        var now = DateTime.Now;
+        var batchId = $"S6_MDP_FULL_{now:yyyyMMddHHmmss}";
+        var normalizedTrigger = NormalizeTriggerType(triggerType);
+        var runLogId = await InsertTransformRunLogAsync(batchId, now, normalizedTrigger);
+
+        var result = new S6MdpSyncTransformResult
+        {
+            BatchId = batchId,
+            RunLogId = runLogId,
+            TriggerType = normalizedTrigger,
+            SourceZtid = option.SourceZtid,
+            BizDate = option.BizDate,
+            BizMonth = option.BizMonth,
+            MonthlyPeriodStart = option.MonthlyPeriodStart,
+            MonthlyPeriodEnd = option.MonthlyPeriodEnd
+        };
+
+        try
+        {
+            result.StageRows = 0;
+            result.StandardRows = 0;
+
+            var sub22 = await BuildS6L1001WorkOrderMfgFulfillmentAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S6_L1_001", sub22);
+
+            var sub23 = await BuildS6L1002WorkOrderMfgEfficiencyAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S6_L1_002", sub23);
+
+            await MarkTransformRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────
+
+    /// <summary>S6_L1_001 工单制造满足率 = 计划完工时间内累计报工 / 工单计划生产数量。</summary>
+    private async Task<KpiBuildSubResult> BuildS6L1001WorkOrderMfgFulfillmentAsync(
+        string batchId, DateTime now, S6MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        const string sql = @"
+select a.noid as noid, b.rwnoid as rwnoid, b.code as code, b.sl as sl, sum(d.slzx) as slzx
+ from kc_dd_head a with(nolock)
+ left join kc_dd_list b with(nolock) on a.Id=b.idid
+ left join (
+   select b.lynoid as lynoid, b.code as code,
+          convert(varchar(10), a.shtime, 23) as shtime, b.slzx as slzx
+    from kc_tz_head a with(nolock)
+    inner join kc_tz_list b with(nolock) on a.Id=b.idid
+    where a.ztid=@ztid and a.lbs='生产入库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
+ ) d on b.rwnoid=d.lynoid and b.code=d.code
+ where a.ztid=@ztid and a.lbs='生产任务' and a.zf=0 and a.shyn=1 and d.shtime<=b.jhdate
+ group by a.noid, b.rwnoid, b.code, b.sl";
+
+        var rows = await QueryT8Async<S6MfgFulfillmentRow>(sql, new[] { new SugarParameter("@ztid", option.SourceZtid) });
+        sub.T8Rows = rows.Count;
+
+        var dwdAffected = 0;
+        var rateList = new List<decimal>();
+        foreach (var r in rows)
+        {
+            ct.ThrowIfCancellationRequested();
+            if (string.IsNullOrEmpty(r.noid)) continue;
+            decimal? rate = (r.sl.HasValue && r.sl.Value > 0m && r.slzx.HasValue)
+                ? Math.Round(r.slzx.Value / r.sl.Value, 4)
+                : null;
+            if (rate.HasValue) rateList.Add(rate.Value);
+
+            dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_work_order_mfg_fulfillment
+  (tenant_id, factory_id, biz_date, source_ztid, order_no, task_no, item_code,
+   plan_qty, done_qty_in_window, fulfillment_rate, batch_id, create_time)
+VALUES
+  (0, 1, @bizDate, @ztid, @orderNo, @taskNo, @itemCode,
+   @planQty, @doneQty, @rate, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  plan_qty=VALUES(plan_qty), done_qty_in_window=VALUES(done_qty_in_window),
+  fulfillment_rate=VALUES(fulfillment_rate),
+  batch_id=VALUES(batch_id), update_time=@now",
+                new SugarParameter("@bizDate", option.BizDate),
+                new SugarParameter("@ztid", option.SourceZtid),
+                new SugarParameter("@orderNo", r.noid),
+                new SugarParameter("@taskNo", r.rwnoid ?? ""),
+                new SugarParameter("@itemCode", r.code ?? ""),
+                new SugarParameter("@planQty", r.sl),
+                new SugarParameter("@doneQty", r.slzx),
+                new SugarParameter("@rate", rate),
+                new SugarParameter("@batchId", batchId),
+                new SugarParameter("@now", now));
+        }
+        sub.DwdRows = dwdAffected;
+
+        decimal? metricValue = rateList.Count > 0
+            ? Math.Round(rateList.Average() * 100m, 4) // 百分号
+            : null;
+        sub.KpiRows = await UpsertKpiValueAsync("S6_L1_001", option.BizDate, metricValue, now);
+        sub.DenominatorStatus = rateList.Count > 0 ? "OK" : "NO_VALID_WORK_ORDER";
+        return sub;
+    }
+
+    /// <summary>S6_L1_002 工单制造人效 = 完成制造工单数(lbs=生产入库 AND (slzx>=sl OR gdyn=1)) / count(gw=生产)。</summary>
+    private async Task<KpiBuildSubResult> BuildS6L1002WorkOrderMfgEfficiencyAsync(
+        string batchId, DateTime now, S6MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        const string sqlNumer = @"
+select count(*) as ddnum
+ from kc_tz_head a with(nolock)
+ inner join kc_tz_list b with(nolock) on a.Id=b.idid
+ where a.ztid=@ztid and a.lbs='生产入库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
+  and a.date0 between @startDate and @endDate
+  and b.slzx>0 and (b.slzx>=b.sl or b.gdyn=1)";
+        const string sqlDenom = @"
+select count(*) as penum
+ from sys_pelist with(nolock)
+ where ztid=@ztid and zzzt='在职' and gw='生产'";
+
+        var pNumer = new[]
+        {
+            new SugarParameter("@ztid", option.SourceZtid),
+            new SugarParameter("@startDate", option.MonthlyPeriodStart),
+            new SugarParameter("@endDate", option.MonthlyPeriodEnd)
+        };
+        var pDenom = new[] { new SugarParameter("@ztid", option.SourceZtid) };
+
+        var numerRows = await QueryT8Async<S6CountRow>(sqlNumer, pNumer);
+        var denomRows = await QueryT8Async<S6PeNumRow>(sqlDenom, pDenom);
+        sub.T8Rows = numerRows.Count + denomRows.Count;
+
+        int? doneCount = numerRows.FirstOrDefault()?.ddnum;
+        int? headcount = denomRows.FirstOrDefault()?.penum;
+
+        decimal? efficiency = null;
+        string denomStatus;
+        if (!headcount.HasValue || headcount.Value <= 0)
+            denomStatus = "NO_HEADCOUNT";
+        else if (!doneCount.HasValue)
+            denomStatus = "NO_NUMERATOR";
+        else
+        {
+            efficiency = Math.Round((decimal)doneCount.Value / headcount.Value, 4);
+            denomStatus = "OK";
+        }
+        sub.DenominatorStatus = denomStatus;
+
+        var dwdAffected = await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_work_order_mfg_efficiency
+  (tenant_id, factory_id, biz_month, source_ztid, period_start, period_end,
+   done_count, production_headcount, efficiency, denominator_status, batch_id, create_time)
+VALUES
+  (0, 1, @bizMonth, @ztid, @periodStart, @periodEnd,
+   @doneCount, @headcount, @efficiency, @denomStatus, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  period_start=VALUES(period_start), period_end=VALUES(period_end),
+  done_count=VALUES(done_count), production_headcount=VALUES(production_headcount),
+  efficiency=VALUES(efficiency), denominator_status=VALUES(denominator_status),
+  batch_id=VALUES(batch_id), update_time=@now",
+            new SugarParameter("@bizMonth", option.BizMonth),
+            new SugarParameter("@ztid", option.SourceZtid),
+            new SugarParameter("@periodStart", option.MonthlyPeriodStart),
+            new SugarParameter("@periodEnd", option.MonthlyPeriodEnd),
+            new SugarParameter("@doneCount", doneCount),
+            new SugarParameter("@headcount", headcount),
+            new SugarParameter("@efficiency", efficiency),
+            new SugarParameter("@denomStatus", denomStatus),
+            new SugarParameter("@batchId", batchId),
+            new SugarParameter("@now", now));
+        sub.DwdRows = dwdAffected;
+
+        sub.KpiRows = await UpsertKpiValueAsync("S6_L1_002", option.MonthlyPeriodEnd, efficiency, now);
+        return sub;
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────
+
+    private async Task<List<T>> QueryT8Async<T>(string sql, SugarParameter[] parameters)
+    {
+        var t8 = _db.AsTenant().GetConnectionScope(T8ConfigId);
+        return await t8.Ado.SqlQueryAsync<T>(sql, parameters);
+    }
+
+    private async Task<int> UpsertKpiValueAsync(string metricCode, DateTime bizDate, decimal? metricValue, DateTime now)
+    {
+        // 沿用 S3 UpsertS3KpiValueAsync 范式:先查现存行 → UPDATE;不存在 → SELECT MAX(id)+1 显式生成 id 后 INSERT。
+        // ado_s9_kpi_value_l1_day.id 为手工分配主键(无 AUTO_INCREMENT),必须显式 set;
+        // metric_value 允许 NULL(分母缺失不得伪装真实 0)。
+        // FIX-2:截断时分秒(月度 KPI 入参可能为 YYYY-MM-DD 23:59:59),保证 SELECT WHERE biz_date=@BizDate 与 DB date 列匹配,避免重复 INSERT。
+        bizDate = bizDate.Date;
+        var existingId = await _db.Ado.GetLongAsync(
+            "SELECT IFNULL((SELECT id FROM ado_s9_kpi_value_l1_day WHERE tenant_id=0 AND factory_id=1 " +
+            "AND module_code=@ModuleCode AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0 " +
+            "ORDER BY id LIMIT 1), 0)",
+            new List<SugarParameter>
+            {
+                new("@ModuleCode", ModuleCode),
+                new("@MetricCode", metricCode),
+                new("@BizDate", bizDate)
+            });
+
+        if (existingId > 0)
+        {
+            return await _db.Ado.ExecuteCommandAsync(
+                "UPDATE ado_s9_kpi_value_l1_day SET metric_value=@MetricValue, calc_time=@Now, " +
+                "update_time=@Now, is_deleted=0, is_active=1 WHERE id=@Id",
+                new SugarParameter("@MetricValue", metricValue),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@Id", existingId));
+        }
+
+        var nextId = await _db.Ado.GetLongAsync(
+            "SELECT COALESCE(MAX(id), 0) + 1 FROM ado_s9_kpi_value_l1_day");
+        return await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO ado_s9_kpi_value_l1_day
+  (id, tenant_id, org_id, company_id, factory_id, status, biz_date,
+   create_time, update_time, is_deleted, is_active,
+   module_code, metric_code, metric_value, calc_time)
+VALUES
+  (@Id, 0, NULL, NULL, 1, NULL, @BizDate,
+   @Now, @Now, 0, 1,
+   @ModuleCode, @MetricCode, @MetricValue, @Now)",
+            new SugarParameter("@Id", nextId),
+            new SugarParameter("@BizDate", bizDate),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@ModuleCode", ModuleCode),
+            new SugarParameter("@MetricCode", metricCode),
+            new SugarParameter("@MetricValue", metricValue));
+    }
+
+    private async Task<long> InsertTransformRunLogAsync(string batchId, DateTime startedAt, string triggerType)
+    {
+        await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO mdp_transform_run_log
+  (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time, stage_rows, standard_rows, dwd_rows, create_time, update_time)
+VALUES
+  (0, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime, 0, 0, 0, @StartTime, @StartTime)",
+            new SugarParameter("@JobCode", JobCode),
+            new SugarParameter("@JobName", JobName),
+            new SugarParameter("@TriggerType", triggerType),
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@StartTime", startedAt));
+        return await _db.Ado.GetLongAsync(
+            "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
+            new List<SugarParameter> { new("@BatchId", batchId) });
+    }
+
+    private async Task MarkTransformRunSuccessAsync(long runLogId, DateTime startedAt, S6MdpSyncTransformResult result)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(@"
+UPDATE mdp_transform_run_log
+SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
+    stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=@DwdRows,
+    summary_json=@SummaryJson, update_time=CURRENT_TIMESTAMP
+WHERE id=@Id",
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@StageRows", result.StageRows),
+            new SugarParameter("@StandardRows", result.StandardRows),
+            new SugarParameter("@DwdRows", result.DwdRows),
+            new SugarParameter("@SummaryJson", JsonSerializer.Serialize(new
+            {
+                batchId = result.BatchId,
+                sourceZtid = result.SourceZtid,
+                bizDate = result.BizDate.ToString("yyyy-MM-dd"),
+                bizMonth = result.BizMonth,
+                dwdRows = result.DwdRows,
+                kpiRows = result.KpiRows,
+                perKpiDwdRows = result.PerKpiDwdRows,
+                perKpiKpiRows = result.PerKpiKpiRows,
+                denominatorStatus = result.KpiDenominatorStatus
+            })),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    {
+        try
+        {
+            var finishedAt = DateTime.Now;
+            await _db.Ado.ExecuteCommandAsync(@"
+UPDATE mdp_transform_run_log
+SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
+    error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
+WHERE id=@Id",
+                new SugarParameter("@EndTime", finishedAt),
+                new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+                new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
+                new SugarParameter("@Id", runLogId));
+        }
+        catch (Exception ex)
+        {
+            Console.Error.WriteLine($"[S6MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
+        }
+    }
+
+    private static string NormalizeTriggerType(string s) =>
+        string.IsNullOrWhiteSpace(s) ? "AUTO" : s.Trim().ToUpperInvariant();
+
+    private static string Truncate(string s, int max) =>
+        string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
+}
+
+// DTO ────────────────────────────────────────────────────────────────────────
+
+public sealed class S6MdpRefreshOption
+{
+    public string SourceZtid { get; set; } = "pbxfxp";
+    public DateTime BizDate { get; set; }
+    public string BizMonth { get; set; } = "";
+    public DateTime MonthlyPeriodStart { get; set; }
+    public DateTime MonthlyPeriodEnd { get; set; }
+
+    public static S6MdpRefreshOption Default()
+    {
+        var today = DateTime.Today;
+        var yesterday = today.AddDays(-1);
+        var lastMonth = today.AddMonths(-1);
+        var monthStart = new DateTime(lastMonth.Year, lastMonth.Month, 1);
+        var monthEnd = monthStart.AddMonths(1).AddDays(-1);
+        return new S6MdpRefreshOption
+        {
+            SourceZtid = "pbxfxp",
+            BizDate = yesterday,
+            BizMonth = lastMonth.ToString("yyyy-MM"),
+            MonthlyPeriodStart = monthStart,
+            MonthlyPeriodEnd = monthEnd.AddDays(1).AddSeconds(-1)
+        };
+    }
+}
+
+public sealed class S6MdpSyncTransformResult
+{
+    public string BatchId { get; set; } = "";
+    public long RunLogId { get; set; }
+    public string TriggerType { get; set; } = "AUTO";
+    public string SourceZtid { get; set; } = "";
+    public DateTime BizDate { get; set; }
+    public string BizMonth { get; set; } = "";
+    public DateTime MonthlyPeriodStart { get; set; }
+    public DateTime MonthlyPeriodEnd { get; set; }
+
+    public int StageRows { get; set; }
+    public int StandardRows { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+
+    public Dictionary<string, int> PerKpiDwdRows { get; } = new();
+    public Dictionary<string, int> PerKpiKpiRows { get; } = new();
+    public List<string> KpiDenominatorStatus { get; } = new();
+
+    public void MergeSub(string kpiCode, KpiBuildSubResult sub)
+    {
+        PerKpiDwdRows[kpiCode] = sub.DwdRows;
+        PerKpiKpiRows[kpiCode] = sub.KpiRows;
+        DwdRows += sub.DwdRows;
+        KpiRows += sub.KpiRows;
+        KpiDenominatorStatus.Add($"{kpiCode}:{sub.DenominatorStatus}");
+    }
+}
+
+public sealed class KpiBuildSubResult
+{
+    public int T8Rows { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+    public string DenominatorStatus { get; set; } = "OK";
+}
+
+// T8 result set 投影类型 ──────────────────────────────────────────────────────
+
+internal sealed class S6MfgFulfillmentRow
+{
+    public string? noid { get; set; }
+    public string? rwnoid { get; set; }
+    public string? code { get; set; }
+    public decimal? sl { get; set; }
+    public decimal? slzx { get; set; }
+}
+
+internal sealed class S6CountRow
+{
+    public int ddnum { get; set; }
+}
+
+internal sealed class S6PeNumRow
+{
+    public int penum { get; set; }
+}

+ 664 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/S5MdpSyncTransformService.cs

@@ -0,0 +1,664 @@
+using System.Text.Json;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 物料仓储 — T8 KPI 数据底座与刷新转换服务。
+/// 路径 A:方老师 v5.4 KPI 字段对照表 J 列 SQL 原逻辑直发 T8 SQL Server(ConfigId=t8_v5);
+/// 一期不做 mdp_stg_t8_* 贴源层;结果直接落 dwd_t8_* 与 ado_s9_kpi_value_l1_day。
+/// 包含 KPI:S5_L1_001 物料上线周期 / S5_L1_002 物料上线满足率 /
+///          S5_L1_003 物料仓储人效 / S5_L1_004 品类物料库存周转。
+/// </summary>
+public class S5MdpSyncTransformService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private const string JobCode = "S5_MDP_SYNC_TRANSFORM";
+    private const string JobName = "S5 物料仓储 MDP 同步与转换";
+    private const string T8ConfigId = "t8_v5";
+    private const string ModuleCode = "S5";
+
+    public S5MdpSyncTransformService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public async Task<S5MdpSyncTransformResult> RunFullAsync(
+        CancellationToken cancellationToken = default,
+        string triggerType = "AUTO",
+        S5MdpRefreshOption? option = null)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        option ??= S5MdpRefreshOption.Default();
+
+        var now = DateTime.Now;
+        var batchId = $"S5_MDP_FULL_{now:yyyyMMddHHmmss}";
+        var normalizedTrigger = NormalizeTriggerType(triggerType);
+        var runLogId = await InsertTransformRunLogAsync(batchId, now, normalizedTrigger, option);
+
+        var result = new S5MdpSyncTransformResult
+        {
+            BatchId = batchId,
+            RunLogId = runLogId,
+            TriggerType = normalizedTrigger,
+            SourceZtid = option.SourceZtid,
+            BizDate = option.BizDate,
+            BizMonth = option.BizMonth,
+            DailyPeriodStart = option.DailyPeriodStart,
+            DailyPeriodEnd = option.DailyPeriodEnd,
+            MonthlyPeriodStart = option.MonthlyPeriodStart,
+            MonthlyPeriodEnd = option.MonthlyPeriodEnd
+        };
+
+        try
+        {
+            // 路径 A:直发 T8 SQL,不做 stg / std 中间层
+            result.StageRows = 0;
+            result.StandardRows = 0;
+
+            var sub16 = await BuildS5L1001MaterialOnlineCycleAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S5_L1_001", sub16);
+
+            var sub17 = await BuildS5L1002MaterialOnlineFulfillmentAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S5_L1_002", sub17);
+
+            var sub18 = await BuildS5L1003MaterialWarehouseEfficiencyAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S5_L1_003", sub18);
+
+            var sub19 = await BuildS5L1004MaterialInventoryTurnoverAsync(batchId, now, option, cancellationToken);
+            result.MergeSub("S5_L1_004", sub19);
+
+            await MarkTransformRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkTransformRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────
+    // KPI 实现(方老师 v5.4 KPI J 列 SQL 原逻辑直发 T8)
+    // ─────────────────────────────────────────────────────────────────────────
+
+    /// <summary>S5_L1_001 物料上线周期 = 配送到产线日期(lbs=生产领料) - 收货日期(lbs=采购入库)。</summary>
+    private async Task<KpiBuildSubResult> BuildS5L1001MaterialOnlineCycleAsync(
+        string batchId, DateTime now, S5MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        const string sqlOnline = @"
+select b.code as code, min(a.shtime) as shtime
+ from kc_tz_head a with(nolock)
+ inner join kc_tz_list b with(nolock) on a.Id=b.idid
+ where a.ztid=@ztid and a.lbs='生产领料' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
+ group by b.code";
+        const string sqlReceipt = @"
+select b.code as code, min(a.shtime) as shtime
+ from kc_tz_head a with(nolock)
+ inner join kc_tz_list b with(nolock) on a.Id=b.idid
+ where a.ztid=@ztid and a.lbs='采购入库' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
+ group by b.code";
+
+        var p = new[] { new SugarParameter("@ztid", option.SourceZtid) };
+        var onlineRows = await QueryT8Async<S5OnlineCycleRow>(sqlOnline, p);
+        var receiptRows = await QueryT8Async<S5OnlineCycleRow>(sqlReceipt, p);
+        sub.T8Rows = onlineRows.Count + receiptRows.Count;
+
+        var onlineByCode = onlineRows.Where(r => !string.IsNullOrEmpty(r.code))
+            .ToDictionary(r => r.code!, r => r.shtime, StringComparer.OrdinalIgnoreCase);
+        var receiptByCode = receiptRows.Where(r => !string.IsNullOrEmpty(r.code))
+            .ToDictionary(r => r.code!, r => r.shtime, StringComparer.OrdinalIgnoreCase);
+
+        var allCodes = new HashSet<string>(onlineByCode.Keys, StringComparer.OrdinalIgnoreCase);
+        allCodes.UnionWith(receiptByCode.Keys);
+
+        var dwdAffected = 0;
+        var cycleDaysList = new List<int>();
+        foreach (var code in allCodes)
+        {
+            ct.ThrowIfCancellationRequested();
+            var online = onlineByCode.GetValueOrDefault(code);
+            var receipt = receiptByCode.GetValueOrDefault(code);
+            int? cycleDays = null;
+            if (online.HasValue && receipt.HasValue)
+            {
+                cycleDays = (int)(online.Value.Date - receipt.Value.Date).TotalDays;
+                cycleDaysList.Add(cycleDays.Value);
+            }
+
+            dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_material_online_cycle
+  (tenant_id, factory_id, biz_date, source_ztid, item_code, online_date, receipt_date, cycle_days, batch_id, create_time)
+VALUES
+  (0, 1, @bizDate, @ztid, @itemCode, @online, @receipt, @cycleDays, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  online_date=VALUES(online_date), receipt_date=VALUES(receipt_date),
+  cycle_days=VALUES(cycle_days), batch_id=VALUES(batch_id), update_time=@now",
+                new SugarParameter("@bizDate", option.BizDate),
+                new SugarParameter("@ztid", option.SourceZtid),
+                new SugarParameter("@itemCode", code),
+                new SugarParameter("@online", online),
+                new SugarParameter("@receipt", receipt),
+                new SugarParameter("@cycleDays", cycleDays),
+                new SugarParameter("@batchId", batchId),
+                new SugarParameter("@now", now));
+        }
+        sub.DwdRows = dwdAffected;
+
+        // KPI 值:所有物料 cycle_days 的算术平均;没有任一可计算物料时写 NULL,不伪装 0
+        decimal? metricValue = cycleDaysList.Count > 0
+            ? (decimal)cycleDaysList.Average()
+            : null;
+        sub.KpiRows = await UpsertKpiValueAsync("S5_L1_001", option.BizDate, metricValue, now);
+        sub.DenominatorStatus = cycleDaysList.Count > 0 ? "OK" : "NO_NUMERATOR";
+        return sub;
+    }
+
+    /// <summary>S5_L1_002 物料上线满足率 = 开工日期前完成上线行数 / 工单物料总行数。</summary>
+    private async Task<KpiBuildSubResult> BuildS5L1002MaterialOnlineFulfillmentAsync(
+        string batchId, DateTime now, S5MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        const string sqlNumer = @"
+select lynoid as lynoid, count(*) as codenum
+ from (
+   select a.lynoid as lynoid, b.code as code
+    from kc_tz_head a with(nolock)
+    inner join kc_tz_list b with(nolock) on a.Id=b.idid
+    left join (
+      select noid as noid, min(kgdate) as kgdate
+       from Cj_Bg_Head_Rep with(nolock)
+       where ztid=@ztid group by noid
+    ) c on a.lynoid=c.noid
+    where a.ztid=@ztid and a.lbs='生产领料' and a.hzyn=0 and a.zfyn=0 and a.shyn=1 and a.shtime<=c.kgdate
+    group by a.lynoid, b.code
+ ) n
+ group by lynoid";
+        const string sqlDenom = @"
+select a.noid as noid, count(b.id) as listnum
+ from kc_dd_head a with(nolock)
+ left join kc_dd_list_cllist b with(nolock) on a.Id=b.idid
+ where a.ztid=@ztid and a.lbs='生产任务' and a.zf=0 and a.shyn=1
+ group by a.noid";
+
+        var p = new[] { new SugarParameter("@ztid", option.SourceZtid) };
+        var numerRows = await QueryT8Async<S5FulfillmentNumerRow>(sqlNumer, p);
+        var denomRows = await QueryT8Async<S5FulfillmentDenomRow>(sqlDenom, p);
+        sub.T8Rows = numerRows.Count + denomRows.Count;
+
+        var numerByOrder = numerRows.Where(r => !string.IsNullOrEmpty(r.lynoid))
+            .ToDictionary(r => r.lynoid!, r => r.codenum, StringComparer.OrdinalIgnoreCase);
+
+        var dwdAffected = 0;
+        var rateList = new List<decimal>();
+        foreach (var d in denomRows)
+        {
+            ct.ThrowIfCancellationRequested();
+            if (string.IsNullOrEmpty(d.noid)) continue;
+            var beforeKg = numerByOrder.GetValueOrDefault(d.noid, 0);
+            decimal? rate = d.listnum > 0
+                ? Math.Round((decimal)beforeKg / d.listnum, 4)
+                : null; // 分母为 0 时不伪装真实 0
+            if (rate.HasValue) rateList.Add(rate.Value);
+
+            dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_material_online_fulfillment
+  (tenant_id, factory_id, biz_date, source_ztid, work_order_no,
+   before_kgdate_rows, total_rows, fulfillment_rate, batch_id, create_time)
+VALUES
+  (0, 1, @bizDate, @ztid, @workOrderNo, @beforeKg, @total, @rate, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  before_kgdate_rows=VALUES(before_kgdate_rows),
+  total_rows=VALUES(total_rows),
+  fulfillment_rate=VALUES(fulfillment_rate),
+  batch_id=VALUES(batch_id), update_time=@now",
+                new SugarParameter("@bizDate", option.BizDate),
+                new SugarParameter("@ztid", option.SourceZtid),
+                new SugarParameter("@workOrderNo", d.noid),
+                new SugarParameter("@beforeKg", beforeKg),
+                new SugarParameter("@total", d.listnum),
+                new SugarParameter("@rate", rate),
+                new SugarParameter("@batchId", batchId),
+                new SugarParameter("@now", now));
+        }
+        sub.DwdRows = dwdAffected;
+
+        decimal? metricValue = rateList.Count > 0
+            ? Math.Round(rateList.Average() * 100m, 4) // 百分号
+            : null;
+        sub.KpiRows = await UpsertKpiValueAsync("S5_L1_002", option.BizDate, metricValue, now);
+        sub.DenominatorStatus = rateList.Count > 0 ? "OK" : "NO_VALID_ORDER";
+        return sub;
+    }
+
+    /// <summary>S5_L1_003 物料仓储人效 = SUM(slzx where lbs=生产领料) / count(gw=仓管)。</summary>
+    private async Task<KpiBuildSubResult> BuildS5L1003MaterialWarehouseEfficiencyAsync(
+        string batchId, DateTime now, S5MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        const string sqlNumer = @"
+select sum(b.slzx) as slzx
+ from kc_tz_head a with(nolock)
+ inner join kc_tz_list b with(nolock) on a.Id=b.idid
+ where a.ztid=@ztid and a.lbs='生产领料' and a.hzyn=0 and a.zfyn=0 and a.shyn=1
+  and a.shtime between @startDate and @endDate";
+        const string sqlDenom = @"
+select count(*) as penum
+ from sys_pelist with(nolock)
+ where ztid=@ztid and zzzt='在职' and gw='仓管'";
+
+        var pNumer = new[]
+        {
+            new SugarParameter("@ztid", option.SourceZtid),
+            new SugarParameter("@startDate", option.MonthlyPeriodStart),
+            new SugarParameter("@endDate", option.MonthlyPeriodEnd)
+        };
+        var pDenom = new[] { new SugarParameter("@ztid", option.SourceZtid) };
+
+        var numerRows = await QueryT8Async<S5SumQtyRow>(sqlNumer, pNumer);
+        var denomRows = await QueryT8Async<S5CountRow>(sqlDenom, pDenom);
+        sub.T8Rows = numerRows.Count + denomRows.Count;
+
+        decimal? onlineQty = numerRows.FirstOrDefault()?.slzx;
+        int? headcount = denomRows.FirstOrDefault()?.penum;
+
+        // 分母 = 0 或 NULL:efficiency 写 NULL,并标记 denominator_status;不伪装真实 0
+        decimal? efficiency = null;
+        string denomStatus;
+        if (!headcount.HasValue || headcount.Value <= 0)
+        {
+            denomStatus = "NO_HEADCOUNT";
+        }
+        else if (!onlineQty.HasValue)
+        {
+            denomStatus = "NO_NUMERATOR";
+        }
+        else
+        {
+            efficiency = Math.Round(onlineQty.Value / headcount.Value, 4);
+            denomStatus = "OK";
+        }
+        sub.DenominatorStatus = denomStatus;
+
+        // 月度 KPI 用 biz_month 唯一键,整月 1 行
+        var dwdAffected = await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_material_warehouse_efficiency
+  (tenant_id, factory_id, biz_month, source_ztid, period_start, period_end,
+   online_qty, warehouse_headcount, efficiency, denominator_status, batch_id, create_time)
+VALUES
+  (0, 1, @bizMonth, @ztid, @periodStart, @periodEnd,
+   @onlineQty, @headcount, @efficiency, @denomStatus, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  period_start=VALUES(period_start), period_end=VALUES(period_end),
+  online_qty=VALUES(online_qty), warehouse_headcount=VALUES(warehouse_headcount),
+  efficiency=VALUES(efficiency), denominator_status=VALUES(denominator_status),
+  batch_id=VALUES(batch_id), update_time=@now",
+            new SugarParameter("@bizMonth", option.BizMonth),
+            new SugarParameter("@ztid", option.SourceZtid),
+            new SugarParameter("@periodStart", option.MonthlyPeriodStart),
+            new SugarParameter("@periodEnd", option.MonthlyPeriodEnd),
+            new SugarParameter("@onlineQty", onlineQty),
+            new SugarParameter("@headcount", headcount),
+            new SugarParameter("@efficiency", efficiency),
+            new SugarParameter("@denomStatus", denomStatus),
+            new SugarParameter("@batchId", batchId),
+            new SugarParameter("@now", now));
+        sub.DwdRows = dwdAffected;
+
+        // 月度 KPI 入日表:用月末日作 biz_date,月内每天可由聚合 API 再分发;分母缺失时 metric_value=NULL
+        sub.KpiRows = await UpsertKpiValueAsync("S5_L1_003", option.MonthlyPeriodEnd, efficiency, now);
+        return sub;
+    }
+
+    /// <summary>S5_L1_004 品类物料库存周转 = D1/D2 × 30;D1=je3 月均库存金额,D2=je2 出库成本。</summary>
+    private async Task<KpiBuildSubResult> BuildS5L1004MaterialInventoryTurnoverAsync(
+        string batchId, DateTime now, S5MdpRefreshOption option, CancellationToken ct)
+    {
+        var sub = new KpiBuildSubResult();
+
+        // TVF:Rep_总账_存货_V3(账套, '普通', '正常', 起期 YYYYMM, 止期 YYYYMM)
+        const string sqlTvf = @"
+select ckcode as ckcode, ckname as ckname,
+       code as code, cname as cname,
+       pcode as pcode, pname as pname,
+       je3 as je3, je2 as je2
+ from dbo.Rep_总账_存货_V3(@ztid, N'普通', N'正常', @startYm, @endYm)";
+
+        var p = new[]
+        {
+            new SugarParameter("@ztid", option.SourceZtid),
+            new SugarParameter("@startYm", option.TvfPeriodStartYyyymm),
+            new SugarParameter("@endYm", option.TvfPeriodEndYyyymm)
+        };
+        var tvfRows = await QueryT8Async<S5InventoryTurnoverRow>(sqlTvf, p);
+        sub.T8Rows = tvfRows.Count;
+
+        var dwdAffected = 0;
+        var turnoverDaysList = new List<decimal>();
+        foreach (var r in tvfRows)
+        {
+            ct.ThrowIfCancellationRequested();
+            // 周转天数:D2=0 或 NULL 时 NULL,不伪装 0
+            decimal? turnoverDays = (r.je2.HasValue && r.je2.Value > 0m && r.je3.HasValue)
+                ? Math.Round(r.je3.Value / r.je2.Value * 30m, 4)
+                : null;
+            if (turnoverDays.HasValue) turnoverDaysList.Add(turnoverDays.Value);
+
+            dwdAffected += await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO dwd_t8_material_inventory_turnover
+  (tenant_id, factory_id, biz_month, source_ztid, period_start_yyyymm, period_end_yyyymm,
+   warehouse_code, warehouse_name, item_code, item_name, category_code, category_name,
+   avg_inventory_value, monthly_outbound_cost, turnover_days, batch_id, create_time)
+VALUES
+  (0, 1, @bizMonth, @ztid, @startYm, @endYm,
+   @ckcode, @ckname, @itemCode, @itemName, @pcode, @pname,
+   @je3, @je2, @turnoverDays, @batchId, @now)
+ON DUPLICATE KEY UPDATE
+  warehouse_name=VALUES(warehouse_name), item_name=VALUES(item_name),
+  category_code=VALUES(category_code), category_name=VALUES(category_name),
+  avg_inventory_value=VALUES(avg_inventory_value),
+  monthly_outbound_cost=VALUES(monthly_outbound_cost),
+  turnover_days=VALUES(turnover_days),
+  period_start_yyyymm=VALUES(period_start_yyyymm),
+  period_end_yyyymm=VALUES(period_end_yyyymm),
+  batch_id=VALUES(batch_id), update_time=@now",
+                new SugarParameter("@bizMonth", option.BizMonth),
+                new SugarParameter("@ztid", option.SourceZtid),
+                new SugarParameter("@startYm", option.TvfPeriodStartYyyymm),
+                new SugarParameter("@endYm", option.TvfPeriodEndYyyymm),
+                new SugarParameter("@ckcode", r.ckcode ?? ""),
+                new SugarParameter("@ckname", r.ckname),
+                new SugarParameter("@itemCode", r.code ?? ""),
+                new SugarParameter("@itemName", r.cname),
+                new SugarParameter("@pcode", r.pcode),
+                new SugarParameter("@pname", r.pname),
+                new SugarParameter("@je3", r.je3),
+                new SugarParameter("@je2", r.je2),
+                new SugarParameter("@turnoverDays", turnoverDays),
+                new SugarParameter("@batchId", batchId),
+                new SugarParameter("@now", now));
+        }
+        sub.DwdRows = dwdAffected;
+
+        // KPI 值:所有品类周转天数算术平均;无任一可计算品类时 NULL
+        decimal? metricValue = turnoverDaysList.Count > 0
+            ? Math.Round(turnoverDaysList.Average(), 4)
+            : null;
+        sub.KpiRows = await UpsertKpiValueAsync("S5_L1_004", option.MonthlyPeriodEnd, metricValue, now);
+        sub.DenominatorStatus = turnoverDaysList.Count > 0 ? "OK" : "NO_VALID_OUTBOUND_COST";
+        return sub;
+    }
+
+    // ─────────────────────────────────────────────────────────────────────────
+    // 跨库 / 写入 / 日志 封装
+    // ─────────────────────────────────────────────────────────────────────────
+
+    private async Task<List<T>> QueryT8Async<T>(string sql, SugarParameter[] parameters)
+    {
+        var t8 = _db.AsTenant().GetConnectionScope(T8ConfigId);
+        return await t8.Ado.SqlQueryAsync<T>(sql, parameters);
+    }
+
+    private async Task<int> UpsertKpiValueAsync(string metricCode, DateTime bizDate, decimal? metricValue, DateTime now)
+    {
+        // 沿用 S3 UpsertS3KpiValueAsync 范式:先查现存行 → UPDATE;不存在 → SELECT MAX(id)+1 显式生成 id 后 INSERT。
+        // ado_s9_kpi_value_l1_day.id 为手工分配主键(无 AUTO_INCREMENT),必须显式 set;
+        // metric_value 允许 NULL(分母缺失不得伪装真实 0)。
+        // FIX-2:截断时分秒(月度 KPI 入参可能为 YYYY-MM-DD 23:59:59),保证 SELECT WHERE biz_date=@BizDate 与 DB date 列匹配,避免重复 INSERT。
+        bizDate = bizDate.Date;
+        var existingId = await _db.Ado.GetLongAsync(
+            "SELECT IFNULL((SELECT id FROM ado_s9_kpi_value_l1_day WHERE tenant_id=0 AND factory_id=1 " +
+            "AND module_code=@ModuleCode AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0 " +
+            "ORDER BY id LIMIT 1), 0)",
+            new List<SugarParameter>
+            {
+                new("@ModuleCode", ModuleCode),
+                new("@MetricCode", metricCode),
+                new("@BizDate", bizDate)
+            });
+
+        if (existingId > 0)
+        {
+            return await _db.Ado.ExecuteCommandAsync(
+                "UPDATE ado_s9_kpi_value_l1_day SET metric_value=@MetricValue, calc_time=@Now, " +
+                "update_time=@Now, is_deleted=0, is_active=1 WHERE id=@Id",
+                new SugarParameter("@MetricValue", metricValue),
+                new SugarParameter("@Now", now),
+                new SugarParameter("@Id", existingId));
+        }
+
+        var nextId = await _db.Ado.GetLongAsync(
+            "SELECT COALESCE(MAX(id), 0) + 1 FROM ado_s9_kpi_value_l1_day");
+        return await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO ado_s9_kpi_value_l1_day
+  (id, tenant_id, org_id, company_id, factory_id, status, biz_date,
+   create_time, update_time, is_deleted, is_active,
+   module_code, metric_code, metric_value, calc_time)
+VALUES
+  (@Id, 0, NULL, NULL, 1, NULL, @BizDate,
+   @Now, @Now, 0, 1,
+   @ModuleCode, @MetricCode, @MetricValue, @Now)",
+            new SugarParameter("@Id", nextId),
+            new SugarParameter("@BizDate", bizDate),
+            new SugarParameter("@Now", now),
+            new SugarParameter("@ModuleCode", ModuleCode),
+            new SugarParameter("@MetricCode", metricCode),
+            new SugarParameter("@MetricValue", metricValue));
+    }
+
+    private async Task<long> InsertTransformRunLogAsync(string batchId, DateTime startedAt, string triggerType, S5MdpRefreshOption option)
+    {
+        await _db.Ado.ExecuteCommandAsync(@"
+INSERT INTO mdp_transform_run_log
+  (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time, stage_rows, standard_rows, dwd_rows, create_time, update_time)
+VALUES
+  (0, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime, 0, 0, 0, @StartTime, @StartTime)",
+            new SugarParameter("@JobCode", JobCode),
+            new SugarParameter("@JobName", JobName),
+            new SugarParameter("@TriggerType", triggerType),
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@StartTime", startedAt));
+        return await _db.Ado.GetLongAsync(
+            "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
+            new List<SugarParameter> { new("@BatchId", batchId) });
+    }
+
+    private async Task MarkTransformRunSuccessAsync(long runLogId, DateTime startedAt, S5MdpSyncTransformResult result)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(@"
+UPDATE mdp_transform_run_log
+SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
+    stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=@DwdRows,
+    summary_json=@SummaryJson, update_time=CURRENT_TIMESTAMP
+WHERE id=@Id",
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@StageRows", result.StageRows),
+            new SugarParameter("@StandardRows", result.StandardRows),
+            new SugarParameter("@DwdRows", result.DwdRows),
+            new SugarParameter("@SummaryJson", BuildRunSummaryJson(result)),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task MarkTransformRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    {
+        try
+        {
+            var finishedAt = DateTime.Now;
+            await _db.Ado.ExecuteCommandAsync(@"
+UPDATE mdp_transform_run_log
+SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
+    error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
+WHERE id=@Id",
+                new SugarParameter("@EndTime", finishedAt),
+                new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+                new SugarParameter("@ErrorMessage", Truncate(message, 2000)),
+                new SugarParameter("@Id", runLogId));
+        }
+        catch (Exception ex)
+        {
+            // 写库本身失败兜底:远端 MySQL 瞬断导致 MarkFailed 自身也连不上
+            Console.Error.WriteLine($"[S5MdpSyncTransform] MarkTransformRunFailed write failed (runLogId={runLogId}): {ex.Message}");
+        }
+    }
+
+    private static string BuildRunSummaryJson(S5MdpSyncTransformResult r)
+    {
+        var summary = new
+        {
+            batchId = r.BatchId,
+            sourceZtid = r.SourceZtid,
+            bizDate = r.BizDate.ToString("yyyy-MM-dd"),
+            bizMonth = r.BizMonth,
+            triggerType = r.TriggerType,
+            dwdRows = r.DwdRows,
+            kpiRows = r.KpiRows,
+            perKpiDwdRows = r.PerKpiDwdRows,
+            perKpiKpiRows = r.PerKpiKpiRows,
+            denominatorStatus = r.KpiDenominatorStatus,
+            tvfPeriod = $"{r.MonthlyPeriodStart:yyyy-MM-dd}~{r.MonthlyPeriodEnd:yyyy-MM-dd}"
+        };
+        return JsonSerializer.Serialize(summary);
+    }
+
+    private static string NormalizeTriggerType(string s) =>
+        string.IsNullOrWhiteSpace(s) ? "AUTO" : s.Trim().ToUpperInvariant();
+
+    private static string Truncate(string s, int max) =>
+        string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Refresh 入参与结果 DTO
+// ─────────────────────────────────────────────────────────────────────────────
+
+public sealed class S5MdpRefreshOption
+{
+    /// <summary>T8 账套(kc_tz_head.ztid);实测当前唯一账套为 pbxfxp。</summary>
+    public string SourceZtid { get; set; } = "pbxfxp";
+    /// <summary>日 T+1 KPI 的业务日期(默认昨天)。</summary>
+    public DateTime BizDate { get; set; }
+    /// <summary>月 M+1 KPI 的业务月 YYYY-MM(默认上月)。</summary>
+    public string BizMonth { get; set; } = "";
+    /// <summary>日 T+1 KPI 区间起(含),默认昨天 00:00。</summary>
+    public DateTime DailyPeriodStart { get; set; }
+    /// <summary>日 T+1 KPI 区间止(含),默认昨天 23:59:59。</summary>
+    public DateTime DailyPeriodEnd { get; set; }
+    /// <summary>月 M+1 KPI 区间起(含),默认上月 1 日。</summary>
+    public DateTime MonthlyPeriodStart { get; set; }
+    /// <summary>月 M+1 KPI 区间止(含),默认上月末日。</summary>
+    public DateTime MonthlyPeriodEnd { get; set; }
+    /// <summary>TVF Rep_总账_存货_V3 入参起期 YYYYMM。</summary>
+    public string TvfPeriodStartYyyymm { get; set; } = "";
+    /// <summary>TVF Rep_总账_存货_V3 入参止期 YYYYMM。</summary>
+    public string TvfPeriodEndYyyymm { get; set; } = "";
+
+    public static S5MdpRefreshOption Default()
+    {
+        var today = DateTime.Today;
+        var yesterday = today.AddDays(-1);
+        var lastMonth = today.AddMonths(-1);
+        var monthStart = new DateTime(lastMonth.Year, lastMonth.Month, 1);
+        var monthEnd = monthStart.AddMonths(1).AddDays(-1);
+        return new S5MdpRefreshOption
+        {
+            SourceZtid = "pbxfxp",
+            BizDate = yesterday,
+            BizMonth = lastMonth.ToString("yyyy-MM"),
+            DailyPeriodStart = yesterday,
+            DailyPeriodEnd = yesterday.AddDays(1).AddSeconds(-1),
+            MonthlyPeriodStart = monthStart,
+            MonthlyPeriodEnd = monthEnd.AddDays(1).AddSeconds(-1),
+            TvfPeriodStartYyyymm = monthStart.ToString("yyyyMM"),
+            TvfPeriodEndYyyymm = monthEnd.ToString("yyyyMM")
+        };
+    }
+}
+
+public sealed class S5MdpSyncTransformResult
+{
+    public string BatchId { get; set; } = "";
+    public long RunLogId { get; set; }
+    public string TriggerType { get; set; } = "AUTO";
+    public string SourceZtid { get; set; } = "";
+    public DateTime BizDate { get; set; }
+    public string BizMonth { get; set; } = "";
+    public DateTime DailyPeriodStart { get; set; }
+    public DateTime DailyPeriodEnd { get; set; }
+    public DateTime MonthlyPeriodStart { get; set; }
+    public DateTime MonthlyPeriodEnd { get; set; }
+
+    public int StageRows { get; set; }
+    public int StandardRows { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+
+    public Dictionary<string, int> PerKpiDwdRows { get; } = new();
+    public Dictionary<string, int> PerKpiKpiRows { get; } = new();
+    public List<string> KpiDenominatorStatus { get; } = new();
+
+    public void MergeSub(string kpiCode, KpiBuildSubResult sub)
+    {
+        PerKpiDwdRows[kpiCode] = sub.DwdRows;
+        PerKpiKpiRows[kpiCode] = sub.KpiRows;
+        DwdRows += sub.DwdRows;
+        KpiRows += sub.KpiRows;
+        KpiDenominatorStatus.Add($"{kpiCode}:{sub.DenominatorStatus}");
+    }
+}
+
+public sealed class KpiBuildSubResult
+{
+    public int T8Rows { get; set; }
+    public int DwdRows { get; set; }
+    public int KpiRows { get; set; }
+    public string DenominatorStatus { get; set; } = "OK";
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// T8 result set 投影类型(与方老师 SQL SELECT 列名严格一致;SqlSugar 映射)
+// ─────────────────────────────────────────────────────────────────────────────
+
+internal sealed class S5OnlineCycleRow
+{
+    public string? code { get; set; }
+    public DateTime? shtime { get; set; }
+}
+
+internal sealed class S5FulfillmentNumerRow
+{
+    public string? lynoid { get; set; }
+    public int codenum { get; set; }
+}
+
+internal sealed class S5FulfillmentDenomRow
+{
+    public string? noid { get; set; }
+    public int listnum { get; set; }
+}
+
+internal sealed class S5SumQtyRow
+{
+    public decimal? slzx { get; set; }
+}
+
+internal sealed class S5CountRow
+{
+    public int penum { get; set; }
+}
+
+internal sealed class S5InventoryTurnoverRow
+{
+    public string? ckcode { get; set; }
+    public string? ckname { get; set; }
+    public string? code { get; set; }
+    public string? cname { get; set; }
+    public string? pcode { get; set; }
+    public string? pname { get; set; }
+    public decimal? je3 { get; set; }
+    public decimal? je2 { get; set; }
+}