Browse Source

fix: 工单物料齐套看板SP转C#代码+租户隔离+数据去重+欠料负数显示0

Pengxy 2 days ago
parent
commit
c3a99de2bc

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.355",
+	"version": "2.4.356",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 9 - 2
Web/src/views/aidop/s3/supply/workOrderMaterialReadinessKanban.vue

@@ -53,6 +53,7 @@
 			>
 				<template v-if="item.format === 'date'" #default="{ row }">{{ fmtDate(row[item.key]) }}</template>
 				<template v-else-if="item.format === 'num'" #default="{ row }">{{ fmtNumber(row[item.key]) }}</template>
+				<template v-else-if="item.format === 'shortage'" #default="{ row }">{{ fmtShortage(row[item.key]) }}</template>
 			</el-table-column>
 		</el-table>
 
@@ -127,7 +128,7 @@ const columns = [
 	{ key: 'componentItemKittingTime', label: '需求日期', width: 130, sortable: true, format: 'date' },
 	{ key: 'qtyRec', label: '发料数量', width: 110, sortable: true, align: 'right', format: 'num' },
 	{ key: 'recDate', label: '发料日期', width: 130, sortable: true, format: 'date' },
-	{ key: 'shortage', label: '欠料数量', width: 110, sortable: true, align: 'right', format: 'num' },
+	{ key: 'shortage', label: '欠料数量', width: 110, sortable: true, align: 'right', format: 'shortage' },
 	{ key: 'qtyFrom', label: '备料数量', width: 110, sortable: true, align: 'right', format: 'num' },
 	{ key: 'fromDate', label: '备料日期', width: 130, sortable: true, format: 'date' },
 	{ key: 'zjsl', label: '在检数量', width: 110, sortable: true, align: 'right', format: 'num' },
@@ -151,7 +152,7 @@ type ColumnDef = (typeof columns)[number];
 type ColumnKey = ColumnDef['key'];
 
 const col = reactive(
-	Object.fromEntries(columns.map((item) => [item.key, true])) as Record<ColumnKey, boolean>
+	Object.fromEntries(columns.map((item) => [item.key, item.key !== 'op' && item.key !== 'opOrdDate'])) as Record<ColumnKey, boolean>
 );
 
 const toggleItems = columns.map((item) => ({ key: item.key, label: item.label }));
@@ -166,6 +167,12 @@ function fmtNumber(v?: string | number | null) {
 	const n = Number(v);
 	return Number.isNaN(n) ? String(v) : n.toLocaleString('zh-CN');
 }
+function fmtShortage(v?: string | number | null) {
+	if (v === null || v === undefined || v === '') return '';
+	const n = Number(v);
+	if (Number.isNaN(n)) return String(v);
+	return (n < 0 ? 0 : n).toLocaleString('zh-CN');
+}
 
 async function loadList() {
 	loading.value = true;

+ 9 - 0
doc/migrations/2026-09-02_workordtemp_add_tenant_id.sql

@@ -0,0 +1,9 @@
+-- WorkOrdTemp 补 tenant_id 列(存储过程产出的无租户中间表,需要加租户隔离)
+SET @col_exists = (SELECT COUNT(1) FROM information_schema.COLUMNS
+    WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'WorkOrdTemp' AND COLUMN_NAME = 'tenant_id');
+SET @sql = IF(@col_exists = 0,
+    'ALTER TABLE WorkOrdTemp ADD COLUMN tenant_id BIGINT NULL',
+    'SELECT 1');
+PREPARE stmt FROM @sql;
+EXECUTE stmt;
+DEALLOCATE PREPARE stmt;

+ 3 - 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.455</AssemblyVersion>
-    <FileVersion>1.0.455</FileVersion>
-    <Version>1.0.455</Version>
+    <AssemblyVersion>1.0.456</AssemblyVersion>
+    <FileVersion>1.0.456</FileVersion>
+    <Version>1.0.456</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 439 - 146
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/WorkOrderMaterialReadinessService.cs

@@ -1,3 +1,5 @@
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+
 namespace Admin.NET.Plugin.AiDOP.Supply;
 
 /// <summary>
@@ -9,13 +11,23 @@ namespace Admin.NET.Plugin.AiDOP.Supply;
 [NonUnify]
 public class WorkOrderMaterialReadinessService : IDynamicApiController, ITransient
 {
+    private const string SqlServerSourceCode = "DOPDEMORQ_SQLSERVER";
+
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly MdpSourceScopeFactory _scopeFactory;
+    private readonly SourceDomainTenantResolver _domainTenant;
 
-    public WorkOrderMaterialReadinessService(ISqlSugarClient db, UserManager userManager)
+    public WorkOrderMaterialReadinessService(
+        ISqlSugarClient db,
+        UserManager userManager,
+        MdpSourceScopeFactory scopeFactory,
+        SourceDomainTenantResolver domainTenant)
     {
         _db = db;
         _userManager = userManager;
+        _scopeFactory = scopeFactory;
+        _domainTenant = domainTenant;
     }
 
     [DisplayName("工单物料齐套上线看板列表")]
@@ -29,6 +41,11 @@ public class WorkOrderMaterialReadinessService : IDynamicApiController, ITransie
         var where = new List<string> { "1=1" };
         var pars = new List<SugarParameter>();
 
+        // 租户隔离:只查当前租户的 WorkOrdTemp 数据
+        var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
+        where.Add("a.tenant_id = @TenantId");
+        pars.Add(new SugarParameter("@TenantId", tenantId));
+
         if (!string.IsNullOrWhiteSpace(input.SeOrder))
         {
             where.Add("a.SeOrder LIKE @SeOrder");
@@ -55,13 +72,15 @@ public class WorkOrderMaterialReadinessService : IDynamicApiController, ITransie
             pars.Add(new SugarParameter("@SupplierName", $"%{input.SupplierName.Trim()}%"));
         }
 
-        if (await ShouldUseDwdAsync(input))
-        {
-            return await GetDwdListAsync(input, page, pageSize, offset);
-        }
+        // 始终使用 WorkOrdTemp 路径(字段完整);dwd_material_readiness 缺少 SeOrder/Priority/MorderType 等列,
+        // 走 DWD 路径会导致订单号、工单优先级、工单类型等列全部为 NULL。
+        // if (await ShouldUseDwdAsync(input))
+        // {
+        //     return await GetDwdListAsync(input, page, pageSize, offset);
+        // }
 
         var fromSql = $"FROM WorkOrdTemp a WHERE {string.Join(" AND ", where)}";
-        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM (SELECT DISTINCT a.RowNum, a.SeOrder, a.workord, a.ItemNum, a.Op, a.bom_number, a.supplier_number {fromSql}) t", pars);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM (SELECT DISTINCT a.RowNum, a.SeOrder, a.workord, a.ItemNum, a.bom_number, a.supplier_number {fromSql}) t", pars);
         var orderBy = BuildOrderBy(input.SortField, input.SortOrder);
 
         var list = await _db.Ado.SqlQueryAsync<WorkOrderMaterialReadinessRow>(
@@ -74,8 +93,6 @@ public class WorkOrderMaterialReadinessService : IDynamicApiController, ITransie
                 a.Priority AS Priority,
                 a.morder_type AS MorderType,
                 a.LotSerial AS LotSerial,
-                a.Op AS Op,
-                a.OpOrdDate AS OpOrdDate,
                 a.bom_number AS BomNumber,
                 a.Descr AS Descr,
                 a.Descr1 AS Descr1,
@@ -123,160 +140,436 @@ public class WorkOrderMaterialReadinessService : IDynamicApiController, ITransie
     [HttpPost("work-order-material-readiness/refresh")]
     public async Task<object> Refresh()
     {
-        var tid = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
-        var now = DateTime.Now;
-        var batchId = $"workordtemp-{now:yyyyMMdd-HHmmss}";
-        var tenantPar = new SugarParameter("@TenantId", tid);
-
-        // Step 1: 执行存储过程填充 WorkOrdTemp。
-        // 不在此处删除 dwd_material_readiness:Step 2 删过期工单 + Step 3 upsert 已等价于完整重建,
-        // 且前置全表删除会在存储过程失败时把看板清空且无法重建(2026-08-11 实测丢失 20872 行)。
-        // 该存储过程实测需 30 秒以上(2026-08-11),单独放宽超时;仅覆盖本次调用,随后还原。
-        var prevTimeout = _db.Ado.CommandTimeOut;
+        var sw = System.Diagnostics.Stopwatch.StartNew();
         try
         {
-            _db.Ado.CommandTimeOut = 300;
-            await _db.Ado.ExecuteCommandAsync("CALL pr_MES_Report_DayWorkOrdDetailShortage_New('')");
+            var tenantId = Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
+            var now = DateTime.Now;
+            var batchId = $"workordtemp-{now:yyyyMMdd-HHmmss}";
+            var tenantPar = new SugarParameter("@TenantId", tenantId);
+            var domain = await _domainTenant.ResolveDomainAsync(SqlServerSourceCode, tenantId);
+
+            // Step 0: 从 SQL Server 读取 InvMaster,写入 MySQL 辅助表
+            await PrepareInvMasterTempAsync(domain, tenantId);
+
+            // Step 1: 清空本租户 WorkOrdTemp
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM WorkOrdTemp WHERE tenant_id = @TenantId", tenantPar);
+
+            // Step 2: INSERT(原存储过程核心查询,所有源表加租户过滤)
+            await _db.Ado.ExecuteCommandAsync("""
+                INSERT INTO WorkOrdTemp(
+                    RowNum, Status, workord, Priority, OrdDate, morder_type, bom_number,
+                    Descr, Descr1, production_number, QtyRequired, supplier_name, ItemNum,
+                    supplier_number, ljsl, SeOrder, SePriority, LotSerial,
+                    checktime, PMBOM, ItemType, OwnerApplication, ComponentItemDescr,
+                    ComponentItemDrawing, ComponentItemUM, ComponentItemKittingTime,
+                    QtyRec, RecDate, QtyFrom, FromDate, jhshrq, PurOrdQty, PurOrdorddate,
+                    Buyer, BuyerUser, tenant_id
+                )
+                SELECT
+                    viewql.RowNum, viewql.Status, viewql.workord, viewql.Priority, viewql.OrdDate,
+                    viewql.morder_type, viewql.bom_number, viewql.Descr, viewql.Descr1,
+                    viewql.morder_production_number, viewql.QtyRequired, viewql.supplier_name,
+                    viewql.ItemNum, viewql.supplier_number,
+                    SUM(viewql.QtyRequired) OVER (
+                        PARTITION BY viewql.ItemNum
+                        ORDER BY viewql.Priority, viewql.workord
+                        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
+                    ) AS ljsl,
+                    viewql.fbill_no, viewql.Priority AS SePriority,
+                    viewql.LotSerial, viewql.checktime,
+                    viewql.PMBOM, viewql.ItemType, viewql.OwnerApplication,
+                    viewql.ComponentItemDescr, viewql.ComponentItemDrawing,
+                    viewql.ComponentItemUM, viewql.ComponentItemKittingTime,
+                    viewql.QtyRec, viewql.RecDate, viewql.QtyFrom, viewql.FromDate,
+                    viewql.jhshrq, viewql.PurOrdQty, viewql.PurOrdorddate,
+                    viewql.Buyer, '' AS BuyerUser,
+                    @TenantId AS tenant_id
+                FROM (
+                    SELECT
+                        a.RowNum, a.Status, a.workord, a.Priority, a.OrdDate, a.LotSerial,
+                        b.morder_type, c.ItemNum AS bom_number, c.Descr, c.Descr1,
+                        b.morder_production_number, d.QtyRequired, e.supplier_name,
+                        d.ItemNum, e.supplier_number, f.fbill_no,
+                        c.PMBOM,
+                        Sonitem.ItemType, Sonitem.OwnerApplication,
+                        Sonitem.Descr AS ComponentItemDescr,
+                        Sonitem.Drawing AS ComponentItemDrawing,
+                        Sonitem.UM AS ComponentItemUM,
+                        exm.checktime,
+                        exd.kitting_time AS ComponentItemKittingTime,
+                        j.QtyRec, j.updatetime AS RecDate,
+                        j.QtyFrom, j.updatetime AS FromDate,
+                        sh.jhshrq,
+                        pm.QtyOrded AS PurOrdQty, pm.orddate AS PurOrdorddate,
+                        pm.Buyer
+                    FROM (
+                        SELECT RowNum, Status, workord, Priority, OrdDate, LotSerial
+                        FROM (
+                            SELECT CAST(ROW_NUMBER() OVER (PARTITION BY Status ORDER BY Priority ASC) AS CHAR(50)) AS RowNum,
+                                   Status, workord, Priority, OrdDate, LotSerial
+                            FROM WorkOrdMaster
+                            WHERE Status IN ('W','S','r','p') AND tenant_id = @TenantId
+                        ) AS workview
+                        WHERE workord IN (
+                            SELECT WorkOrd FROM NbrMaster
+                            WHERE Type = 'SM' AND Status = ''
+                            GROUP BY WorkOrd
+                        ) OR Status = 'p'
+                    ) a
+                    LEFT JOIN mes_morder b ON a.workord = b.morder_no AND b.tenant_id = @TenantId
+                    LEFT JOIN ItemMaster c ON b.product_code = c.ItemNum AND c.tenant_id = @TenantId
+                    LEFT JOIN WorkOrdDetail d ON a.WorkOrd = d.WorkOrd AND d.tenant_id = @TenantId
+                    LEFT JOIN ItemMaster Sonitem ON d.itemnum = Sonitem.itemnum AND Sonitem.tenant_id = @TenantId
+                    LEFT JOIN (
+                        SELECT moentry_mono, GROUP_CONCAT(DISTINCT fbill_no SEPARATOR ',') AS fbill_no
+                        FROM mes_moentry WHERE tenant_id = @TenantId
+                        GROUP BY moentry_mono
+                    ) f ON b.morder_no = f.moentry_mono
+                    LEFT JOIN (
+                        SELECT workord, itemnum,
+                               SUM(QtyRec) AS QtyRec, MAX(updatetime) AS updatetime,
+                               SUM(QtyFrom) AS QtyFrom
+                        FROM NbrDetail
+                        WHERE tenant_id = @TenantId
+                        GROUP BY workord, itemnum
+                    ) j ON a.workord = j.workord AND d.itemnum = j.itemnum
+                    LEFT JOIN (
+                        SELECT a0.number,
+                       GROUP_CONCAT(a0.supplier_name SEPARATOR ',') AS supplier_name,
+                       GROUP_CONCAT(a0.supplier_number SEPARATOR ',') AS supplier_number
+                FROM srm_purchase a0
+                WHERE a0.quota_rate > 0 AND a0.tenant_id = @TenantId
+                GROUP BY a0.number
+                    ) e ON d.ItemNum = e.number
+                    LEFT JOIN LATERAL (
+                        SELECT create_time AS checktime, id
+                        FROM b_examine_result
+                        WHERE morder_no = a.WorkOrd AND tenant_id = @TenantId
+                        ORDER BY create_time DESC LIMIT 1
+                    ) exm ON TRUE
+                    LEFT JOIN (
+                        SELECT examine_id, item_number, MIN(kitting_time) AS kitting_time
+                        FROM b_bom_child_examine
+                        GROUP BY examine_id, item_number
+                    ) exd ON exm.id = exd.examine_id AND exd.item_number != b.product_code and exd.item_number=d.ItemNum
+                    LEFT JOIN (
+                        SELECT MIN(shd.jhshrq) AS jhshrq,
+                               SUM(shzb.sh_delivery_quantity) AS sh_delivery_quantity,
+                               shzb.sh_material_code
+                        FROM scm_shd shd
+                        LEFT JOIN scm_shdzb shzb ON shd.id = shzb.glid AND shzb.tenant_id = shd.tenant_id
+                        WHERE shd.shzt != '完成' AND shd.tenant_id = @TenantId
+                        GROUP BY shzb.sh_material_code
+                    ) sh ON d.itemnum = sh.sh_material_code
+                    LEFT JOIN (
+                        SELECT SUM(pd.QtyOrded) AS QtyOrded, pd.itemnum,
+                               MIN(pm.orddate) AS orddate,
+                               GROUP_CONCAT(pm.Buyer SEPARATOR ',') AS Buyer
+                        FROM PurOrdMaster pm
+                        LEFT JOIN PurOrdDetail pd ON pm.purord = pd.purord AND pd.tenant_id = pm.tenant_id
+                        WHERE pm.status != 'C' AND pd.status != 'C' AND pm.tenant_id = @TenantId
+                        GROUP BY pd.itemnum
+                    ) pm ON d.itemnum = pm.itemnum
+                ) viewql
+                """, new List<SugarParameter> { tenantPar });
+
+            // 兜底:确保所有行都有 tenant_id(防止参数绑定异常导致写入 NULL)
+            await _db.Ado.ExecuteCommandAsync(
+                "UPDATE WorkOrdTemp SET tenant_id = @TenantId WHERE tenant_id IS NULL",
+                new List<SugarParameter> { tenantPar });
+
+            var workOrdTempCount = await _db.Ado.GetIntAsync(
+                "SELECT COUNT(1) FROM WorkOrdTemp WHERE IFNULL(workord,'') <> '' AND tenant_id = @TenantId",
+                tenantPar);
+
+            // Step 3: UPDATE locStock(从 _tmp_inv_master 辅助表 JOIN)
+            await _db.Ado.ExecuteCommandAsync("""
+                UPDATE WorkOrdTemp a
+                LEFT JOIN _tmp_inv_master t ON a.ItemNum = t.ItemNum AND t.tenant_id = @TenantId
+                SET a.locStock = IFNULL(t.locStock, 0)
+                WHERE a.tenant_id = @TenantId
+                """, tenantPar);
+
+            // Step 4: UPDATE ztsl / zjsl / jykssj
+            await _db.Ado.ExecuteCommandAsync("""
+                UPDATE WorkOrdTemp a
+                LEFT JOIN (
+                    SELECT ItemNum, SUM(ztsl) AS ztsl, SUM(zsl1) AS zjsl, MIN(jykssj) AS jykssj
+                    FROM (
+                        SELECT p.ItemNum,
+                               CASE WHEN IFNULL(p.zfhl, 0) <= IFNULL(p.zshl, 0) THEN 0
+                                    ELSE IFNULL(p.zfhl, 0) - IFNULL(p.zshl, 0) END AS ztsl,
+                               IFNULL(p.zsl, 0) AS zsl1, p.jykssj
+                        FROM (
+                            SELECT p0.PurOrd, p0.Line, p0.ItemNum,
+                                   IFNULL(s0.fhsl, 0) - IFNULL(Do0.thsl, 0) AS zfhl,
+                                   p0.ReceiptQty + p0.RctQty AS zshl,
+                                   IFNULL(i0.zjsl, 0) AS zsl, i0.jykssj
+                            FROM PurOrdDetail p0
+                            LEFT JOIN LATERAL (
+                                SELECT SUM(sh_delivery_quantity) AS fhsl
+                                FROM scm_shdzb WHERE po_bill = p0.PurOrd AND po_billline = p0.Line
+                                  AND tenant_id = p0.tenant_id
+                            ) s0 ON TRUE
+                            LEFT JOIN LATERAL (
+                                SELECT SUM(m.Qty) AS zjsl, MIN(qms.FAPPLYTIME) AS jykssj
+                                FROM MissedPrint m
+                                LEFT JOIN qms_qcp_inspecapplyn qms ON m.checkno = qms.FBILLNO
+                                WHERE m.Status = 'I' AND m.PurOrd = p0.PurOrd AND m.PurLine = p0.Line
+                                  AND m.tenant_id = p0.tenant_id
+                            ) i0 ON TRUE
+                            LEFT JOIN LATERAL (
+                                SELECT SUM(t.QtyReturn) AS thsl
+                                FROM PurOrdRctDetail t
+                                WHERE t.OrdNbr = p0.PurOrd AND t.OrdLine = p0.Line
+                                  AND t.rcttype IN ('pt','temp') AND t.tenant_id = p0.tenant_id
+                            ) Do0 ON TRUE
+                            WHERE p0.Status <> 'C' AND p0.tenant_id = @TenantId
+                        ) p
+                    ) viewpur
+                    GROUP BY ItemNum
+                ) b ON a.ItemNum = b.ItemNum
+                SET a.ztsl = IFNULL(b.ztsl, 0),
+                    a.zjsl = IFNULL(b.zjsl, 0),
+                    a.jykssj = b.jykssj
+                WHERE a.tenant_id = @TenantId
+                """, tenantPar);
+
+            // Step 5: UPDATE jqsl / hfjq
+            await _db.Ado.ExecuteCommandAsync("""
+                UPDATE WorkOrdTemp a
+                LEFT JOIN (
+                    SELECT IF(jqsl > schedqty, schedqty, jqsl) AS jqsl, ItemNum, jqhf
+                    FROM (
+                        SELECT SUM(CAST(IFNULL(jq.jhdsl, 0) AS DECIMAL(18,6))) AS jqsl,
+                               MAX(ds.schedqty) AS schedqty,
+                               ds.ItemNum, DATE(jq.jqhf) AS jqhf
+                        FROM scm_jhjh_jq jq
+                        INNER JOIN srm_polist_ds ds ON jq.glid = ds.id AND ds.tenant_id = jq.tenant_id
+                        LEFT JOIN (
+                            SELECT SUM(sh_delivery_quantity) AS sh_delivery_quantity, jhdbh
+                            FROM scm_shdzb WHERE tenant_id = @TenantId
+                            GROUP BY jhdbh
+                        ) c ON ds.dsnum = c.jhdbh
+                        WHERE jq.flag = 0 AND jq.tenant_id = @TenantId
+                          AND IFNULL(c.sh_delivery_quantity, 0) < ds.schedqty
+                          AND ds.status = 'P' AND ds.schedqty - ds.sentqty > 0
+                          AND DATE(jq.jqhf) > CURDATE()
+                        GROUP BY ds.ItemNum, DATE(jq.jqhf)
+                    ) jqview
+                ) b ON a.ItemNum = b.ItemNum AND DATE(a.OrdDate) = b.jqhf
+                SET a.jqsl = CAST(IFNULL(b.jqsl, 0) AS DECIMAL(18,6)),
+                    a.hfjq = b.jqhf
+                WHERE a.tenant_id = @TenantId
+                """, tenantPar);
+
+            // Step 6: UPDATE available / shortage(按日期分段累加)
+            await _db.Ado.ExecuteCommandAsync("""
+                UPDATE WorkOrdTemp
+                SET available = IFNULL(locStock, 0),
+                    shortage  = IFNULL(locStock, 0) - IFNULL(ljsl, 0)
+                WHERE tenant_id = @TenantId AND DATE(OrdDate) <= CURDATE() + INTERVAL 1 DAY
+                """, tenantPar);
+            await _db.Ado.ExecuteCommandAsync("""
+                UPDATE WorkOrdTemp
+                SET available = IFNULL(locStock, 0) + IFNULL(zjsl, 0),
+                    shortage  = IFNULL(locStock, 0) + IFNULL(zjsl, 0) - IFNULL(ljsl, 0)
+                WHERE tenant_id = @TenantId AND DATE(OrdDate) = CURDATE() + INTERVAL 2 DAY
+                """, tenantPar);
+            await _db.Ado.ExecuteCommandAsync("""
+                UPDATE WorkOrdTemp
+                SET available = IFNULL(locStock, 0) + IFNULL(zjsl, 0) + IFNULL(ztsl, 0),
+                    shortage  = IFNULL(locStock, 0) + IFNULL(zjsl, 0) + IFNULL(ztsl, 0) - IFNULL(ljsl, 0)
+                WHERE tenant_id = @TenantId AND DATE(OrdDate) = CURDATE() + INTERVAL 3 DAY
+                """, tenantPar);
+            await _db.Ado.ExecuteCommandAsync("""
+                UPDATE WorkOrdTemp
+                SET available = IFNULL(locStock, 0) + IFNULL(zjsl, 0) + IFNULL(ztsl, 0) + IFNULL(jqsl, 0),
+                    shortage  = IFNULL(locStock, 0) + IFNULL(zjsl, 0) + IFNULL(ztsl, 0) + IFNULL(jqsl, 0) - IFNULL(ljsl, 0)
+                WHERE tenant_id = @TenantId
+                  AND DATE(OrdDate) >= CURDATE() + INTERVAL 4 DAY
+                  AND DATE(OrdDate) <= CURDATE() + INTERVAL 14 DAY
+                """, tenantPar);
+
+            // 清理辅助表(仅本租户)
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM _tmp_inv_master WHERE tenant_id = @TenantId", tenantPar);
+
+            // ── DWD 写入 ──
+            await _db.Ado.BeginTranAsync();
+            try
+            {
+            await _db.Ado.ExecuteCommandAsync(
+                """
+                DELETE FROM dwd_material_readiness
+                WHERE tenant_id = @TenantId
+                  AND work_order NOT IN (
+                    SELECT DISTINCT workord FROM WorkOrdTemp WHERE IFNULL(workord, '') <> '' AND tenant_id = @TenantId
+                )
+                """, tenantPar);
+
+            var dwdCount = await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO dwd_material_readiness
+                (tenant_id, stat_date, work_order, op_code, parent_item_code, component_item_code, component_item_name, required_qty, cumulative_required_qty, stock_available_qty, qc_pending_qty, in_transit_qty, delivery_reply_qty, available_qty, shortage_qty, ready_status, supplier_code, supplier_name, need_date, calc_batch_id, calc_time)
+                SELECT
+                    @TenantId, @StatDate,
+                    IFNULL(workord, ''), CAST(IFNULL(Op, 0) AS CHAR), IFNULL(bom_number, ''),
+                    COALESCE(NULLIF(ItemNum, ''), NULLIF(PMBOM, ''), CONCAT('WORKORDTEMP-', IFNULL(RowNum, 0))),
+                    IFNULL(ComponentItemDescr, ''),
+                    IFNULL(QtyRequired, 0), IFNULL(ljsl, 0), IFNULL(locStock, 0),
+                    IFNULL(jqsl, 0), IFNULL(ztsl, 0), IFNULL(PurOrdQty, 0), IFNULL(available, 0),
+                    GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)),
+                    CASE WHEN GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)) > 0 THEN 'SHORTAGE' ELSE 'READY' END,
+                    supplier_number, supplier_name, COALESCE(jhshrq, hfjq, RecDate),
+                    @BatchId, @SyncTime
+                FROM WorkOrdTemp
+                WHERE IFNULL(workord, '') <> '' AND tenant_id = @TenantId
+                ON DUPLICATE KEY UPDATE
+                    component_item_name = VALUES(component_item_name),
+                    required_qty = VALUES(required_qty),
+                    cumulative_required_qty = VALUES(cumulative_required_qty),
+                    stock_available_qty = VALUES(stock_available_qty),
+                    qc_pending_qty = VALUES(qc_pending_qty),
+                    in_transit_qty = VALUES(in_transit_qty),
+                    delivery_reply_qty = VALUES(delivery_reply_qty),
+                    available_qty = VALUES(available_qty),
+                    shortage_qty = VALUES(shortage_qty),
+                    ready_status = VALUES(ready_status),
+                    supplier_code = VALUES(supplier_code),
+                    supplier_name = VALUES(supplier_name),
+                    need_date = VALUES(need_date),
+                    calc_time = VALUES(calc_time),
+                    update_time = CURRENT_TIMESTAMP
+                """,
+                new SugarParameter("@StatDate", now.Date),
+                new SugarParameter("@BatchId", batchId),
+                new SugarParameter("@SyncTime", now),
+                tenantPar);
+
+            await _db.Ado.ExecuteCommandAsync(
+                "DELETE FROM dwd_material_shortage WHERE stat_date = @StatDate AND tenant_id = @TenantId",
+                new SugarParameter("@StatDate", now.Date), tenantPar);
+
+            var shortageCount = await _db.Ado.ExecuteCommandAsync(
+                """
+                INSERT INTO dwd_material_shortage
+                (tenant_id, stat_date, work_order, op_code, component_item_code, shortage_qty, shortage_reason, expected_supply_date, supplier_code, related_po_no, risk_level, calc_batch_id, calc_time)
+                SELECT
+                    @TenantId, @StatDate,
+                    IFNULL(workord, ''), CAST(IFNULL(Op, 0) AS CHAR),
+                    COALESCE(NULLIF(ItemNum, ''), NULLIF(PMBOM, ''), CONCAT('WORKORDTEMP-', IFNULL(RowNum, 0))),
+                    GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)),
+                    '当前可用量不足', COALESCE(jhshrq, hfjq, RecDate),
+                    supplier_number, PurOrds,
+                    CASE
+                        WHEN GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)) > IFNULL(QtyRequired, 0) THEN 'HIGH'
+                        WHEN GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)) > 0 THEN 'MEDIUM'
+                        ELSE 'LOW'
+                    END,
+                    @BatchId, @SyncTime
+                FROM WorkOrdTemp
+                WHERE IFNULL(workord, '') <> '' AND tenant_id = @TenantId
+                  AND GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)) > 0
+                """,
+                new SugarParameter("@StatDate", now.Date),
+                new SugarParameter("@BatchId", batchId),
+                new SugarParameter("@SyncTime", now),
+                tenantPar);
+
+                await _db.Ado.CommitTranAsync();
+                return new
+                {
+                    enabled = true, batchId, workOrdTempCount, dwdCount, shortageCount,
+                    elapsedMs = sw.ElapsedMilliseconds,
+                    message = $"齐套/缺料刷新完成:获取 {workOrdTempCount} 条 WorkOrdTemp,写入 DWD 齐套 {dwdCount} 条、缺料 {shortageCount} 条。"
+                };
+            }
+            catch
+            {
+                await _db.Ado.RollbackTranAsync();
+                throw;
+            }
         }
         catch (Exception ex)
         {
-            return new { enabled = false, message = $"执行存储过程 pr_MES_Report_DayWorkOrdDetailShortage_New 失败: {ex.Message}" };
+            return new { enabled = false, elapsedMs = sw.ElapsedMilliseconds, message = ex.Message };
         }
-        finally
-        {
-            _db.Ado.CommandTimeOut = prevTimeout;
-        }
-
-        var workOrdTempCount = await _db.Ado.GetIntAsync(
-            "SELECT COUNT(1) FROM WorkOrdTemp WHERE IFNULL(workord, '') <> ''");
+    }
 
-        await _db.Ado.BeginTranAsync();
-        try
-        {
-        // Step 2: 清理 dwd_material_readiness 旧数据(保留当前 WorkOrdTemp 中存在的工单)
-        await _db.Ado.ExecuteCommandAsync(
-            """
-            DELETE FROM dwd_material_readiness
-            WHERE tenant_id = @TenantId
-              AND work_order NOT IN (
-                SELECT DISTINCT workord FROM WorkOrdTemp WHERE IFNULL(workord, '') <> ''
+    /// <summary>
+    /// 从 SQL Server 读取 InvMaster 在库库存,写入 MySQL 辅助表 _tmp_inv_master。
+    /// </summary>
+    private async Task PrepareInvMasterTempAsync(string domain, long tenantId)
+    {
+        await _db.Ado.ExecuteCommandAsync("""
+            CREATE TABLE IF NOT EXISTS _tmp_inv_master (
+                tenant_id BIGINT NOT NULL,
+                ItemNum VARCHAR(50) NOT NULL,
+                locStock DECIMAL(18,6),
+                PRIMARY KEY (tenant_id, ItemNum)
             )
-            """,
-            tenantPar);
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            "DELETE FROM _tmp_inv_master WHERE tenant_id = @TenantId",
+            new List<SugarParameter> { new("@TenantId", tenantId) });
 
-        // Step 3: 从 WorkOrdTemp 写入 dwd_material_readiness
-        var dwdCount = await _db.Ado.ExecuteCommandAsync(
-            """
-            INSERT INTO dwd_material_readiness
-            (tenant_id, stat_date, work_order, op_code, parent_item_code, component_item_code, component_item_name, required_qty, cumulative_required_qty, stock_available_qty, qc_pending_qty, in_transit_qty, delivery_reply_qty, available_qty, shortage_qty, ready_status, supplier_code, supplier_name, need_date, calc_batch_id, calc_time)
-            SELECT
-                @TenantId,
-                @StatDate,
-                IFNULL(workord, ''),
-                CAST(IFNULL(Op, 0) AS CHAR),
-                IFNULL(bom_number, ''),
-                COALESCE(NULLIF(ItemNum, ''), NULLIF(PMBOM, ''), CONCAT('WORKORDTEMP-', IFNULL(RowNum, 0))),
-                IFNULL(ComponentItemDescr, ''),
-                IFNULL(QtyRequired, 0),
-                IFNULL(ljsl, 0),
-                IFNULL(locStock, 0),
-                IFNULL(jqsl, 0),
-                IFNULL(ztsl, 0),
-                IFNULL(PurOrdQty, 0),
-                IFNULL(available, 0),
-                GREATEST(
-                    IFNULL(shortage, 0),
-                    IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)
-                ),
-                CASE WHEN GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)) > 0 THEN 'SHORTAGE' ELSE 'READY' END,
-                supplier_number,
-                supplier_name,
-                COALESCE(jhshrq, hfjq, RecDate),
-                @BatchId,
-                @SyncTime
-            FROM WorkOrdTemp
-            WHERE IFNULL(workord, '') <> ''
-            ON DUPLICATE KEY UPDATE
-                component_item_name = VALUES(component_item_name),
-                required_qty = VALUES(required_qty),
-                cumulative_required_qty = VALUES(cumulative_required_qty),
-                stock_available_qty = VALUES(stock_available_qty),
-                qc_pending_qty = VALUES(qc_pending_qty),
-                in_transit_qty = VALUES(in_transit_qty),
-                delivery_reply_qty = VALUES(delivery_reply_qty),
-                available_qty = VALUES(available_qty),
-                shortage_qty = VALUES(shortage_qty),
-                ready_status = VALUES(ready_status),
-                supplier_code = VALUES(supplier_code),
-                supplier_name = VALUES(supplier_name),
-                need_date = VALUES(need_date),
-                calc_time = VALUES(calc_time),
-                update_time = CURRENT_TIMESTAMP
-            """,
-            new SugarParameter("@StatDate", now.Date),
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@SyncTime", now),
-            tenantPar);
+        var locRange = await _db.Ado.GetStringAsync(
+            "SELECT Val FROM ConfigurationItem WHERE FldName='MRPLocationRange' AND tenant_id = @TenantId LIMIT 1",
+            new List<SugarParameter> { new("@TenantId", tenantId) });
+        if (string.IsNullOrWhiteSpace(locRange)) return;
 
-        // Step 4: 从 WorkOrdTemp 写入 dwd_material_shortage(先清理当天旧数据)
-        // WorkOrdTemp 为存储过程产出的无租户中间表,租户来自调用方 JWT。
-        await _db.Ado.ExecuteCommandAsync(
-            "DELETE FROM dwd_material_shortage WHERE stat_date = @StatDate AND tenant_id = @TenantId",
-            new SugarParameter("@StatDate", now.Date),
-            tenantPar);
-
-        var shortageCount = await _db.Ado.ExecuteCommandAsync(
-            """
-            INSERT INTO dwd_material_shortage
-            (tenant_id, stat_date, work_order, op_code, component_item_code, shortage_qty, shortage_reason, expected_supply_date, supplier_code, related_po_no, risk_level, calc_batch_id, calc_time)
-            SELECT
-                @TenantId,
-                @StatDate,
-                IFNULL(workord, ''),
-                CAST(IFNULL(Op, 0) AS CHAR),
-                COALESCE(NULLIF(ItemNum, ''), NULLIF(PMBOM, ''), CONCAT('WORKORDTEMP-', IFNULL(RowNum, 0))),
-                GREATEST(
-                    IFNULL(shortage, 0),
-                    IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)
-                ),
-                '当前可用量不足',
-                COALESCE(jhshrq, hfjq, RecDate),
-                supplier_number,
-                PurOrds,
-                CASE
-                    WHEN GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)) > IFNULL(QtyRequired, 0) THEN 'HIGH'
-                    WHEN GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)) > 0 THEN 'MEDIUM'
-                    ELSE 'LOW'
-                END,
-                @BatchId,
-                @SyncTime
-            FROM WorkOrdTemp
-            WHERE IFNULL(workord, '') <> ''
-              AND GREATEST(IFNULL(shortage, 0), IFNULL(QtyRequired, 0) - IFNULL(locStock, 0) - IFNULL(jqsl, 0) - IFNULL(ztsl, 0) - IFNULL(PurOrdQty, 0)) > 0
-            """,
-            new SugarParameter("@StatDate", now.Date),
-            new SugarParameter("@BatchId", batchId),
-            new SugarParameter("@SyncTime", now),
-            tenantPar);
+        var locations = locRange.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+        if (locations.Length == 0) return;
 
-            await _db.Ado.CommitTranAsync();
-            return new
-            {
-                enabled = true,
-                batchId,
-                workOrdTempCount,
-                dwdCount,
-                shortageCount,
-                message = $"齐套/缺料刷新完成:执行存储过程获取 {workOrdTempCount} 条 WorkOrdTemp 数据,写入 DWD 齐套 {dwdCount} 条、缺料 {shortageCount} 条。"
-            };
+        var remote = await _scopeFactory.GetScopeAsync(SqlServerSourceCode);
+        var locParams = new List<string>();
+        var pars = new List<SugarParameter> { new("@Domain", domain) };
+        for (var i = 0; i < locations.Length; i++)
+        {
+            locParams.Add($"@loc{i}");
+            pars.Add(new SugarParameter($"@loc{i}", locations[i]));
         }
-        catch
+
+        var invSql = $"""
+            SELECT ItemNum, SUM(ISNULL(AvailStatusQty, 0) + ISNULL(Assay, 0)) AS locStock
+            FROM InvMaster
+            WHERE Domain = @Domain AND Location IN ({string.Join(",", locParams)})
+            GROUP BY ItemNum
+            """;
+        var rows = await remote.Ado.SqlQueryAsync<LocStockRow>(invSql, pars);
+        if (rows.Count == 0) return;
+
+        const int batchSize = 500;
+        for (var offset = 0; offset < rows.Count; offset += batchSize)
         {
-            await _db.Ado.RollbackTranAsync();
-            throw;
+            var batch = rows.Skip(offset).Take(batchSize).ToList();
+            var batchPars = new List<SugarParameter> { new("@TenantId", tenantId) };
+            var values = new List<string>();
+            for (var i = 0; i < batch.Count; i++)
+            {
+                if (string.IsNullOrWhiteSpace(batch[i].ItemNum)) continue;
+                values.Add($"(@TenantId, @item{i}, @stock{i})");
+                batchPars.Add(new SugarParameter($"@item{i}", batch[i].ItemNum));
+                batchPars.Add(new SugarParameter($"@stock{i}", batch[i].LocStock));
+            }
+            if (values.Count > 0)
+            {
+                await _db.Ado.ExecuteCommandAsync(
+                    $"INSERT INTO _tmp_inv_master (tenant_id, ItemNum, locStock) VALUES {string.Join(",", values)}",
+                    batchPars);
+            }
         }
     }
 
+    private sealed class LocStockRow
+    {
+        public string? ItemNum { get; set; }
+        public decimal LocStock { get; set; }
+    }
+
     private async Task<bool> ShouldUseDwdAsync(WorkOrderMaterialReadinessListInput input)
     {
         if (!string.IsNullOrWhiteSpace(input.SeOrder))