ProductionSchedulingActionService.cs 36 KB

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