namespace Admin.NET.Plugin.AiDOP.Production; /// 生产排程生成:为待排工单写入 PeriodSequenceDet(工作日历 + 工作中心冲突避让)。 public class ProductionScheduleGenerationService : ITransient { private readonly ISqlSugarClient _db; public ProductionScheduleGenerationService(ISqlSugarClient db) { _db = db; } 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(); foreach (var wo in workOrders) { 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; } 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; } } }