Преглед изворни кода

fix: 发货单待交数量扣除已有发货单+送货数量超发校验+待交数量非负+工单状态回退保护(热回读守卫+触发器)

Pengxy пре 14 часа
родитељ
комит
83a0391719

+ 1 - 1
Web/package.json

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

+ 2 - 2
Web/src/views/aidop/s4/delivery/supplierShipmentForm.vue

@@ -73,7 +73,7 @@
 							v-model="row.shDeliveryQuantity"
 							:controls="false"
 							:min="0"
-							:max="row.djsl ?? undefined"
+							:max="row.djsl || undefined"
 							:disabled="fieldLocked"
 							style="width: 100%"
 							@change="() => recalcDetailBqsl(row)"
@@ -105,7 +105,7 @@
 				</el-table-column>
 				<el-table-column label="生产批号" width="120"><template #default="{ row }"><el-input v-model="row.scph" :disabled="fieldLocked" /></template></el-table-column>
 				<el-table-column label="备注" min-width="140"><template #default="{ row }"><el-input v-model="row.remarks" :disabled="fieldLocked" /></template></el-table-column>
-				<el-table-column label="待交数量" width="100"><template #default="{ row }"><el-input-number v-model="row.djsl" :controls="false" :disabled="fieldLocked" style="width: 100%" /></template></el-table-column>
+				<el-table-column label="待交数量" width="100"><template #default="{ row }"><el-input-number v-model="row.djsl" :controls="false" disabled style="width: 100%" /></template></el-table-column>
 				<el-table-column label="检验报告" min-width="220">
 					<template #default="{ row }">
 						<ShipmentAttachment v-model="row.jybb" :readonly="isView" />

+ 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.473</AssemblyVersion>
-    <FileVersion>1.0.473</FileVersion>
-    <Version>1.0.473</Version>
+    <AssemblyVersion>1.0.474</AssemblyVersion>
+    <FileVersion>1.0.474</FileVersion>
+    <Version>1.0.474</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 24 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.463.sql

@@ -0,0 +1,24 @@
+-- 1.0.463:WorkOrdMaster 状态回退保护(禁止 r/w/s/c 回退为 p)
+-- 背景:本库工单与 165(MES) 热回读共管(MdpHotWatchService 轮询回写 Status)。
+--   存在状态时间差竞态:本库已下达/投产(r/w/s)甚至关单(c),而 165 侧仍为 p(已发布),
+--   轮询回写会把执行态工单悄悄打回计划态 p,导致齐套看板判定紊乱(状态回退后不再满足
+--   下达/投产口径)、领料单更新逻辑(仅处理 r/w/s)失效。
+-- 方案B(静默抑制):BEFORE UPDATE 触发器拦截 NEW.Status='p' 且 OLD.Status IN ('r','w','s','c'),
+--   保留 OLD.Status;不阻断语句,其余字段(QtyCompleted 报工量等)正常更新。
+-- 配套应用层根因修复:MdpHotWatchService 回写 CASE 已加同规则守卫;本触发器为全库兜底,
+--   覆盖手工 SQL / scheduling/status PATCH 接口等其他一切写入路径。
+-- 合法路径不受影响:交期确认仅从空状态写 p(WHERE Status=''),MES 投产 r→W 正常放行。
+-- 幂等:DROP TRIGGER IF EXISTS 后重建;触发器体为单语句(SET ... = IF(...)),
+--   无 BEGIN...END、无中间分号,无需 DELIMITER,兼容按分号拆分的迁移执行器。
+
+DROP TRIGGER IF EXISTS trg_wo_status_no_regress_p;
+
+CREATE TRIGGER trg_wo_status_no_regress_p
+BEFORE UPDATE ON WorkOrdMaster
+FOR EACH ROW
+SET NEW.Status = IF(
+    LOWER(TRIM(IFNULL(NEW.Status, ''))) = 'p'
+    AND LOWER(TRIM(IFNULL(OLD.Status, ''))) IN ('r', 'w', 's', 'c'),
+    OLD.Status,
+    NEW.Status
+);

+ 7 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs

