namespace Admin.NET.Plugin.AiDOP.Production; using Admin.NET.Plugin.AiDOP.WorkOrder; /// 生产排程生成:为待排工单写入 PeriodSequenceDet(工作日历 + 工作中心冲突避让)。 public class ProductionScheduleGenerationService : ITransient { private readonly ISqlSugarClient _db; private readonly WorkOrderKittingCheckService _kittingCheck; public ProductionScheduleGenerationService(ISqlSugarClient db, WorkOrderKittingCheckService kittingCheck) { _db = db; _kittingCheck = kittingCheck; } public async Task GenerateAsync(long tenantId, string? domain, string account) { var workOrders = await LoadPendingWorkOrdersAsync(tenantId); if (workOrders.Count == 0) return new ScheduleGenerationResult { Message = "没有待排产的工单(状态 p/r)" }; return await ScheduleWorkOrdersAsync(tenantId, domain, account, workOrders); } public async Task RegenerateForWorkOrderAsync( long tenantId, string workOrd, string? domain, string account) { var rows = await _db.Ado.SqlQueryAsync( """ SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent FROM WorkOrdMaster WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd.Trim())); var wo = rows.FirstOrDefault(); if (wo is null) return new ScheduleGenerationResult { Message = $"工单 {workOrd} 不存在" }; return await ScheduleWorkOrdersAsync(tenantId, domain, account, new List { wo }); } private async Task ScheduleWorkOrdersAsync( long tenantId, string? domain, string account, List workOrders) { var now = DateTime.Now; var calendar = await LoadWorkCenterCalendarAsync(tenantId); var occupiedSlots = await LoadOccupiedSlotsAsync(tenantId); var usedCalendar = calendar.Count > 0; var scheduledCount = 0; var rowCount = 0; var skipped = new List(); // 按优先级顺序跟踪各物料已占用库存量(ItemNumber → 已占用数量) var consumedStock = new Dictionary(StringComparer.OrdinalIgnoreCase); // 清理旧的占用记录,确保每次排程重新计算 const long scheduleBangId = 1; await _db.Ado.ExecuteCommandAsync( "DELETE FROM ic_item_stockoccupy WHERE tenant_id = @TenantId AND bang_id = @BangId", new SugarParameter("@TenantId", tenantId), new SugarParameter("@BangId", scheduleBangId)); await _db.Ado.ExecuteCommandAsync( "DELETE FROM srm_po_occupy WHERE tenant_id = @TenantId AND bang_id = @BangId", new SugarParameter("@TenantId", tenantId), new SugarParameter("@BangId", scheduleBangId)); foreach (var wo in workOrders) { // 1. 执行齐套检查,刷新资源检查记录(写入 b_examine_result / b_bom_child_examine) try { await _kittingCheck.CheckSingleAsync(tenantId, wo.WorkOrd, account, scheduleBangId); } catch (Exception ex) { skipped.Add($"齐套检查失败[{wo.WorkOrd}]: {ex.Message}"); } // 2. 刷新齐套数量(LocationStock),扣减已被高优先级工单占用的库存 var locationStock = await CalcLocationStockAsync(tenantId, wo.WorkOrd, wo.QtyOrded ?? 0, consumedStock); if (locationStock.HasValue) { await _db.Ado.ExecuteCommandAsync( """ UPDATE WorkOrdMaster SET LocationStock = @Stock, UpdateTime = @Now WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd """, new SugarParameter("@Stock", locationStock.Value), new SugarParameter("@Now", now), new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", wo.WorkOrd)); } var routings = await LoadRoutingsAsync(tenantId, wo.WorkOrd); if (routings.Count == 0) { skipped.Add($"{wo.WorkOrd}(无工艺路线)"); continue; } var woDomain = ResolveDomain(wo.Domain, domain, tenantId); await DeactivateExistingScheduleAsync(tenantId, wo.WorkOrd, woDomain); var planStart = (wo.OrdDate ?? now).Date; var planEnd = (wo.DueDate ?? planStart.AddDays(Math.Max(routings.Count, 7))).Date; if (planEnd < planStart) planEnd = planStart; var cursor = planStart; var seq = 1; for (var i = 0; i < routings.Count; i++) { var routing = routings[i]; DateTime planDate; if (usedCalendar) { planDate = ResolveNextAvailablePlanDate(cursor, routing.WorkCtr, calendar, occupiedSlots); cursor = planDate.AddDays(1); } else { var spanDays = Math.Max((planEnd - planStart).Days, 0); planDate = routings.Count <= 1 ? planStart : planStart.AddDays(spanDays * i / Math.Max(routings.Count - 1, 1)); } if (planDate > planEnd) planEnd = planDate; var slotKey = BuildOccupancyKey(routing.WorkCtr, planDate); occupiedSlots.Add(slotKey); var recId = await NextPeriodRecIdAsync(); await InsertScheduleRowAsync( recId, woDomain, routing, wo, planDate, seq, account, now, tenantId); seq++; rowCount++; } scheduledCount++; } return new ScheduleGenerationResult { WorkOrderCount = scheduledCount, ScheduleRowCount = rowCount, SkippedWorkOrders = skipped, UsedWorkCenterCalendar = usedCalendar, Message = scheduledCount > 0 ? $"已为 {scheduledCount} 个工单生成 {rowCount} 条工序排程" : "未生成排程,请确认工单已同步工艺路线" }; } private async Task InsertScheduleRowAsync( int recId, string woDomain, RoutingRow routing, PendingWorkOrderRow wo, DateTime planDate, int seq, string account, DateTime now, long tenantId) { await _db.Ado.ExecuteCommandAsync( """ INSERT INTO PeriodSequenceDet ( RecID, `Domain`, Site, ItemNum, Line, Op, WorkCtr, ProdDate, PlanDate, Sequence, OrdQty, CompQty, WorkOrds, Status, Employee, CreateUser, CreateTime, UpdateUser, UpdateTime, IsActive, IsConfirm, BusinessID, tenant_id ) VALUES ( @RecId, @Domain, @Site, @ItemNum, @Line, @Op, @WorkCtr, @ProdDate, @PlanDate, @Sequence, @OrdQty, 0, @WorkOrd, '', '', @User, @Now, @User, @Now, 1, 0, @BusinessId, @TenantId ) """, new SugarParameter("@RecId", recId), new SugarParameter("@Domain", woDomain.Length > 8 ? woDomain[..8] : woDomain), new SugarParameter("@Site", routing.Site ?? (object)DBNull.Value), new SugarParameter("@ItemNum", wo.ItemNum ?? string.Empty), new SugarParameter("@Line", routing.ProdLine ?? (object)DBNull.Value), new SugarParameter("@Op", routing.Op), new SugarParameter("@WorkCtr", routing.WorkCtr ?? (object)DBNull.Value), new SugarParameter("@ProdDate", planDate), new SugarParameter("@PlanDate", planDate), new SugarParameter("@Sequence", seq), new SugarParameter("@OrdQty", wo.QtyOrded ?? 0), new SugarParameter("@WorkOrd", wo.WorkOrd), new SugarParameter("@BusinessId", wo.RecId), new SugarParameter("@User", account.Length > 24 ? account[..24] : account), new SugarParameter("@Now", now), new SugarParameter("@TenantId", tenantId)); } private static DateTime ResolveNextAvailablePlanDate( DateTime start, string? workCtr, Dictionary> calendar, HashSet occupiedSlots) { var date = start.Date; for (var guard = 0; guard < 366; guard++) { if (IsWorkDay(date, workCtr, calendar)) { var key = BuildOccupancyKey(workCtr, date); if (!occupiedSlots.Contains(key)) return date; } date = date.AddDays(1); } return start.Date; } private static bool IsWorkDay(DateTime date, string? workCtr, Dictionary> calendar) { if (calendar.Count == 0) return true; var key = NormalizeWorkCtr(workCtr); if (!calendar.TryGetValue(key, out var days) || days.Count == 0) return date.DayOfWeek is not DayOfWeek.Saturday and not DayOfWeek.Sunday; var weekDay = (int)date.DayOfWeek; return days.Contains(weekDay); } private static string BuildOccupancyKey(string? workCtr, DateTime date) => $"{NormalizeWorkCtr(workCtr)}|{date:yyyy-MM-dd}"; private static string NormalizeWorkCtr(string? workCtr) => string.IsNullOrWhiteSpace(workCtr) ? "*" : workCtr.Trim(); private async Task>> LoadWorkCenterCalendarAsync(long tenantId) { var rows = await _db.Ado.SqlQueryAsync( """ SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, WeekDay FROM ShopCalendarWorkCtr WHERE tenant_id = @TenantId AND IFNULL(IsActive, 0) = 1 AND IFNULL(IsWorkDay, 0) = 1 AND WeekDay IS NOT NULL """, new SugarParameter("@TenantId", tenantId)); var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var row in rows) { if (row.WeekDay is null) continue; var key = NormalizeWorkCtr(row.WorkCtr); if (!map.TryGetValue(key, out var set)) { set = new HashSet(); map[key] = set; } set.Add(row.WeekDay.Value); } return map; } private async Task> LoadOccupiedSlotsAsync(long tenantId) { var rows = await _db.Ado.SqlQueryAsync( """ SELECT TRIM(IFNULL(WorkCtr,'')) AS WorkCtr, DATE(PlanDate) AS PlanDate FROM PeriodSequenceDet WHERE tenant_id = @TenantId AND IFNULL(IsActive, 0) = 1 AND PlanDate IS NOT NULL """, new SugarParameter("@TenantId", tenantId)); return rows .Where(x => x.PlanDate.HasValue) .Select(x => BuildOccupancyKey(x.WorkCtr, x.PlanDate!.Value)) .ToHashSet(StringComparer.OrdinalIgnoreCase); } private async Task> LoadPendingWorkOrdersAsync(long tenantId) { return await _db.Ado.SqlQueryAsync( """ SELECT RecID AS RecId, WorkOrd, ItemNum, `Domain`, QtyOrded, OrdDate, DueDate, Priority, Urgent FROM WorkOrdMaster WHERE tenant_id = @TenantId AND LOWER(TRIM(IFNULL(Status,''))) IN ('p', 'r') ORDER BY IFNULL(Urgent, 0) DESC, IFNULL(Priority, 0) DESC, DueDate, WorkOrd """, new SugarParameter("@TenantId", tenantId)); } private async Task> LoadRoutingsAsync(long tenantId, string workOrd) { return await _db.Ado.SqlQueryAsync( """ SELECT OP AS Op, ProdLine, WorkCtr, WorkCtr AS Site FROM WorkOrdRouting WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1 ORDER BY (OP + 0), OP """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); } public async Task DeactivateExistingScheduleAsync(long tenantId, string workOrd, string domain) { await _db.Ado.ExecuteCommandAsync( """ UPDATE PeriodSequenceDet SET IsActive = 0, UpdateTime = @Now WHERE tenant_id = @TenantId AND WorkOrds = @WorkOrd AND `Domain` = @Domain AND IFNULL(IsActive, 0) = 1 """, new SugarParameter("@Now", DateTime.Now), new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd), new SugarParameter("@Domain", domain.Length > 8 ? domain[..8] : domain)); } private static string ResolveDomain(string? woDomain, string? requestDomain, long tenantId) { if (!string.IsNullOrWhiteSpace(woDomain)) return woDomain.Trim(); if (!string.IsNullOrWhiteSpace(requestDomain)) return requestDomain.Trim(); return tenantId.ToString(); } private async Task NextPeriodRecIdAsync() { var max = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM PeriodSequenceDet"); return max + 1; } public sealed class ScheduleGenerationResult { public int WorkOrderCount { get; set; } public int ScheduleRowCount { get; set; } public bool UsedWorkCenterCalendar { get; set; } public List SkippedWorkOrders { get; set; } = new(); public string Message { get; set; } = string.Empty; } /// /// 计算工单齐套数量:MIN(可用库存 / qty) 得可生产成品数,超过工单需求量则取工单需求量。 /// 若工单领料单已全部发完料,则齐套数量 = 工单需求量。 /// consumedStock 跟踪已被高优先级工单占用的库存量,避免重复占用。 /// private async Task CalcLocationStockAsync( long tenantId, string workOrd, decimal qtyOrded, Dictionary consumedStock) { // 检查领料单是否已全部发完料 var allIssued = await _db.Ado.GetIntAsync( """ SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END FROM NbrMaster nm WHERE nm.tenant_id = @TenantId AND nm.WorkOrd = @WorkOrd AND nm.Type = 'SM' AND IFNULL(nm.IsActive, 0) = 1 AND NOT EXISTS ( SELECT 1 FROM NbrDetail nd WHERE nd.tenant_id = nm.tenant_id AND nd.Nbr = nm.Nbr AND nd.Type = 'SM' AND IFNULL(nd.IsActive, 0) = 1 AND IFNULL(nd.QtyRec, 0) < IFNULL(nd.QtyOrd, 0) ) """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); if (allIssued > 0 && qtyOrded > 0) return qtyOrded; // 从最新资源检查获取各物料的 use_qty 和 qty var materials = await _db.Ado.SqlQueryAsync( """ SELECT bce.item_number AS ItemNumber, IFNULL(bce.use_qty, 0) AS UseQty, IFNULL(bce.qty, 0) AS Qty FROM b_examine_result ber INNER JOIN b_bom_child_examine bce ON ber.Id = bce.examine_id AND bce.is_use = 1 WHERE ber.tenant_id = @TenantId AND ber.IsDeleted = 0 AND ber.morder_no = @WorkOrd AND ber.Id = ( SELECT br.Id FROM b_examine_result br WHERE br.tenant_id = @TenantId AND br.morder_no = @WorkOrd AND br.IsDeleted = 0 ORDER BY br.create_time DESC LIMIT 1 ) AND IFNULL(bce.qty, 0) > 0 AND IFNULL(bce.erp_cls, 3) = 3 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); if (materials.Count == 0) return null; // MIN(可用库存 / qty) = 扣减已占用后的瓶颈物料可生产成品数 decimal? minProducible = null; foreach (var mat in materials) { if (mat.Qty <= 0) continue; var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m; var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed); var producible = availableUseQty / mat.Qty; if (minProducible is null || producible < minProducible) minProducible = producible; } if (minProducible is null) return null; // 可生产数 > 工单需求量则取工单需求量 var result = Math.Ceiling(minProducible.Value); if (qtyOrded > 0 && result > qtyOrded) result = qtyOrded; // 记录本工单占用的库存量 foreach (var mat in materials) { if (mat.Qty <= 0) continue; var alreadyConsumed = consumedStock.TryGetValue(mat.ItemNumber, out var c) ? c : 0m; var availableUseQty = Math.Max(0m, mat.UseQty - alreadyConsumed); var consume = Math.Min(availableUseQty, result * mat.Qty); if (!consumedStock.ContainsKey(mat.ItemNumber)) consumedStock[mat.ItemNumber] = 0m; consumedStock[mat.ItemNumber] += consume; } return result; } private sealed class LocationStockMaterialRow { public string ItemNumber { get; set; } = string.Empty; public decimal UseQty { get; set; } public decimal Qty { get; set; } } private sealed class PendingWorkOrderRow { public long RecId { get; set; } public string WorkOrd { get; set; } = string.Empty; public string? ItemNum { get; set; } public string? Domain { get; set; } public decimal? QtyOrded { get; set; } public DateTime? OrdDate { get; set; } public DateTime? DueDate { get; set; } public decimal? Priority { get; set; } public int? Urgent { get; set; } } private sealed class RoutingRow { public int Op { get; set; } public string? ProdLine { get; set; } public string? WorkCtr { get; set; } public string? Site { get; set; } } private sealed class CalendarRow { public string? WorkCtr { get; set; } public int? WeekDay { get; set; } } private sealed class OccupiedSlotRow { public string? WorkCtr { get; set; } public DateTime? PlanDate { get; set; } } }