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

feat: 系统预计交期产能计算 + 选择物料租户隔离加固 + 订单表单列名修正

Pengxy пре 19 часа
родитељ
комит
e13a2e1d9e

+ 1 - 1
Web/package.json

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

+ 5 - 2
Web/src/views/aidop/api/universalSelector.ts

@@ -1,4 +1,5 @@
 import service from '/@/utils/request';
+import { withAidopTenantParams } from './aidopTenant';
 
 // ──────────────── 数据类型 ────────────────
 
@@ -35,6 +36,7 @@ export interface CustomerPageParams {
 	pageSize: number;
 	sortField?: string;
 	sortOrder?: string;
+	[key: string]: unknown;
 }
 
 export interface ItemPageParams {
@@ -44,14 +46,15 @@ export interface ItemPageParams {
 	pageSize: number;
 	sortField?: string;
 	sortOrder?: string;
+	[key: string]: unknown;
 }
 
 // ──────────────── API 调用 ────────────────
 
 export function fetchCustomerPage(params: CustomerPageParams) {
-	return service.get<PageResult<CustomerRow>>('/api/Universal/customer/page', { params }).then((r) => r.data);
+	return service.get<PageResult<CustomerRow>>('/api/Universal/customer/page', { params: withAidopTenantParams(params as Record<string, unknown>) }).then((r) => r.data);
 }
 
 export function fetchItemPage(params: ItemPageParams) {
-	return service.get<PageResult<ItemRow>>('/api/Universal/item/page', { params }).then((r) => r.data);
+	return service.get<PageResult<ItemRow>>('/api/Universal/item/page', { params: withAidopTenantParams(params as Record<string, unknown>) }).then((r) => r.data);
 }

+ 7 - 2
Web/src/views/aidop/business/salesOrderForm.vue

@@ -95,7 +95,7 @@
 						<el-date-picker v-model="row.planDate" :disabled="isView" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
 					</template>
 				</el-table-column>
-				<el-table-column label="系统建议交期(产能)" width="160" align="center">
+				<el-table-column label="系统预计交期" width="160" align="center">
 					<template #default="{ row }">
 						<el-date-picker v-model="row.sysCapacityDate" :disabled="isView" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
 					</template>
@@ -248,7 +248,12 @@ async function onSave() {
 	}
 	saving.value = true;
 	try {
-		await saveSeOrder(form);
+		// 子表最终交货日期不写入
+		const payload = {
+			...form,
+			entries: (form.entries || []).map((x) => ({ ...x, date: null })),
+		};
+		await saveSeOrder(payload);
 		ElMessage.success('保存成功');
 		emit('saved');
 	} finally {

+ 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.248</AssemblyVersion>
-    <FileVersion>1.0.248</FileVersion>
-    <Version>1.0.248</Version>
+    <AssemblyVersion>1.0.249</AssemblyVersion>
+    <FileVersion>1.0.249</FileVersion>
+    <Version>1.0.249</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 293 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Order/CapacityDateCalculator.cs

@@ -0,0 +1,293 @@
+namespace Admin.NET.Plugin.AiDOP.Order;
+
+/// <summary>
+/// 产能交期计算器:根据工艺路线、产线产能、工作日历和节假日,
+/// 从物料齐套日次日开始逐日累加产能,计算生产结束日期。
+/// </summary>
+public class CapacityDateCalculator : ITransient
+{
+    private readonly ISqlSugarClient _db;
+
+    public CapacityDateCalculator(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    // ── 内部 DTO ──────────────────────────────────────────────
+
+    private sealed class RoutingOpRow
+    {
+        public int Op { get; set; }
+        public int ParentOp { get; set; }
+        public string RoutingCode { get; set; } = string.Empty;
+    }
+
+    private sealed class ProdLineRow
+    {
+        public string Part { get; set; } = string.Empty;
+        public int Op { get; set; }
+        public string Line { get; set; } = string.Empty;
+        public decimal Rate { get; set; }
+    }
+
+    private sealed class CalendarRow
+    {
+        public string ProdLine { get; set; } = string.Empty;
+        public int WeekDay { get; set; }
+        public decimal ShiftsHours1 { get; set; }
+    }
+
+    private sealed class QualityRow
+    {
+        public string ProdLine { get; set; } = string.Empty;
+        public decimal RestTime { get; set; }
+    }
+
+    private sealed class HolidayRow
+    {
+        public DateTime Dated { get; set; }
+        public string Ufld1 { get; set; } = string.Empty;
+    }
+
+    private sealed class IcItemLeadTimes
+    {
+        public int SelfInspectionDate { get; set; }
+        public int WarehousingDate { get; set; }
+        public int ShippingDate { get; set; }
+        public int RoutingDesignTime { get; set; }
+    }
+
+    // ── 公开方法 ──────────────────────────────────────────────
+
+    /// <summary>
+    /// 计算产能交期。
+    /// </summary>
+    /// <param name="itemNum">成品物料编码</param>
+    /// <param name="lackQty">缺口数量(需生产的数量)</param>
+    /// <param name="planStart">物料齐套日(KittingTime),生产从其次日开始</param>
+    /// <param name="planDate">计划交货日期,用于兜底比较</param>
+    /// <param name="tenantId">租户ID</param>
+    /// <returns>计算后的预计交货日期</returns>
+    public async Task<DateTime> CalculateAsync(
+        string itemNum,
+        decimal lackQty,
+        DateTime planStart,
+        DateTime planDate,
+        long tenantId)
+    {
+        if (lackQty <= 0)
+            return planDate;
+
+        // 加载参考数据
+        var routingOps = await LoadRoutingOpsAsync(tenantId);
+        var prodLines = await LoadProdLinesAsync(itemNum, tenantId);
+        var holidays = await LoadHolidaysAsync(tenantId);
+
+        var planStartDate = planStart.Date.AddDays(1);
+
+        // 产能计算
+        var prodEnd = ProductiveExamine(itemNum, lackQty, planStartDate,
+            routingOps, prodLines, holidays, tenantId);
+
+        // 查 ic_item 提前期
+        var leadTimes = await LoadIcItemLeadTimesAsync(itemNum, tenantId);
+        if (leadTimes != null)
+        {
+            var addDays = leadTimes.SelfInspectionDate
+                        + leadTimes.WarehousingDate
+                        + leadTimes.ShippingDate
+                        + leadTimes.RoutingDesignTime;
+            prodEnd = prodEnd.AddDays(addDays);
+        }
+
+        // 与计划交期取较大值
+        return prodEnd < planDate ? planDate : prodEnd;
+    }
+
+    // ── 数据加载 ──────────────────────────────────────────────
+
+    private async Task<List<RoutingOpRow>> LoadRoutingOpsAsync(long tenantId)
+    {
+        return await _db.Ado.SqlQueryAsync<RoutingOpRow>(
+            """
+            SELECT Op, IFNULL(ParentOp, 0) AS ParentOp, RoutingCode
+            FROM RoutingOpDetail
+            WHERE IFNULL(IsActive, 1) = 1 AND tenant_id = @TenantId
+            """,
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    private async Task<List<ProdLineRow>> LoadProdLinesAsync(string itemNum, long tenantId)
+    {
+        return await _db.Ado.SqlQueryAsync<ProdLineRow>(
+            """
+            SELECT Part, Op, IFNULL(Line, '') AS Line, IFNULL(Rate, 0) AS Rate
+            FROM ProdLineDetail
+            WHERE Part = @Part AND tenant_id = @TenantId
+            """,
+            new SugarParameter("@Part", itemNum),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    private async Task<List<HolidayRow>> LoadHolidaysAsync(long tenantId)
+    {
+        return await _db.Ado.SqlQueryAsync<HolidayRow>(
+            """
+            SELECT Dated, IFNULL(Ufld1, '') AS Ufld1
+            FROM HolidayMaster
+            WHERE IFNULL(IsActive, 1) = 1 AND tenant_id = @TenantId
+            """,
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    private async Task<List<CalendarRow>> LoadCalendarsAsync(string prodLine, long tenantId)
+    {
+        return await _db.Ado.SqlQueryAsync<CalendarRow>(
+            """
+            SELECT ProdLine, WeekDay, IFNULL(ShiftsHours1, 0) AS ShiftsHours1
+            FROM ShopCalendarWorkCtr
+            WHERE ProdLine = @ProdLine AND IFNULL(IsActive, 1) = 1 AND tenant_id = @TenantId
+            """,
+            new SugarParameter("@ProdLine", prodLine),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    private async Task<List<QualityRow>> LoadQualityLinesAsync(string prodLine, long tenantId)
+    {
+        return await _db.Ado.SqlQueryAsync<QualityRow>(
+            """
+            SELECT ProdLine, IFNULL(RestTime, 0) AS RestTime
+            FROM QualityLineWorkDetail
+            WHERE ProdLine = @ProdLine AND IFNULL(IsActive, 1) = 1 AND tenant_id = @TenantId
+            """,
+            new SugarParameter("@ProdLine", prodLine),
+            new SugarParameter("@TenantId", tenantId));
+    }
+
+    private async Task<IcItemLeadTimes?> LoadIcItemLeadTimesAsync(string itemNum, long tenantId)
+    {
+        var rows = await _db.Ado.SqlQueryAsync<IcItemLeadTimes>(
+            """
+            SELECT
+                CAST(IFNULL(self_inspection_date, 0) AS SIGNED) AS SelfInspectionDate,
+                CAST(IFNULL(Warehousing_date, 0) AS SIGNED) AS WarehousingDate,
+                CAST(IFNULL(Shipping_date, 0) AS SIGNED) AS ShippingDate,
+                CAST(IFNULL(RoutingDesignTime, 0) AS SIGNED) AS RoutingDesignTime
+            FROM ic_item
+            WHERE number = @ItemNum AND tenant_id = @TenantId
+            LIMIT 1
+            """,
+            new SugarParameter("@ItemNum", itemNum),
+            new SugarParameter("@TenantId", tenantId));
+        return rows.Count > 0 ? rows[0] : null;
+    }
+
+    // ── 产能计算核心 ──────────────────────────────────────────
+
+    private DateTime ProductiveExamine(
+        string itemNum,
+        decimal qtyOrd,
+        DateTime planStart,
+        List<RoutingOpRow> routingOps,
+        List<ProdLineRow> prodLines,
+        List<HolidayRow> holidays,
+        long tenantId)
+    {
+        DateTime planEnd = planStart.Date;
+        if (qtyOrd <= 0)
+            return planEnd;
+
+        // 获取当前产品的工艺路线:主产线,最后一道工序(ParentOp=0)
+        var curRoutingOps = routingOps
+            .Where(p => p.RoutingCode.Equals(itemNum, StringComparison.OrdinalIgnoreCase) && p.ParentOp == 0)
+            .OrderByDescending(p => p.Op)
+            .ToList();
+        if (curRoutingOps.Count == 0)
+            return planEnd;
+
+        var lastOp = curRoutingOps.First();
+
+        // 获取产线
+        var curProdLine = prodLines
+            .FirstOrDefault(p => p.Part.Equals(itemNum, StringComparison.OrdinalIgnoreCase) && p.Op == lastOp.Op);
+        if (curProdLine == null || string.IsNullOrWhiteSpace(curProdLine.Line))
+            return planEnd;
+
+        // 加载产线工作日历(延迟加载,仅在此产线需要时查询)
+        var curCalendars = LoadCalendarsAsync(curProdLine.Line, tenantId).GetAwaiter().GetResult();
+        if (curCalendars.Count == 0 || curCalendars.Count != 7)
+            return planEnd;
+
+        // 加载产线休息时间
+        var curQualityLines = LoadQualityLinesAsync(curProdLine.Line, tenantId).GetAwaiter().GetResult();
+
+        // 逐日累加产能
+        decimal sumAmount = 0m;
+        int maxDays = 365 * 3; // 防止死循环保护
+        int dayCount = 0;
+        do
+        {
+            // 获取工作日
+            planEnd = GetNextWorkDay(planEnd, curCalendars, holidays);
+            // 计算工作日的产能
+            sumAmount += CalcCapacity(planEnd, curProdLine, curCalendars, curQualityLines);
+            planEnd = planEnd.AddDays(1);
+            dayCount++;
+        } while (sumAmount < qtyOrd && dayCount < maxDays);
+
+        return planEnd.AddDays(-1);
+    }
+
+    /// <summary>获取下一个工作日,跳过周末和节假日,处理调班。</summary>
+    private static DateTime GetNextWorkDay(
+        DateTime startTime,
+        List<CalendarRow> curCalendars,
+        List<HolidayRow> holidays)
+    {
+        int weekDay = (int)startTime.DayOfWeek;
+
+        // 周六、周日
+        if (weekDay == 0 || weekDay == 6)
+        {
+            // 检查是否有调班(周末上班)
+            var isShiftWork = holidays.Any(p =>
+                p.Dated.Date == startTime.Date &&
+                p.Ufld1.Equals("调班", StringComparison.OrdinalIgnoreCase));
+            if (!isShiftWork)
+            {
+                // 递归找下一个工作日
+                return GetNextWorkDay(startTime.AddDays(1), curCalendars, holidays);
+            }
+            return startTime;
+        }
+
+        // 工作日,检查是否是节假日(休假)
+        var isHoliday = holidays.Any(p =>
+            p.Dated.Date == startTime.Date &&
+            p.Ufld1.Equals("休假", StringComparison.OrdinalIgnoreCase));
+        if (isHoliday)
+        {
+            return GetNextWorkDay(startTime.AddDays(1), curCalendars, holidays);
+        }
+
+        return startTime;
+    }
+
+    /// <summary>计算当天的产能。</summary>
+    private static decimal CalcCapacity(
+        DateTime startTime,
+        ProdLineRow prodLine,
+        List<CalendarRow> curCalendars,
+        List<QualityRow> curQualityLines)
+    {
+        // 获取休息时长(分钟)
+        decimal sumRest = curQualityLines.Sum(p => p.RestTime);
+        // 获取当天的工作时长(分钟)
+        var calendar = curCalendars.FirstOrDefault(p => p.WeekDay == (int)startTime.DayOfWeek);
+        if (calendar == null) return 0;
+        decimal workTime = calendar.ShiftsHours1 * 60;
+        // 计算产能
+        if (workTime <= sumRest) return 0;
+        return Math.Ceiling((workTime - sumRest) / 60m * prodLine.Rate);
+    }
+}

+ 14 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Order/OrderResourceCheckService.cs

@@ -6,15 +6,18 @@ public class OrderResourceCheckService : ITransient
     private readonly ISqlSugarClient _db;
     private readonly MaterialRequirementCalculator _calculator;
     private readonly ResourceCheckResultWriter _writer;
+    private readonly CapacityDateCalculator _capacityCalc;
 
     public OrderResourceCheckService(
         ISqlSugarClient db,
         MaterialRequirementCalculator calculator,
-        ResourceCheckResultWriter writer)
+        ResourceCheckResultWriter writer,
+        CapacityDateCalculator capacityCalc)
     {
         _db = db;
         _calculator = calculator;
         _writer = writer;
+        _capacityCalc = capacityCalc;
     }
 
     public async Task<OrderResourceCheckResult> RunForEntryAsync(
@@ -41,6 +44,16 @@ public class OrderResourceCheckService : ITransient
         var activeLines = lines.Where(x => x.IsUse == 1).ToList();
         var needTime = entry.SysCapacityDate ?? entry.PlanDate ?? DateTime.Today;
         var hasShortage = activeLines.Any(x => x.LackQty > 0);
+
+        // 对根行(Level=1)执行产能计算,更新 KittingTime
+        var rootLine = lines.FirstOrDefault(x => x.Level == 1 && x.IsUse == 1 && x.LackQty > 0);
+        if (rootLine != null)
+        {
+            var planDate = entry.PlanDate ?? DateTime.Today;
+            rootLine.KittingTime = await _capacityCalc.CalculateAsync(
+                rootLine.ItemNumber, rootLine.LackQty, needTime, planDate, entry.TenantId);
+        }
+
         var kittingTime = activeLines
             .Where(x => x.LackQty > 0)
             .Select(x => x.KittingTime)

+ 13 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Order/ResourceCheckResultWriter.cs

@@ -6,10 +6,12 @@ namespace Admin.NET.Plugin.AiDOP.Order;
 public class ResourceCheckResultWriter : ITransient
 {
     private readonly ISqlSugarClient _db;
+    private readonly CapacityDateCalculator _capacityCalc;
 
-    public ResourceCheckResultWriter(ISqlSugarClient db)
+    public ResourceCheckResultWriter(ISqlSugarClient db, CapacityDateCalculator capacityCalc)
     {
         _db = db;
+        _capacityCalc = capacityCalc;
     }
 
     public async Task<OrderResourceCheckResult> WriteAsync(
@@ -26,6 +28,16 @@ public class ResourceCheckResultWriter : ITransient
         var examineId = YitIdHelper.NextId();
         var needQty = entry.Qty ?? 0;
         var needTime = entry.SysCapacityDate ?? entry.PlanDate ?? now;
+
+        // 对根行(Level=1)执行产能计算,更新 KittingTime
+        var rootLine = lines.FirstOrDefault(x => x.Level == 1 && x.IsUse == 1 && x.LackQty > 0);
+        if (rootLine != null)
+        {
+            var planDate = entry.PlanDate ?? now;
+            rootLine.KittingTime = await _capacityCalc.CalculateAsync(
+                rootLine.ItemNumber, rootLine.LackQty, needTime, planDate, entry.TenantId);
+        }
+
         var kittingTime = lines
             .Where(x => x.IsUse == 1 && x.LackQty > 0)
             .Select(x => x.KittingTime)

+ 7 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Order/SeOrderService.cs

@@ -139,11 +139,13 @@ public class SeOrderService : IDynamicApiController, ITransient
     [HttpGet("seorder/customers")]
     public async Task<object> GetCustomers([FromQuery] KeywordPageInput input)
     {
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
         var pars = new List<SugarParameter>();
-        var where = "1=1";
+        var where = "tenant_id = @TenantId";
+        pars.Add(new SugarParameter("@TenantId", tenantId));
         if (!string.IsNullOrWhiteSpace(input.Keyword))
         {
-            where = "(Cust LIKE @Keyword OR SortName LIKE @Keyword)";
+            where += " AND (Cust LIKE @Keyword OR SortName LIKE @Keyword)";
             pars.Add(new SugarParameter("@Keyword", $"%{input.Keyword.Trim()}%"));
         }
 
@@ -166,8 +168,10 @@ public class SeOrderService : IDynamicApiController, ITransient
     [HttpGet("seorder/items")]
     public async Task<object> GetItems([FromQuery] KeywordPageInput input)
     {
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
         var pars = new List<SugarParameter>();
-        var where = "IsDeleted = 0";
+        var where = "IsDeleted = 0 AND tenant_id = @TenantId";
+        pars.Add(new SugarParameter("@TenantId", tenantId));
         if (!string.IsNullOrWhiteSpace(input.Keyword))
         {
             where += " AND (item_number LIKE @Keyword OR item_name LIKE @Keyword)";

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/UniversalCustomerService.cs

@@ -24,7 +24,7 @@ public class UniversalCustomerService : IDynamicApiController, ITransient
     [HttpGet("customer/page")]
     public async Task<object> GetCustomerPage([FromQuery] UniversalCustomerPageInput input)
     {
-        var tenantId = _userManager.TenantId;
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
         var query = _custRep.AsQueryable()
             .Where(u => u.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(input.CustNo),   u => u.Cust!.Contains(input.CustNo!.Trim()))

+ 1 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Universal/UniversalItemService.cs

@@ -24,7 +24,7 @@ public class UniversalItemService : IDynamicApiController, ITransient
     [HttpGet("item/page")]
     public async Task<object> GetItemPage([FromQuery] UniversalItemPageInput input)
     {
-        var tenantId = _userManager.TenantId;
+        var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
         var query = _itemRep.AsQueryable()
             .Where(u => u.TenantId == tenantId)
             .WhereIF(!string.IsNullOrWhiteSpace(input.ItemNum), u => u.ItemNum!.Contains(input.ItemNum!.Trim()))