ProductionSchedulingActionService.cs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828
  1. using System.Globalization;
  2. using System.Text.Json;
  3. using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Admin.NET.Plugin.AiDOP.Order;
  6. using Admin.NET.Plugin.AiDOP.WorkOrder;
  7. using Microsoft.Extensions.DependencyInjection;
  8. using Microsoft.Extensions.Logging;
  9. namespace Admin.NET.Plugin.AiDOP.Production;
  10. /// <summary>工单排产动作:生成排程、优先级调整并重检。</summary>
  11. [ApiDescriptionSettings(Order = 264, Description = "工单排产动作")]
  12. [Route("api/Production")]
  13. [AllowAnonymous]
  14. [NonUnify]
  15. public class ProductionSchedulingActionService : IDynamicApiController, ITransient
  16. {
  17. private readonly ISqlSugarClient _db;
  18. private readonly UserManager _userManager;
  19. private readonly ProductionScheduleGenerationService _scheduleGen;
  20. private readonly WorkOrderRoutingSyncService _routingSync;
  21. private readonly AidopActionRunLogWriter _runLog;
  22. private readonly MdpRefreshDispatcher _dispatcher;
  23. private readonly PickBillNbrSyncService _pickNbrSync;
  24. private readonly ILogger<ProductionSchedulingActionService> _logger;
  25. /// <summary>RUNNING 超过此时长视为僵死并自动关闭(防单飞永久堵死)。</summary>
  26. private static readonly TimeSpan StaleRunningAge = TimeSpan.FromHours(3);
  27. public ProductionSchedulingActionService(
  28. ISqlSugarClient db,
  29. UserManager userManager,
  30. ProductionScheduleGenerationService scheduleGen,
  31. WorkOrderRoutingSyncService routingSync,
  32. AidopActionRunLogWriter runLog,
  33. MdpRefreshDispatcher dispatcher,
  34. PickBillNbrSyncService pickNbrSync,
  35. ILogger<ProductionSchedulingActionService> logger)
  36. {
  37. _db = db;
  38. _userManager = userManager;
  39. _scheduleGen = scheduleGen;
  40. _routingSync = routingSync;
  41. _runLog = runLog;
  42. _dispatcher = dispatcher;
  43. _pickNbrSync = pickNbrSync;
  44. _logger = logger;
  45. }
  46. public const string ActionScheduleGenerate = "S2_SCHEDULE_GENERATE";
  47. /// <summary>
  48. /// 提交全量生产排程(异步)。立即返回 runId,后台执行;前端轮询 status。
  49. /// </summary>
  50. [DisplayName("生成生产排程")]
  51. [HttpPost("scheduling/generate")]
  52. public async Task<object> GenerateSchedule([FromQuery] string domain, [FromQuery] bool enableCapacityConstraint = false)
  53. {
  54. var tenantId = ResolveTenantId(domain);
  55. var account = _userManager.Account ?? "system";
  56. return await SubmitGenerateAsync(tenantId, domain, account, enableCapacityConstraint, trigger: "manual");
  57. }
  58. /// <summary>查询排程运行状态(供前端轮询)。</summary>
  59. [DisplayName("生产排程运行状态")]
  60. [HttpGet("scheduling/generate-status")]
  61. public async Task<object> GetGenerateStatus([FromQuery] long runId)
  62. {
  63. var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
  64. var row = (await _db.Ado.SqlQueryAsync<ScheduleRunStatusRow>(
  65. """
  66. SELECT id AS Id, status AS Status, message AS Message, detail_json AS DetailJson,
  67. start_time AS StartTime, end_time AS EndTime
  68. FROM aidop_action_run_log
  69. WHERE id = @Id AND tenant_id = @TenantId AND action_code = @ActionCode
  70. LIMIT 1
  71. """,
  72. new SugarParameter("@Id", runId),
  73. new SugarParameter("@TenantId", tenantId),
  74. new SugarParameter("@ActionCode", ActionScheduleGenerate))).FirstOrDefault()
  75. ?? throw Oops.Oh("排程运行记录不存在");
  76. object? detail = null;
  77. if (!string.IsNullOrWhiteSpace(row.DetailJson))
  78. {
  79. try { detail = JsonSerializer.Deserialize<JsonElement>(row.DetailJson); }
  80. catch { detail = row.DetailJson; }
  81. }
  82. return new
  83. {
  84. runId = row.Id,
  85. status = row.Status,
  86. message = row.Message,
  87. detail,
  88. startTime = row.StartTime,
  89. endTime = row.EndTime
  90. };
  91. }
  92. /// <summary>
  93. /// 供 HTTP 与定时作业共用:单飞校验 → 写 RUNNING → 后台派发。
  94. /// </summary>
  95. public async Task<object> SubmitGenerateAsync(
  96. long tenantId,
  97. string domain,
  98. string account,
  99. bool enableCapacityConstraint,
  100. string trigger)
  101. {
  102. await _runLog.FailStaleRunningAsync(ActionScheduleGenerate, tenantId, StaleRunningAge);
  103. if (await _runLog.HasRunningAsync(ActionScheduleGenerate, tenantId))
  104. throw Oops.Oh("生产排程进行中,请稍后再试(同租户同时只允许一个排程运行)");
  105. var logId = await _runLog.StartAsync(
  106. ActionScheduleGenerate, tenantId, "WorkOrdMaster", null, $"domain={domain};trigger={trigger}");
  107. await _runLog.UpdateProgressAsync(logId, "已提交,后台执行中…", new
  108. {
  109. tenantId,
  110. domain,
  111. runId = logId,
  112. trigger,
  113. phase = "queued"
  114. });
  115. // 按租户保留各自委托与运行日志;底层生成服务使用全局门闩串行分配旧表主键号段。
  116. var key = $"S2_SCHEDULE:{tenantId}";
  117. _dispatcher.Enqueue(key, async (sp, _) =>
  118. {
  119. var runLog = sp.GetRequiredService<AidopActionRunLogWriter>();
  120. var gen = sp.GetRequiredService<ProductionScheduleGenerationService>();
  121. var routingSync = sp.GetRequiredService<WorkOrderRoutingSyncService>();
  122. var log = sp.GetRequiredService<ILogger<ProductionSchedulingActionService>>();
  123. try
  124. {
  125. await runLog.UpdateProgressAsync(logId, "排程计算中…", new
  126. {
  127. tenantId,
  128. domain,
  129. runId = logId,
  130. trigger,
  131. phase = "running"
  132. });
  133. await routingSync.EnsureAllPendingAsync(tenantId, account);
  134. var result = await gen.GenerateAsync(tenantId, domain, account, enableCapacityConstraint, runId: logId);
  135. await runLog.SuccessAsync(logId, result.Message, new
  136. {
  137. tenantId,
  138. domain,
  139. runId = logId,
  140. trigger,
  141. phase = "done",
  142. workOrderCount = result.WorkOrderCount,
  143. scheduleRowCount = result.ScheduleRowCount,
  144. pushEnqueuedCount = result.PushEnqueuedCount,
  145. frozenWorkOrderCount = result.FrozenWorkOrderCount,
  146. frozenOpCount = result.FrozenOpCount,
  147. usedWorkCenterCalendar = result.UsedWorkCenterCalendar,
  148. skippedWorkOrders = result.SkippedWorkOrders,
  149. psdMismatchCount = result.PsdMismatchCount,
  150. psdDuplicateActiveCount = result.PsdDuplicateActiveCount,
  151. outboxDeadCount = result.OutboxDeadCount,
  152. mesLineMissingCount = result.MesLineMissingCount,
  153. mesRoutingMissingCount = result.MesRoutingMissingCount
  154. });
  155. }
  156. catch (Exception ex)
  157. {
  158. log.LogError(ex, "S2 异步排程失败 tenant={TenantId} runId={RunId}", tenantId, logId);
  159. await runLog.FailedAsync(logId, ex.Message, new { tenantId, domain, runId = logId, trigger });
  160. }
  161. });
  162. _logger.LogInformation("S2 排程已提交异步执行 tenant={TenantId} runId={RunId} trigger={Trigger}", tenantId, logId, trigger);
  163. return new
  164. {
  165. message = "生产排程已提交,后台执行中",
  166. runId = logId,
  167. status = "RUNNING",
  168. async = true
  169. };
  170. }
  171. private sealed class ScheduleRunStatusRow
  172. {
  173. public long Id { get; set; }
  174. public string? Status { get; set; }
  175. public string? Message { get; set; }
  176. public string? DetailJson { get; set; }
  177. public DateTime? StartTime { get; set; }
  178. public DateTime? EndTime { get; set; }
  179. }
  180. /// <summary>优先级/数量/交期调整并重做资源检查。</summary>
  181. [DisplayName("优先级调整并重检")]
  182. [HttpPost("scheduling/update-priority-and-recheck")]
  183. public async Task<object> UpdatePriorityAndRecheck([FromBody] WorkOrderPriorityRecheckInput input)
  184. {
  185. var tenantId = AidopTenantHelper.Resolve(App.HttpContext);
  186. var account = string.IsNullOrWhiteSpace(input.UserAccount)
  187. ? (_userManager.Account ?? "system")
  188. : input.UserAccount.Trim();
  189. var workOrd = input.Workord.Trim();
  190. var logId = await _runLog.StartAsync("S2_PRIORITY_RECHECK", tenantId, "WorkOrdMaster", null, workOrd);
  191. try
  192. {
  193. var before = await LoadWorkOrderSnapshotAsync(tenantId, workOrd);
  194. DateTime? dueDate = null;
  195. if (!string.IsNullOrWhiteSpace(input.Instockdate))
  196. dueDate = DateTime.Parse(input.Instockdate.Trim(), CultureInfo.InvariantCulture).Date;
  197. decimal? qty = null;
  198. if (!string.IsNullOrWhiteSpace(input.Qty) && decimal.TryParse(input.Qty.Trim(), NumberStyles.Any, CultureInfo.InvariantCulture, out var q))
  199. qty = q;
  200. var pars = new List<SugarParameter>
  201. {
  202. new("@WorkOrd", workOrd),
  203. new("@TenantId", tenantId),
  204. new("@Priority", string.IsNullOrWhiteSpace(input.Priority) ? (object)DBNull.Value : input.Priority.Trim()),
  205. new("@LotSerial", string.IsNullOrWhiteSpace(input.LotSerial) ? (object)DBNull.Value : input.LotSerial.Trim()),
  206. new("@Qty", qty ?? (object)DBNull.Value),
  207. new("@DueDate", dueDate ?? (object)DBNull.Value),
  208. new("@User", account),
  209. new("@Now", DateTime.Now)
  210. };
  211. var affected = await _db.Ado.ExecuteCommandAsync(
  212. """
  213. UPDATE WorkOrdMaster
  214. SET Priority = COALESCE(@Priority, Priority),
  215. LotSerial = COALESCE(@LotSerial, LotSerial),
  216. QtyOrded = COALESCE(@Qty, QtyOrded),
  217. DueDate = COALESCE(@DueDate, DueDate),
  218. UpdateUser = @User,
  219. UpdateTime = @Now
  220. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  221. """,
  222. pars);
  223. if (affected == 0)
  224. throw Oops.Oh("工单不存在或未更新");
  225. var qtyChanged = qty.HasValue && before?.QtyOrded != qty;
  226. var dueChanged = dueDate.HasValue && before?.DueDate?.Date != dueDate.Value.Date;
  227. var priorityChanged = !string.IsNullOrWhiteSpace(input.Priority)
  228. && !string.Equals(before?.Priority?.Trim(), input.Priority.Trim(), StringComparison.Ordinal);
  229. var warnings = new List<string>();
  230. // ── 同步 crm_seorderentry,避免后续齐套检查重新加载时读到旧值 ──
  231. if (qtyChanged || dueChanged)
  232. {
  233. var link = await LoadWorkOrderEntryLinkAsync(tenantId, workOrd);
  234. if (link is not null)
  235. {
  236. await _db.Ado.ExecuteCommandAsync(
  237. """
  238. UPDATE crm_seorderentry
  239. SET qty = COALESCE(@Qty, qty),
  240. sys_capacity_date = COALESCE(@SysCapacityDate, sys_capacity_date),
  241. update_time = NOW()
  242. WHERE Id = @EntryId AND tenant_id = @TenantId AND IsDeleted = 0
  243. """,
  244. new SugarParameter("@Qty", qty ?? (object)DBNull.Value),
  245. new SugarParameter("@SysCapacityDate", dueDate ?? (object)DBNull.Value),
  246. new SugarParameter("@EntryId", link.Entry.Id),
  247. new SugarParameter("@TenantId", tenantId));
  248. }
  249. }
  250. // 数量变更时同步更新 mes_morder 和 mes_moentry
  251. if (qtyChanged && qty.HasValue)
  252. {
  253. await UpdateMesOrderQuantityAsync(tenantId, workOrd, qty.Value, account);
  254. // 同步更新 WorkOrdRouting.QtyOrded(EnsureFromRoutingAsync 已有数据时不会更新数量)
  255. await _db.Ado.ExecuteCommandAsync(
  256. """
  257. UPDATE WorkOrdRouting
  258. SET QtyOrded = @Qty,
  259. UpdateUser = @User, UpdateTime = @Now
  260. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  261. AND IFNULL(IsActive, 0) = 1
  262. """,
  263. new SugarParameter("@Qty", qty.Value),
  264. new SugarParameter("@User", account),
  265. new SugarParameter("@Now", DateTime.Now),
  266. new SugarParameter("@TenantId", tenantId),
  267. new SugarParameter("@WorkOrd", workOrd));
  268. }
  269. // 数量变更时同步更新领料单明细(NbrDetail)
  270. if (qtyChanged)
  271. {
  272. await SyncPickingListDetailsAsync(tenantId, workOrd, account, warnings);
  273. }
  274. ProductionScheduleGenerationService.ScheduleGenerationResult? reschedule = null;
  275. if (qtyChanged || dueChanged)
  276. {
  277. reschedule = await _scheduleGen.RegenerateForWorkOrderAsync(tenantId, workOrd, input.Domain, account, input.EnableCapacityConstraint);
  278. warnings.Add(reschedule.Message);
  279. // 排程后确保工艺路线数据存在
  280. await _routingSync.EnsureFromRoutingAsync(tenantId, workOrd, account);
  281. }
  282. else if (priorityChanged)
  283. {
  284. warnings.Add("优先级已更新,将在下次全量重排时参与排序(现有日计划保持不变)");
  285. }
  286. var pendingReorder = priorityChanged && !qtyChanged && !dueChanged;
  287. await _runLog.SuccessAsync(logId, "优先级调整并重检完成", new
  288. {
  289. workOrd,
  290. before,
  291. after = new
  292. {
  293. qty,
  294. dueDate,
  295. priority = input.Priority,
  296. lotSerial = input.LotSerial
  297. },
  298. qtyChanged,
  299. dueChanged,
  300. priorityChanged,
  301. pendingReorder,
  302. resourceRechecked = qtyChanged || dueChanged,
  303. reschedule = reschedule is null
  304. ? null
  305. : new
  306. {
  307. reschedule.WorkOrderCount,
  308. reschedule.ScheduleRowCount,
  309. reschedule.UsedWorkCenterCalendar
  310. },
  311. warnings
  312. });
  313. return new
  314. {
  315. message = "ok",
  316. warnings,
  317. resourceRechecked = qtyChanged || dueChanged,
  318. rescheduleTriggered = reschedule is not null,
  319. scheduleRowCount = reschedule?.ScheduleRowCount ?? 0,
  320. pendingReorder
  321. };
  322. }
  323. catch (Exception ex)
  324. {
  325. await _runLog.FailedAsync(logId, ex.Message, new { workOrd, tenantId });
  326. throw;
  327. }
  328. }
  329. private long ResolveTenantId(string? domain)
  330. {
  331. if (!string.IsNullOrWhiteSpace(domain) && long.TryParse(domain.Trim(), out var tid) && tid > 0)
  332. return tid;
  333. return AidopTenantHelper.Resolve(App.HttpContext);
  334. }
  335. /// <summary>
  336. /// 数量变更时同步更新 mes_morder(制造工单)和 mes_moentry(工单明细)的数量字段。
  337. /// </summary>
  338. private async Task UpdateMesOrderQuantityAsync(long tenantId, string workOrd, decimal newQty, string account)
  339. {
  340. var now = DateTime.Now;
  341. // 更新 mes_morder
  342. await _db.Ado.ExecuteCommandAsync(
  343. """
  344. UPDATE mes_morder
  345. SET need_number = @Qty,
  346. morder_production_number = @Qty,
  347. update_by_name = @User,
  348. update_time = @Now
  349. WHERE morder_no = @MorderNo
  350. AND tenant_id = @TenantId
  351. AND IsDeleted = 0
  352. """,
  353. new SugarParameter("@Qty", newQty),
  354. new SugarParameter("@User", account),
  355. new SugarParameter("@Now", now),
  356. new SugarParameter("@MorderNo", workOrd),
  357. new SugarParameter("@TenantId", tenantId));
  358. // 更新 mes_moentry(remaining_number 仅在未开始生产时才更新)
  359. await _db.Ado.ExecuteCommandAsync(
  360. """
  361. UPDATE mes_moentry
  362. SET need_number = @Qty,
  363. morder_production_number = @Qty,
  364. remaining_number = CASE
  365. WHEN IFNULL(remaining_number, 0) = IFNULL(need_number, 0)
  366. OR IFNULL(morder_production_number, 0) = 0
  367. THEN @Qty
  368. ELSE remaining_number
  369. END,
  370. update_by_name = @User,
  371. update_time = @Now
  372. WHERE moentry_mono = @Mono
  373. AND tenant_id = @TenantId
  374. AND IsDeleted = 0
  375. """,
  376. new SugarParameter("@Qty", newQty),
  377. new SugarParameter("@User", account),
  378. new SugarParameter("@Now", now),
  379. new SugarParameter("@Mono", workOrd),
  380. new SugarParameter("@TenantId", tenantId));
  381. }
  382. /// <summary>
  383. /// 数量变更后同步领料单明细(NbrDetail),按物料逐行比对更新。
  384. /// 规则:
  385. /// 已发料(QtyRec>0) + 新需求>已发料 → 更新 QtyOrd/CurrQtyOpened
  386. /// 已发料(QtyRec>0) + 新需求≤已发料 → 关闭该行
  387. /// 未发料 → 直接修改 QtyOrd/CurrQtyOpened
  388. /// 工单明细有但领料单无 → 新增
  389. /// 领料单有但工单明细无 → 关闭
  390. /// </summary>
  391. private async Task SyncPickingListDetailsAsync(
  392. long tenantId, string workOrd, string account, List<string> warnings)
  393. {
  394. // 获取工单状态
  395. var status = await _db.Ado.GetStringAsync(
  396. """
  397. SELECT IFNULL(LOWER(TRIM(Status)), '') FROM WorkOrdMaster
  398. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  399. LIMIT 1
  400. """,
  401. new SugarParameter("@TenantId", tenantId),
  402. new SugarParameter("@WorkOrd", workOrd));
  403. // 仅处理 下达(R)/投产(W)/暂停(S) 状态的工单
  404. if (status is not ("r" or "w" or "s"))
  405. {
  406. // 初始(P)状态无领料单,无需同步
  407. return;
  408. }
  409. var woDomain = await _db.Ado.GetStringAsync(
  410. """
  411. SELECT IFNULL(`Domain`, '') FROM WorkOrdMaster
  412. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  413. LIMIT 1
  414. """,
  415. new SugarParameter("@TenantId", tenantId),
  416. new SugarParameter("@WorkOrd", workOrd));
  417. if (await _pickNbrSync.TryEnqueueAfterPlanChangeAsync(tenantId, workOrd, woDomain, account))
  418. {
  419. warnings.Add($"工单 {workOrd} 领料单已入队回写 165");
  420. return;
  421. }
  422. // 查找领料单主记录
  423. var nbrRows = await _db.Ado.SqlQueryAsync<NbrMasterRow>(
  424. """
  425. SELECT RecID, Nbr, `Domain` FROM NbrMaster
  426. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND Type = 'SM'
  427. AND IFNULL(TransType, '') = ''
  428. AND IFNULL(IsActive, 0) = 1
  429. """,
  430. new SugarParameter("@TenantId", tenantId),
  431. new SugarParameter("@WorkOrd", workOrd));
  432. if (nbrRows.Count == 0)
  433. {
  434. warnings.Add($"工单 {workOrd} 状态为 {status.ToUpper()} 但无领料单,跳过领料单同步");
  435. return;
  436. }
  437. var now = DateTime.Now;
  438. // 从 WorkOrdDetail 汇总最新物料需求
  439. var details = await _db.Ado.SqlQueryAsync<PickDetailRow>(
  440. """
  441. SELECT
  442. d.ItemNum,
  443. SUM(d.QtyRequired) AS QtyRequired,
  444. MAX(IFNULL(d.UM, im.Um)) AS Unit,
  445. MAX(im.Descr) AS ItemName
  446. FROM WorkOrdDetail d
  447. LEFT JOIN ItemMaster im ON d.ItemNum = im.ItemNum AND im.tenant_id = d.tenant_id
  448. WHERE d.tenant_id = @TenantId AND d.WorkOrd = @WorkOrd AND IFNULL(d.IsActive, 0) = 1
  449. GROUP BY d.ItemNum
  450. HAVING SUM(d.QtyRequired) > 0
  451. ORDER BY d.ItemNum
  452. """,
  453. new SugarParameter("@TenantId", tenantId),
  454. new SugarParameter("@WorkOrd", workOrd));
  455. foreach (var nbr in nbrRows)
  456. {
  457. var domain = string.IsNullOrWhiteSpace(nbr.Domain) ? tenantId.ToString() : nbr.Domain!.Trim();
  458. if (domain.Length > 8) domain = domain[..8];
  459. // 加载当前领料单明细行(仅未关闭的)
  460. var existingDetails = await _db.Ado.SqlQueryAsync<NbrDetailRow>(
  461. """
  462. SELECT RecID, ItemNum, QtyOrd, QtyRec, CurrQtyOpened, Line
  463. FROM NbrDetail
  464. WHERE tenant_id = @TenantId AND Nbr = @Nbr AND Type = 'SM'
  465. AND IFNULL(IsActive, 0) = 1
  466. """,
  467. new SugarParameter("@TenantId", tenantId),
  468. new SugarParameter("@Nbr", nbr.Nbr));
  469. var detailMap = details.ToDictionary(d => d.ItemNum.Trim(), d => d);
  470. var existingMap = existingDetails.ToDictionary(d => (d.ItemNum ?? "").Trim(), d => d);
  471. // ── 1. 处理已有明细行:按 ItemNum 匹配 ──
  472. foreach (var existing in existingDetails)
  473. {
  474. var key = (existing.ItemNum ?? "").Trim();
  475. if (detailMap.TryGetValue(key, out var newDetail))
  476. {
  477. var newQty = newDetail.QtyRequired;
  478. if (existing.QtyRec > 0)
  479. {
  480. if (newQty > existing.QtyRec)
  481. {
  482. // 新需求 > 已发料 → 更新需求数
  483. await _db.Ado.ExecuteCommandAsync(
  484. """
  485. UPDATE NbrDetail
  486. SET QtyOrd = @QtyOrd, CurrQtyOpened = @CurrQtyOpened,
  487. UM = @UM, ItemName = @ItemName,
  488. UpdateUser = @User, UpdateTime = @Now
  489. WHERE RecID = @RecId
  490. """,
  491. new SugarParameter("@QtyOrd", newQty),
  492. new SugarParameter("@CurrQtyOpened", newQty),
  493. new SugarParameter("@UM", (object?)newDetail.Unit ?? DBNull.Value),
  494. new SugarParameter("@ItemName", (object?)newDetail.ItemName ?? DBNull.Value),
  495. new SugarParameter("@User", account),
  496. new SugarParameter("@Now", now),
  497. new SugarParameter("@RecId", existing.RecID));
  498. }
  499. else
  500. {
  501. // 新需求 <= 已发料 → 关闭当前行
  502. await _db.Ado.ExecuteCommandAsync(
  503. """
  504. UPDATE NbrDetail
  505. SET Status = 'C', IsActive = 0,
  506. UpdateUser = @User, UpdateTime = @Now
  507. WHERE RecID = @RecId
  508. """,
  509. new SugarParameter("@User", account),
  510. new SugarParameter("@Now", now),
  511. new SugarParameter("@RecId", existing.RecID));
  512. }
  513. }
  514. else
  515. {
  516. // 未发料 → 直接修改需求数
  517. await _db.Ado.ExecuteCommandAsync(
  518. """
  519. UPDATE NbrDetail
  520. SET QtyOrd = @QtyOrd, CurrQtyOpened = @CurrQtyOpened,
  521. UM = @UM, ItemName = @ItemName,
  522. UpdateUser = @User, UpdateTime = @Now
  523. WHERE RecID = @RecId
  524. """,
  525. new SugarParameter("@QtyOrd", newQty),
  526. new SugarParameter("@CurrQtyOpened", newQty),
  527. new SugarParameter("@UM", (object?)newDetail.Unit ?? DBNull.Value),
  528. new SugarParameter("@ItemName", (object?)newDetail.ItemName ?? DBNull.Value),
  529. new SugarParameter("@User", account),
  530. new SugarParameter("@Now", now),
  531. new SugarParameter("@RecId", existing.RecID));
  532. }
  533. }
  534. else
  535. {
  536. // 物料明细中没有,但领料单明细有 → 关闭当前领料单明细行
  537. await _db.Ado.ExecuteCommandAsync(
  538. """
  539. UPDATE NbrDetail
  540. SET Status = 'C', IsActive = 0,
  541. UpdateUser = @User, UpdateTime = @Now
  542. WHERE RecID = @RecId
  543. """,
  544. new SugarParameter("@User", account),
  545. new SugarParameter("@Now", now),
  546. new SugarParameter("@RecId", existing.RecID));
  547. }
  548. }
  549. // ── 2. 新增:物料明细中有,领料单明细没有的 ──
  550. var nextDetailId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) + 1 FROM NbrDetail");
  551. short newLine = (short)(existingDetails.Count > 0 ? existingDetails.Max(d => d.Line) + 1 : 1);
  552. foreach (var d in details)
  553. {
  554. var key = d.ItemNum.Trim();
  555. if (existingMap.ContainsKey(key))
  556. continue;
  557. await _db.Ado.ExecuteCommandAsync(
  558. """
  559. INSERT INTO NbrDetail (
  560. RecID, `Domain`, Type, Nbr, Line, ItemNum, Dimension1, Dimension2,
  561. LocationFrom, LocationTo, QtyFrom, QtyTo, QtyOrd, QtyRec,
  562. CurrQtyOpened, UM, WorkOrd, ItemName, Status,
  563. IsActive, IsConfirm, IsChanged, BusinessID, NbrRecID,
  564. CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
  565. ) VALUES (
  566. @RecId, @Domain, 'SM', @Nbr, @Line, @ItemNum, '', '',
  567. '', '', 0, 0, @QtyOrd, 0,
  568. @CurrQtyOpened, @UM, @WorkOrd, @ItemName, '',
  569. 1, 0, 1, 0, @NbrRecId,
  570. @User, @Now, @User, @Now, @TenantId
  571. )
  572. """,
  573. new SugarParameter("@RecId", nextDetailId++),
  574. new SugarParameter("@Domain", domain),
  575. new SugarParameter("@Nbr", nbr.Nbr),
  576. new SugarParameter("@Line", newLine++),
  577. new SugarParameter("@ItemNum", d.ItemNum),
  578. new SugarParameter("@QtyOrd", d.QtyRequired),
  579. new SugarParameter("@CurrQtyOpened", d.QtyRequired),
  580. new SugarParameter("@UM", (object?)d.Unit ?? DBNull.Value),
  581. new SugarParameter("@WorkOrd", workOrd),
  582. new SugarParameter("@ItemName", (object?)d.ItemName ?? DBNull.Value),
  583. new SugarParameter("@NbrRecId", nbr.RecID),
  584. new SugarParameter("@User", account.Length > 24 ? account[..24] : account),
  585. new SugarParameter("@Now", now),
  586. new SugarParameter("@TenantId", tenantId));
  587. }
  588. // ── 3. 更新领料单主记录的更新时间和数量 ──
  589. await _db.Ado.ExecuteCommandAsync(
  590. """
  591. UPDATE NbrMaster
  592. SET QtyOrd = (SELECT IFNULL(SUM(QtyOrd), 0) FROM NbrDetail
  593. WHERE Nbr = @Nbr AND Type = 'SM' AND IFNULL(IsActive, 1) = 1),
  594. UpdateUser = @User, UpdateTime = @Now
  595. WHERE RecID = @RecId
  596. """,
  597. new SugarParameter("@Nbr", nbr.Nbr),
  598. new SugarParameter("@User", account),
  599. new SugarParameter("@Now", now),
  600. new SugarParameter("@RecId", nbr.RecID));
  601. }
  602. }
  603. // ══════════════════════════════════════════════════════════════
  604. // 内部 DTO
  605. // ══════════════════════════════════════════════════════════════
  606. private sealed class NbrMasterRow
  607. {
  608. public int RecID { get; set; }
  609. public string Nbr { get; set; } = string.Empty;
  610. public string? Domain { get; set; }
  611. }
  612. private sealed class NbrDetailRow
  613. {
  614. public int RecID { get; set; }
  615. public string? ItemNum { get; set; }
  616. public decimal QtyOrd { get; set; }
  617. public decimal QtyRec { get; set; }
  618. public decimal CurrQtyOpened { get; set; }
  619. public short Line { get; set; }
  620. }
  621. private sealed class PickDetailRow
  622. {
  623. public string ItemNum { get; set; } = string.Empty;
  624. public decimal QtyRequired { get; set; }
  625. public string? Unit { get; set; }
  626. public string? ItemName { get; set; }
  627. }
  628. private async Task<WorkOrderSnapshotRow?> LoadWorkOrderSnapshotAsync(long tenantId, string workOrd)
  629. {
  630. var rows = await _db.Ado.SqlQueryAsync<WorkOrderSnapshotRow>(
  631. """
  632. SELECT WorkOrd, Priority, QtyOrded, DueDate, LotSerial, `Domain`
  633. FROM WorkOrdMaster
  634. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd
  635. LIMIT 1
  636. """,
  637. new SugarParameter("@TenantId", tenantId),
  638. new SugarParameter("@WorkOrd", workOrd));
  639. return rows.FirstOrDefault();
  640. }
  641. private async Task<WorkOrderEntryLink?> LoadWorkOrderEntryLinkAsync(long tenantId, string workOrd)
  642. {
  643. var rows = await _db.Ado.SqlQueryAsync<WorkOrderEntryLinkRow>(
  644. """
  645. SELECT
  646. w.BusinessID AS EntryId,
  647. e.seorder_id AS SeOrderId,
  648. e.bill_no AS BillNo,
  649. e.entry_seq AS EntrySeq,
  650. e.item_number AS ItemNumber,
  651. e.item_name AS ItemName,
  652. e.specification AS Specification,
  653. e.unit AS Unit,
  654. e.bom_number AS BomNumber,
  655. e.qty AS Qty,
  656. e.plan_date AS PlanDate,
  657. e.sys_capacity_date AS SysCapacityDate,
  658. e.progress AS Progress,
  659. e.urgent AS Urgent,
  660. e.factory_id AS FactoryId,
  661. e.company_id AS CompanyId,
  662. e.tenant_id AS TenantId,
  663. o.Id AS OrderId,
  664. o.bill_no AS OrderBillNo,
  665. o.custom_no AS CustomNo,
  666. o.urgent AS OrderUrgent,
  667. o.factory_id AS OrderFactoryId
  668. FROM WorkOrdMaster w
  669. INNER JOIN crm_seorderentry e ON e.Id = w.BusinessID AND e.tenant_id = w.tenant_id AND e.IsDeleted = 0
  670. INNER JOIN crm_seorder o ON o.Id = e.seorder_id AND o.tenant_id = e.tenant_id AND o.IsDeleted = 0
  671. WHERE w.tenant_id = @TenantId AND w.WorkOrd = @WorkOrd
  672. LIMIT 1
  673. """,
  674. new SugarParameter("@TenantId", tenantId),
  675. new SugarParameter("@WorkOrd", workOrd));
  676. var row = rows.FirstOrDefault();
  677. if (row is null || row.EntryId <= 0)
  678. return null;
  679. return new WorkOrderEntryLink
  680. {
  681. Order = new OrderWorkOrderGenerationService.OrderHeader
  682. {
  683. Id = row.OrderId,
  684. BillNo = row.OrderBillNo ?? row.BillNo,
  685. CustomNo = row.CustomNo,
  686. Urgent = row.OrderUrgent,
  687. FactoryId = row.OrderFactoryId,
  688. TenantId = tenantId
  689. },
  690. Entry = new OrderWorkOrderGenerationService.OrderEntryLine
  691. {
  692. Id = row.EntryId,
  693. SeOrderId = row.SeOrderId,
  694. BillNo = row.BillNo,
  695. EntrySeq = row.EntrySeq,
  696. ItemNumber = row.ItemNumber,
  697. ItemName = row.ItemName,
  698. Specification = row.Specification,
  699. Unit = row.Unit,
  700. BomNumber = row.BomNumber,
  701. Qty = row.Qty,
  702. PlanDate = row.PlanDate,
  703. SysCapacityDate = row.SysCapacityDate,
  704. Progress = row.Progress,
  705. Urgent = row.Urgent,
  706. FactoryId = row.FactoryId,
  707. CompanyId = row.CompanyId,
  708. TenantId = row.TenantId
  709. }
  710. };
  711. }
  712. private sealed class WorkOrderSnapshotRow
  713. {
  714. public string? WorkOrd { get; set; }
  715. public string? Priority { get; set; }
  716. public decimal? QtyOrded { get; set; }
  717. public DateTime? DueDate { get; set; }
  718. public string? LotSerial { get; set; }
  719. public string? Domain { get; set; }
  720. }
  721. private sealed class WorkOrderEntryLink
  722. {
  723. public OrderWorkOrderGenerationService.OrderHeader Order { get; set; } = new();
  724. public OrderWorkOrderGenerationService.OrderEntryLine Entry { get; set; } = new();
  725. }
  726. private sealed class WorkOrderEntryLinkRow
  727. {
  728. public long EntryId { get; set; }
  729. public long SeOrderId { get; set; }
  730. public string? BillNo { get; set; }
  731. public int? EntrySeq { get; set; }
  732. public string? ItemNumber { get; set; }
  733. public string? ItemName { get; set; }
  734. public string? Specification { get; set; }
  735. public string? Unit { get; set; }
  736. public string? BomNumber { get; set; }
  737. public decimal? Qty { get; set; }
  738. public DateTime? PlanDate { get; set; }
  739. public DateTime? SysCapacityDate { get; set; }
  740. public string? Progress { get; set; }
  741. public int? Urgent { get; set; }
  742. public long? FactoryId { get; set; }
  743. public long? CompanyId { get; set; }
  744. public long TenantId { get; set; }
  745. public long OrderId { get; set; }
  746. public string? OrderBillNo { get; set; }
  747. public string? CustomNo { get; set; }
  748. public int? OrderUrgent { get; set; }
  749. public long? OrderFactoryId { get; set; }
  750. }
  751. }
  752. public class WorkOrderPriorityRecheckInput
  753. {
  754. public string Workord { get; set; } = string.Empty;
  755. public string? Qty { get; set; }
  756. public string? Instockdate { get; set; }
  757. public string? Priority { get; set; }
  758. public string Domain { get; set; } = string.Empty;
  759. public string? UserAccount { get; set; }
  760. public string? LotSerial { get; set; }
  761. public bool EnableCapacityConstraint { get; set; }
  762. }