using System.Globalization; using Admin.NET.Plugin.AiDOP.Infrastructure; using Admin.NET.Plugin.AiDOP.Order; namespace Admin.NET.Plugin.AiDOP.Production; /// 工单排产动作:生成排程、优先级调整并重检。 [ApiDescriptionSettings(Order = 264, Description = "工单排产动作")] [Route("api/Production")] [AllowAnonymous] [NonUnify] public class ProductionSchedulingActionService : IDynamicApiController, ITransient { private readonly ISqlSugarClient _db; private readonly UserManager _userManager; private readonly OrderResourceCheckService _resourceCheck; private readonly ProductionScheduleGenerationService _scheduleGen; private readonly AidopActionRunLogWriter _runLog; public ProductionSchedulingActionService( ISqlSugarClient db, UserManager userManager, OrderResourceCheckService resourceCheck, ProductionScheduleGenerationService scheduleGen, AidopActionRunLogWriter runLog) { _db = db; _userManager = userManager; _resourceCheck = resourceCheck; _scheduleGen = scheduleGen; _runLog = runLog; } /// 生成生产排程计划(写入 PeriodSequenceDet)。 [DisplayName("生成生产排程")] [HttpPost("scheduling/generate")] public async Task GenerateSchedule([FromQuery] string domain) { var tenantId = ResolveTenantId(domain); var account = _userManager.Account ?? "system"; var logId = await _runLog.StartAsync("S2_SCHEDULE_GENERATE", tenantId, "WorkOrdMaster", null, $"domain={domain}"); try { var result = await _scheduleGen.GenerateAsync(tenantId, domain, account); await _runLog.SuccessAsync(logId, result.Message, new { tenantId, domain, result.WorkOrderCount, result.ScheduleRowCount, result.UsedWorkCenterCalendar, result.SkippedWorkOrders }); return new { message = result.Message, workOrderCount = result.WorkOrderCount, scheduleRowCount = result.ScheduleRowCount, usedWorkCenterCalendar = result.UsedWorkCenterCalendar, skippedWorkOrders = result.SkippedWorkOrders }; } catch (Exception ex) { await _runLog.FailedAsync(logId, ex.Message, new { tenantId, domain }); throw; } } /// 优先级/数量/交期调整并重做资源检查。 [DisplayName("优先级调整并重检")] [HttpPost("scheduling/update-priority-and-recheck")] public async Task UpdatePriorityAndRecheck([FromBody] WorkOrderPriorityRecheckInput input) { var tenantId = ResolveTenantId(input.Domain); var account = string.IsNullOrWhiteSpace(input.UserAccount) ? (_userManager.Account ?? "system") : input.UserAccount.Trim(); var workOrd = input.Workord.Trim(); var logId = await _runLog.StartAsync("S2_PRIORITY_RECHECK", tenantId, "WorkOrdMaster", null, workOrd); try { var before = await LoadWorkOrderSnapshotAsync(tenantId, workOrd); DateTime? dueDate = null; if (!string.IsNullOrWhiteSpace(input.Instockdate)) dueDate = DateTime.Parse(input.Instockdate.Trim(), CultureInfo.InvariantCulture).Date; decimal? qty = null; if (!string.IsNullOrWhiteSpace(input.Qty) && decimal.TryParse(input.Qty.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var q)) qty = q; var pars = new List { new("@WorkOrd", workOrd), new("@TenantId", tenantId), new("@Priority", string.IsNullOrWhiteSpace(input.Priority) ? (object)DBNull.Value : input.Priority.Trim()), new("@LotSerial", string.IsNullOrWhiteSpace(input.LotSerial) ? (object)DBNull.Value : input.LotSerial.Trim()), new("@Qty", qty ?? (object)DBNull.Value), new("@DueDate", dueDate ?? (object)DBNull.Value), new("@User", account), new("@Now", DateTime.Now) }; var affected = await _db.Ado.ExecuteCommandAsync( """ UPDATE WorkOrdMaster SET Priority = COALESCE(@Priority, Priority), LotSerial = COALESCE(@LotSerial, LotSerial), QtyOrded = COALESCE(@Qty, QtyOrded), DueDate = COALESCE(@DueDate, DueDate), UpdateUser = @User, UpdateTime = @Now WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd """, pars); if (affected == 0) throw Oops.Oh("工单不存在或未更新"); var link = await LoadWorkOrderEntryLinkAsync(tenantId, workOrd); var warnings = new List(); var resourceRechecked = false; if (link is not null && (qty.HasValue || dueDate.HasValue)) { if (qty.HasValue) link.Entry.Qty = qty; if (dueDate.HasValue) link.Entry.SysCapacityDate = dueDate; await _resourceCheck.RunForEntryAsync(link.Order, link.Entry, workOrd, account, warnings); resourceRechecked = true; } var qtyChanged = qty.HasValue && before?.QtyOrded != qty; var dueChanged = dueDate.HasValue && before?.DueDate?.Date != dueDate.Value.Date; var priorityChanged = !string.IsNullOrWhiteSpace(input.Priority) && !string.Equals(before?.Priority?.Trim(), input.Priority.Trim(), StringComparison.Ordinal); ProductionScheduleGenerationService.ScheduleGenerationResult? reschedule = null; if (qtyChanged || dueChanged) { reschedule = await _scheduleGen.RegenerateForWorkOrderAsync(tenantId, workOrd, input.Domain, account); warnings.Add(reschedule.Message); } else if (priorityChanged) { var woDomain = before?.Domain ?? input.Domain; await _scheduleGen.DeactivateExistingScheduleAsync(tenantId, workOrd, woDomain); warnings.Add("优先级已变更,原排程已失效,请重新执行批量排程"); } await _runLog.SuccessAsync(logId, "优先级调整并重检完成", new { workOrd, before, after = new { qty, dueDate, priority = input.Priority, lotSerial = input.LotSerial }, qtyChanged, dueChanged, priorityChanged, resourceRechecked, reschedule = reschedule is null ? null : new { reschedule.WorkOrderCount, reschedule.ScheduleRowCount, reschedule.UsedWorkCenterCalendar }, warnings }); return new { message = "ok", warnings, resourceRechecked, rescheduleTriggered = reschedule is not null, scheduleRowCount = reschedule?.ScheduleRowCount ?? 0 }; } catch (Exception ex) { await _runLog.FailedAsync(logId, ex.Message, new { workOrd, tenantId }); throw; } } private long ResolveTenantId(string? domain) { if (!string.IsNullOrWhiteSpace(domain) && long.TryParse(domain.Trim(), out var tid) && tid > 0) return tid; return AidopTenantHelper.Resolve(App.HttpContext); } private async Task LoadWorkOrderSnapshotAsync(long tenantId, string workOrd) { var rows = await _db.Ado.SqlQueryAsync( """ SELECT WorkOrd, Priority, QtyOrded, DueDate, LotSerial, `Domain` FROM WorkOrdMaster WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd LIMIT 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); return rows.FirstOrDefault(); } private async Task LoadWorkOrderEntryLinkAsync(long tenantId, string workOrd) { var rows = await _db.Ado.SqlQueryAsync( """ SELECT w.BusinessID AS EntryId, e.seorder_id AS SeOrderId, e.bill_no AS BillNo, e.entry_seq AS EntrySeq, e.item_number AS ItemNumber, e.item_name AS ItemName, e.specification AS Specification, e.unit AS Unit, e.bom_number AS BomNumber, e.qty AS Qty, e.plan_date AS PlanDate, e.sys_capacity_date AS SysCapacityDate, e.progress AS Progress, e.urgent AS Urgent, e.factory_id AS FactoryId, e.company_id AS CompanyId, e.tenant_id AS TenantId, o.Id AS OrderId, o.bill_no AS OrderBillNo, o.custom_no AS CustomNo, o.urgent AS OrderUrgent, o.factory_id AS OrderFactoryId FROM WorkOrdMaster w INNER JOIN crm_seorderentry e ON e.Id = w.BusinessID AND e.tenant_id = w.tenant_id AND e.IsDeleted = 0 INNER JOIN crm_seorder o ON o.Id = e.seorder_id AND o.tenant_id = e.tenant_id AND o.IsDeleted = 0 WHERE w.tenant_id = @TenantId AND w.WorkOrd = @WorkOrd LIMIT 1 """, new SugarParameter("@TenantId", tenantId), new SugarParameter("@WorkOrd", workOrd)); var row = rows.FirstOrDefault(); if (row is null || row.EntryId <= 0) return null; return new WorkOrderEntryLink { Order = new OrderWorkOrderGenerationService.OrderHeader { Id = row.OrderId, BillNo = row.OrderBillNo ?? row.BillNo, CustomNo = row.CustomNo, Urgent = row.OrderUrgent, FactoryId = row.OrderFactoryId, TenantId = tenantId }, Entry = new OrderWorkOrderGenerationService.OrderEntryLine { Id = row.EntryId, SeOrderId = row.SeOrderId, BillNo = row.BillNo, EntrySeq = row.EntrySeq, ItemNumber = row.ItemNumber, ItemName = row.ItemName, Specification = row.Specification, Unit = row.Unit, BomNumber = row.BomNumber, Qty = row.Qty, PlanDate = row.PlanDate, SysCapacityDate = row.SysCapacityDate, Progress = row.Progress, Urgent = row.Urgent, FactoryId = row.FactoryId, CompanyId = row.CompanyId, TenantId = row.TenantId } }; } private sealed class WorkOrderSnapshotRow { public string? WorkOrd { get; set; } public string? Priority { get; set; } public decimal? QtyOrded { get; set; } public DateTime? DueDate { get; set; } public string? LotSerial { get; set; } public string? Domain { get; set; } } private sealed class WorkOrderEntryLink { public OrderWorkOrderGenerationService.OrderHeader Order { get; set; } = new(); public OrderWorkOrderGenerationService.OrderEntryLine Entry { get; set; } = new(); } private sealed class WorkOrderEntryLinkRow { public long EntryId { get; set; } public long SeOrderId { get; set; } public string? BillNo { get; set; } public int? EntrySeq { get; set; } public string? ItemNumber { get; set; } public string? ItemName { get; set; } public string? Specification { get; set; } public string? Unit { get; set; } public string? BomNumber { get; set; } public decimal? Qty { get; set; } public DateTime? PlanDate { get; set; } public DateTime? SysCapacityDate { get; set; } public string? Progress { get; set; } public int? Urgent { get; set; } public long? FactoryId { get; set; } public long? CompanyId { get; set; } public long TenantId { get; set; } public long OrderId { get; set; } public string? OrderBillNo { get; set; } public string? CustomNo { get; set; } public int? OrderUrgent { get; set; } public long? OrderFactoryId { get; set; } } } public class WorkOrderPriorityRecheckInput { public string Workord { get; set; } = string.Empty; public string? Qty { get; set; } public string? Instockdate { get; set; } public string? Priority { get; set; } public string Domain { get; set; } = string.Empty; public string? UserAccount { get; set; } public string? LotSerial { get; set; } }