@@ -505,7 +505,13 @@ public sealed class MdpHotWatchService : ITransient
                 """
                 UPDATE WorkOrdMaster
                 SET QtyCompleted = @QtyCompleted,
-                    Status = CASE WHEN IFNULL(@Status,'') = '' THEN Status ELSE @Status END,
+                    Status = CASE
+                        WHEN IFNULL(@Status,'') = '' THEN Status
+                        -- 执行态守卫:165 侧仍为 p 的时间差竞态下,禁止把本地 r/w/s/c 打回 p(与触发器 trg_wo_status_no_regress_p 同规则)
+                        WHEN LOWER(TRIM(IFNULL(@Status,''))) = 'p'
+                             AND LOWER(TRIM(IFNULL(Status,''))) IN ('r','w','s','c') THEN Status
+                        ELSE @Status
+                    END,
                     UpdateUser = 'MDP_HOT',
                     UpdateTime = @Now
                 WHERE WorkOrd = @WorkOrd

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierDeliveryManagementService.cs

@@ -399,6 +399,7 @@ public class SupplierDeliveryManagementService : IDynamicApiController, ITransie
             v.*,
             CASE
                 WHEN v.jhdsl - IFNULL(v.zfhl, 0) + IFNULL(v.thsl, 0) > v.wjhsl THEN v.wjhsl
+                WHEN v.jhdsl - IFNULL(v.zfhl, 0) + IFNULL(v.thsl, 0) < 0 THEN 0
                 ELSE v.jhdsl - IFNULL(v.zfhl, 0) + IFNULL(v.thsl, 0)
             END AS dfhsl,
             CASE

+ 170 - 30
server/Plugins/Admin.NET.Plugin.AiDOP/ProcurementExecution/SupplierShipmentService.cs

@@ -124,6 +124,24 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
         var master = await _masterRep.GetFirstAsync(x => x.Id == id && x.TenantId == tenantId) ?? throw Oops.Oh("发货单不存在");
         var details = await _detailRep.AsQueryable().Where(x => x.Glid == id.ToString() && x.TenantId == tenantId).OrderBy(x => x.Hh).ToListAsync();
+
+        // 待交数量按订单行实时重算(排除本发货单自身明细,避免重复扣减),查不到订单行时沿用明细原值
+        var djslOverrides = new Dictionary<long, decimal>();
+        var remainCache = new Dictionary<string, decimal?>();
+        foreach (var d in details)
+        {
+            if (string.IsNullOrWhiteSpace(d.PoBill) || !int.TryParse(d.PoBillLine?.Trim(), out var line))
+                continue;
+            var cacheKey = $"{d.PoBill.Trim()}|{line}";
+            if (!remainCache.TryGetValue(cacheKey, out var remain))
+            {
+                remain = await GetPoRemainQtyAsync(d.PoBill.Trim(), line, id.ToString(), tenantId);
+                remainCache[cacheKey] = remain;
+            }
+            if (remain is not null)
+                djslOverrides[d.Id] = remain.Value;
+        }
+
         return new
         {
             id = master.Id,
@@ -155,7 +173,7 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                 scrq = d.Scrq,
                 scph = d.Scph,
                 remarks = d.Remarks,
-                djsl = d.Djsl,
+                djsl = djslOverrides.TryGetValue(d.Id, out var liveDjsl) ? liveDjsl : d.Djsl,
                 jybb = d.Jybb,
                 jhdbh = d.Jhdbh
             })
@@ -180,18 +198,27 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
                 p.ItemNum AS shMaterialCode,
                 im.Descr AS shMaterialName,
                 IFNULL(im.Drawing, p.Drawing) AS th,
-                IFNULL(ds.SchedQty, p.QtyOrded - p.RctQty - p.ReceiptQty + p.QtyReturned) AS shDeliveryQuantity,
                 p.StdPackQty AS bzsl,
-                CASE
-                    WHEN IFNULL(p.StdPackQty, 0) > 0 THEN CEILING(IFNULL(ds.SchedQty, p.QtyOrded - p.RctQty - p.ReceiptQty + p.QtyReturned) / p.StdPackQty)
-                    ELSE 0
-                END AS bqsl,
                 p.UM AS shMaterialDw,
-                (p.QtyOrded - p.RctQty - p.ReceiptQty + p.QtyReturned) AS djsl,
                 ds.DSNum AS jhdbh,
-                im.Descr1 AS shMaterialGgxh
+                im.Descr1 AS shMaterialGgxh,
+                p.QtyOrded AS QtyOrded,
+                p.RctQty AS RctQty,
+                p.ReceiptQty AS ReceiptQty,
+                p.QtyReturned AS QtyReturned,
+                ds.SchedQty AS SchedQty,
+                IFNULL(s.fhsl, 0) AS ShippedFhsl
             FROM PurOrdDetail p
             LEFT JOIN srm_polist_ds ds ON p.PurOrd = ds.ponumber AND p.Line = ds.poline AND ds.tenant_id = p.tenant_id
+            LEFT JOIN (
+                SELECT d.po_bill, d.po_billline, SUM(d.sh_delivery_quantity) AS fhsl
+                FROM scm_shdzb d
+                INNER JOIN scm_shd m ON CAST(m.id AS CHAR) = d.glid
+                WHERE m.tenant_id = @TenantId
+                  AND IFNULL(m.state, 1) <> 0
+                  AND IFNULL(d.po_bill, '') <> ''
+                GROUP BY d.po_bill, d.po_billline
+            ) s ON s.po_bill = p.PurOrd AND s.po_billline = p.Line
             LEFT JOIN ItemMaster im ON p.ItemNum = im.ItemNum AND (im.tenant_id = p.tenant_id OR p.tenant_id IS NULL)
             WHERE FIND_IN_SET(CAST(IFNULL(ds.id, p.RecID) AS CHAR(50)), REPLACE(IFNULL(@ids, ''), ' ', '')) > 0
               AND p.tenant_id = @TenantId
@@ -213,26 +240,35 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
             sfpc = 0,
             chbg = string.Empty,
             pcsm = string.Empty,
-            details = rows.Select((r, i) => new
+            details = rows.Select((r, i) =>
             {
-                id = (long?)null,
-                hh = i + 1,
-                poBill = r.PoBill,
-                poBillLine = r.PoBillLine?.ToString(),
-                orderType = r.OrderType,
-                shMaterialCode = r.ShMaterialCode,
-                shMaterialName = r.ShMaterialName,
-                th = r.Th,
-                shDeliveryQuantity = r.ShDeliveryQuantity,
-                bzsl = r.Bzsl,
-                bqsl = r.Bqsl ?? 0,
-                shMaterialDw = r.ShMaterialDw,
-                scrq = string.Empty,
-                scph = string.Empty,
-                remarks = string.Empty,
-                djsl = r.Djsl,
-                jybb = string.Empty,
-                jhdbh = r.Jhdbh
+                // 待交数量需扣除已有发货单的累计送货量(含已收货),剩余的才是本次可发数量
+                var djsl = CalcPendingDeliveryQty(
+                    r.QtyOrded ?? 0m, r.RctQty ?? 0m, r.ReceiptQty ?? 0m, r.QtyReturned ?? 0m, r.ShippedFhsl ?? 0m);
+                var poPending = (r.QtyOrded ?? 0m) - (r.RctQty ?? 0m) - (r.ReceiptQty ?? 0m) + (r.QtyReturned ?? 0m);
+                var defaultQty = Math.Min(r.SchedQty ?? poPending, djsl);
+                var bqsl = (r.Bzsl ?? 0m) > 0m ? Math.Ceiling(defaultQty / r.Bzsl!.Value) : 0m;
+                return new
+                {
+                    id = (long?)null,
+                    hh = i + 1,
+                    poBill = r.PoBill,
+                    poBillLine = r.PoBillLine?.ToString(),
+                    orderType = r.OrderType,
+                    shMaterialCode = r.ShMaterialCode,
+                    shMaterialName = r.ShMaterialName,
+                    th = r.Th,
+                    shDeliveryQuantity = defaultQty,
+                    bzsl = r.Bzsl,
+                    bqsl,
+                    shMaterialDw = r.ShMaterialDw,
+                    scrq = string.Empty,
+                    scph = string.Empty,
+                    remarks = string.Empty,
+                    djsl,
+                    jybb = string.Empty,
+                    jhdbh = r.Jhdbh
+                };
             })
         };
     }
@@ -247,6 +283,9 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         var userName = _userManager.Account ?? "system";
         var shipDate = string.IsNullOrWhiteSpace(input.Jhshrq) ? now.ToString("yyyy-MM-dd") : input.Jhshrq!.Trim();
 
+        // 送货数量不得超过待交数量(订单数量 - 已有发货单累计送货量 + 退货量;编辑时排除本单自身明细)
+        await ValidateDeliveryQtyAsync(input.Id, input.Details, tenantId);
+
         if (input.Id is null or 0)
         {
             var newId = YitIdHelper.NextId();
@@ -1141,6 +1180,95 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         }
     }
 
+    /// <summary>
+    /// 待交数量:订单数量 - 未删除发货单累计送货量 + 退货量,并以(订单数量 - 已收货 - 已入库 + 退货量)封顶、0 保底。
+    /// 已有发货单(无论是否已收货)的数量一律计入已发,避免“已发货未收货”时待交数量虚高。
+    /// </summary>
+    private static decimal CalcPendingDeliveryQty(decimal qtyOrded, decimal rctQty, decimal receiptQty, decimal qtyReturned, decimal shipped)
+    {
+        var poPending = qtyOrded - rctQty - receiptQty + qtyReturned;
+        var pending = qtyOrded - shipped + qtyReturned;
+        if (pending > poPending)
+            pending = poPending;
+        return pending < 0 ? 0 : pending;
+    }
+
+    /// <summary>
+    /// 查询采购订单行当前待交数量;未找到订单行返回 null(调用方跳过校验/沿用旧值)。
+    /// excludeGlid 传当前发货单 id 字符串,编辑场景排除本单自身明细,避免重复扣减。
+    /// </summary>
+    private async Task<decimal?> GetPoRemainQtyAsync(string poBill, int line, string excludeGlid, long tenantId)
+    {
+        var rows = await _db.Ado.SqlQueryAsync<PoRemainRow>(
+            """
+            SELECT
+                p.QtyOrded AS QtyOrded,
+                p.RctQty AS RctQty,
+                p.ReceiptQty AS ReceiptQty,
+                p.QtyReturned AS QtyReturned,
+                IFNULL((
+                    SELECT SUM(d.sh_delivery_quantity)
+                    FROM scm_shdzb d
+                    INNER JOIN scm_shd m ON CAST(m.id AS CHAR) = d.glid
+                    WHERE d.po_bill = p.PurOrd
+                      AND d.po_billline = p.Line
+                      AND m.tenant_id = p.tenant_id
+                      AND IFNULL(m.state, 1) <> 0
+                      AND IFNULL(d.glid, '') <> @ExcludeGlid
+                ), 0) AS Shipped
+            FROM PurOrdDetail p
+            WHERE p.PurOrd = @PoBill
+              AND p.Line = @Line
+              AND p.tenant_id = @TenantId
+            LIMIT 1
+            """,
+            new List<SugarParameter>
+            {
+                new("@PoBill", poBill),
+                new("@Line", line),
+                new("@ExcludeGlid", excludeGlid),
+                new("@TenantId", tenantId),
+            });
+        var row = rows.FirstOrDefault();
+        if (row?.QtyOrded is null)
+            return null;
+
+        return CalcPendingDeliveryQty(
+            row.QtyOrded ?? 0m, row.RctQty ?? 0m, row.ReceiptQty ?? 0m, row.QtyReturned ?? 0m, row.Shipped ?? 0m);
+    }
+
+    /// <summary>
+    /// 保存前校验:同一采购订单行的送货数量(含本次表单多行合计)不得超过待交数量,
+    /// 即已有发货单时待交数量 = 订单待交数量 - 发货单数量。
+    /// </summary>
+    private async Task ValidateDeliveryQtyAsync(long? shipmentId, List<SupplierShipmentDetailInput> details, long tenantId)
+    {
+        if (details.Count == 0)
+            return;
+
+        var excludeGlid = (shipmentId ?? 0) > 0 ? shipmentId!.ToString() : string.Empty;
+        var groups = details
+            .Where(d => !string.IsNullOrWhiteSpace(d.PoBill) && !string.IsNullOrWhiteSpace(d.PoBillLine))
+            .GroupBy(d => new { PoBill = d.PoBill!.Trim(), PoBillLine = d.PoBillLine!.Trim() })
+            .ToList();
+
+        foreach (var g in groups)
+        {
+            if (!int.TryParse(g.Key.PoBillLine, out var line))
+                continue;
+            var qty = g.Sum(x => x.ShDeliveryQuantity ?? 0m);
+            if (qty <= 0)
+                continue;
+
+            var remain = await GetPoRemainQtyAsync(g.Key.PoBill, line, excludeGlid, tenantId);
+            if (remain is null)
+                continue;
+
+            if (qty > remain.Value)
+                throw Oops.Oh($"采购订单 {g.Key.PoBill} 第 {line} 行送货数量({qty})超过待交数量({remain.Value}),请调整");
+        }
+    }
+
     private static string BuildListOrderBy(string? sortField, string? sortOrder)
     {
         var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
@@ -1295,13 +1423,25 @@ public class SupplierShipmentService : IDynamicApiController, ITransient
         public string? ShMaterialCode { get; set; }
         public string? ShMaterialName { get; set; }
         public string? Th { get; set; }
-        public decimal? ShDeliveryQuantity { get; set; }
         public decimal? Bzsl { get; set; }
-        public decimal? Bqsl { get; set; }
         public string? ShMaterialDw { get; set; }
-        public decimal? Djsl { get; set; }
         public string? Jhdbh { get; set; }
         public string? ShMaterialGgxh { get; set; }
+        public decimal? QtyOrded { get; set; }
+        public decimal? RctQty { get; set; }
+        public decimal? ReceiptQty { get; set; }
+        public decimal? QtyReturned { get; set; }
+        public decimal? SchedQty { get; set; }
+        public decimal? ShippedFhsl { get; set; }
+    }
+
+    private sealed class PoRemainRow
+    {
+        public decimal? QtyOrded { get; set; }
+        public decimal? RctQty { get; set; }
+        public decimal? ReceiptQty { get; set; }
+        public decimal? QtyReturned { get; set; }
+        public decimal? Shipped { get; set; }
     }
 
     private sealed class ShippingNoteMasterRow