WorkOrderKittingCheckService.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. using Admin.NET.Plugin.AiDOP.Order;
  2. namespace Admin.NET.Plugin.AiDOP.WorkOrder;
  3. /// <summary>
  4. /// 工单齐套检查动作:重新执行资源检查(BOM 展开 + 库存/在途缺口计算),
  5. /// 将新结果写入 b_examine_result / b_bom_child_examine,并更新 mes_morder.MaterialSituation。
  6. /// </summary>
  7. public class WorkOrderKittingCheckService : ITransient
  8. {
  9. private readonly ISqlSugarClient _db;
  10. private readonly MaterialRequirementCalculator _calculator;
  11. private readonly ResourceCheckResultWriter _writer;
  12. private readonly WorkOrderMaterialDetailSyncService _materialDetailSync;
  13. public WorkOrderKittingCheckService(
  14. ISqlSugarClient db,
  15. MaterialRequirementCalculator calculator,
  16. ResourceCheckResultWriter writer,
  17. WorkOrderMaterialDetailSyncService materialDetailSync)
  18. {
  19. _db = db;
  20. _calculator = calculator;
  21. _writer = writer;
  22. _materialDetailSync = materialDetailSync;
  23. }
  24. /// <summary>批量齐套检查:租户下所有 Status='p'/'r' 的工单逐一重新检查。</summary>
  25. public async Task<KittingCheckResult> CheckTenantWorkOrdersAsync(long tenantId, string account)
  26. {
  27. var workOrds = await _db.Ado.SqlQueryAsync<string>(
  28. """
  29. SELECT WorkOrd FROM WorkOrdMaster
  30. WHERE tenant_id = @TenantId
  31. AND LOWER(TRIM(IFNULL(Status,''))) IN ('p', 'r')
  32. ORDER BY WorkOrd
  33. """,
  34. new SugarParameter("@TenantId", tenantId));
  35. var result = new KittingCheckResult();
  36. foreach (var wo in workOrds)
  37. {
  38. var one = await CheckSingleAsync(tenantId, wo, account, bangId: 0, checkScope: "BATCH");
  39. result.CheckedCount++;
  40. if (one.IsKitted) result.KittedCount++;
  41. else result.ShortageCount++;
  42. }
  43. return result;
  44. }
  45. /// <summary>
  46. /// 单工单齐套检查:以工单为主,重新 BOM 展开 + 库存计算 → 写入检查结果。
  47. /// BusinessID>0 时从订单明细行加载数据;BusinessID=0 时从工单/生产工单构造参数。
  48. /// </summary>
  49. /// <param name="bangId">
  50. /// 排程版本。&gt; 0 表示由排产驱动(净口径 + 写占用);
  51. /// = 0 表示手动/下达触发,此时自动复用当前有效排程版本,保持口径一致但只读不写。
  52. /// </param>
  53. /// <param name="checkScope">触发来源,写入 b_examine_result.check_scope 供事后追溯。</param>
  54. public async Task<SingleKittingCheckResult> CheckSingleAsync(
  55. long tenantId, string workOrd, string account, long bangId = 0, string checkScope = "SINGLE")
  56. {
  57. var wo = workOrd.Trim();
  58. // 口径统一(C-D1):手动/下达触发时也按净口径算——复用当前有效排程版本的占用账,
  59. // 否则每个工单都以为自己能独占全部库存,多工单同时下达必然超发。
  60. var isScheduleDriven = bangId > 0;
  61. if (!isScheduleDriven)
  62. bangId = await ResolveCurrentScheduleBangIdAsync(tenantId) ?? 0;
  63. // 1. 从 WorkOrdMaster + mes_morder 获取工单数据
  64. var wm = await LoadWorkOrdMasterAsync(tenantId, wo);
  65. if (wm is null)
  66. throw Oops.Oh($"工单 {wo} 不存在或已关闭");
  67. OrderWorkOrderGenerationService.OrderEntryLine entry;
  68. OrderWorkOrderGenerationService.OrderHeader order;
  69. if (wm.BusinessId > 0)
  70. {
  71. // 2a. 有关联订单明细行:从 crm_seorderentry / crm_seorder 加载
  72. var loadedEntry = await LoadOrderEntryAsync(wm.BusinessId, tenantId);
  73. if (loadedEntry is not null)
  74. {
  75. entry = loadedEntry;
  76. var loadedOrder = await LoadOrderHeaderAsync(entry.SeOrderId, tenantId);
  77. order = loadedOrder ?? BuildFallbackOrderFromWorkOrder(wm);
  78. }
  79. else
  80. {
  81. // 明细行已删除/不存在,回退到工单数据
  82. entry = BuildEntryFromWorkOrder(wm);
  83. order = BuildFallbackOrderFromWorkOrder(wm);
  84. }
  85. }
  86. else
  87. {
  88. // 2b. 无关联订单明细行(BusinessID=0):从工单构造检查参数
  89. entry = BuildEntryFromWorkOrder(wm);
  90. order = BuildFallbackOrderFromWorkOrder(wm);
  91. }
  92. // 3. 重新执行资源检查(BOM 展开 + 库存/在途缺口计算)
  93. // 非排产触发:只读不写占用(C-D3),并排除本工单自身占用(C-D2)
  94. var warnings = new List<string>();
  95. var lines = await _calculator.BuildLinesAsync(
  96. order, entry, warnings, bangId, wo, wm.MorderId ?? 0,
  97. writeOccupy: isScheduleDriven,
  98. excludeOccupyWorkOrd: wo);
  99. // 4. 将新结果写入 b_examine_result / b_bom_child_examine 并更新 mes_morder
  100. var checkResult = await _writer.WriteAsync(
  101. order, entry, wo, wm.MorderId, lines, account, DateTime.Now,
  102. bangId: bangId, checkScope: checkScope);
  103. // 5. 同步工单物料明细(WorkOrdDetail)
  104. await _materialDetailSync.EnsureFromResourceCheckAsync(tenantId, wo, account);
  105. // 6. 计算齐套率并回写到领料单(NbrMaster.MFRate / NbrDetail.SatisfyQty)
  106. await WriteBackMFRateAsync(tenantId, wo);
  107. return new SingleKittingCheckResult
  108. {
  109. WorkOrd = wo,
  110. IsKitted = !checkResult.HasShortage,
  111. ShortageLineCount = checkResult.HasShortage
  112. ? lines.Count(x => x.IsUse == 1 && x.LackQty > 0)
  113. : 0,
  114. MaterialSituation = checkResult.HasShortage ? "缺料" : "齐套"
  115. };
  116. }
  117. /// <summary>从 WorkOrdMaster + mes_morder 数据构造 OrderEntryLine。</summary>
  118. private static OrderWorkOrderGenerationService.OrderEntryLine BuildEntryFromWorkOrder(WorkOrdMasterRow wm)
  119. {
  120. return new OrderWorkOrderGenerationService.OrderEntryLine
  121. {
  122. Id = wm.BusinessId,
  123. SeOrderId = 0,
  124. BillNo = wm.SalesJob,
  125. EntrySeq = 0,
  126. ItemNumber = wm.WoItemNum,
  127. ItemName = wm.WoItemName,
  128. Specification = wm.MorderModel,
  129. Unit = wm.MorderUnit,
  130. BomNumber = wm.MorderBomNumber ?? wm.WoBomFormula,
  131. Qty = wm.WoQtyOrded,
  132. PlanDate = wm.WoDueDate,
  133. SysCapacityDate = wm.WoDueDate,
  134. Progress = null,
  135. Urgent = wm.WoUrgent,
  136. FactoryId = null,
  137. CompanyId = null,
  138. TenantId = wm.WoTenantId
  139. };
  140. }
  141. /// <summary>从 WorkOrdMaster 数据构造兜底 OrderHeader。</summary>
  142. private static OrderWorkOrderGenerationService.OrderHeader BuildFallbackOrderFromWorkOrder(WorkOrdMasterRow wm)
  143. {
  144. return new OrderWorkOrderGenerationService.OrderHeader
  145. {
  146. Id = 0,
  147. BillNo = wm.SalesJob,
  148. CustomNo = wm.WoCustNo,
  149. Urgent = wm.WoUrgent,
  150. FactoryId = null,
  151. TenantId = wm.WoTenantId
  152. };
  153. }
  154. // ──────────────── 数据加载 ────────────────
  155. /// <summary>
  156. /// 取当前有效排程版本 id(= 最近一次成功排产的 aidop_action_run_log.id,与 bang_id 同源)。
  157. /// 与 ProductionScheduleGenerationService.ResolveCurrentScheduleBangIdAsync 同逻辑;
  158. /// 无成功排产记录时返回 null,口径退化为毛口径(与改动前行为一致,不会更差)。
  159. /// </summary>
  160. private async Task<long?> ResolveCurrentScheduleBangIdAsync(long tenantId)
  161. {
  162. var id = await _db.Ado.GetLongAsync(
  163. """
  164. SELECT id FROM aidop_action_run_log
  165. WHERE tenant_id = @TenantId AND action_code = @ActionCode AND status = 'SUCCESS'
  166. ORDER BY start_time DESC, id DESC
  167. LIMIT 1
  168. """,
  169. new List<SugarParameter>
  170. {
  171. new("@TenantId", tenantId),
  172. new("@ActionCode", "S2_SCHEDULE_GENERATE")
  173. });
  174. return id > 0 ? id : null;
  175. }
  176. private async Task<WorkOrdMasterRow?> LoadWorkOrdMasterAsync(long tenantId, string workOrd)
  177. {
  178. var rows = await _db.Ado.SqlQueryAsync<WorkOrdMasterRow>(
  179. """
  180. SELECT wm.RecID, wm.WorkOrd, wm.BusinessID, wm.SalesJob,
  181. wm.ItemNum AS WoItemNum, wm.ItemName AS WoItemName,
  182. wm.QtyOrded AS WoQtyOrded, wm.DueDate AS WoDueDate,
  183. wm.Urgent AS WoUrgent, wm.CustNo AS WoCustNo,
  184. wm.BOMFormula AS WoBomFormula,
  185. wm.tenant_id AS WoTenantId,
  186. morder.Id AS MorderId,
  187. morder.bom_number AS MorderBomNumber,
  188. morder.product_code AS MorderProductCode,
  189. morder.product_name AS MorderProductName,
  190. morder.fmodel AS MorderModel,
  191. morder.unit AS MorderUnit
  192. FROM WorkOrdMaster wm
  193. LEFT JOIN mes_morder morder
  194. ON morder.morder_no = wm.WorkOrd
  195. AND morder.tenant_id = wm.tenant_id
  196. AND morder.IsDeleted = 0
  197. WHERE wm.tenant_id = @TenantId
  198. AND wm.WorkOrd = @WorkOrd
  199. AND LOWER(TRIM(IFNULL(wm.Status,''))) <> 'c'
  200. ORDER BY wm.RecID DESC
  201. LIMIT 1
  202. """,
  203. new SugarParameter("@TenantId", tenantId),
  204. new SugarParameter("@WorkOrd", workOrd));
  205. return rows.FirstOrDefault();
  206. }
  207. private async Task<OrderWorkOrderGenerationService.OrderEntryLine?> LoadOrderEntryAsync(long entryId, long tenantId)
  208. {
  209. var rows = await _db.Ado.SqlQueryAsync<OrderWorkOrderGenerationService.OrderEntryLine>(
  210. """
  211. SELECT
  212. Id, seorder_id AS SeOrderId, bill_no AS BillNo, entry_seq AS EntrySeq,
  213. item_number AS ItemNumber, item_name AS ItemName, specification AS Specification,
  214. unit AS Unit, bom_number AS BomNumber, qty AS Qty,
  215. plan_date AS PlanDate, sys_capacity_date AS SysCapacityDate,
  216. progress AS Progress, urgent AS Urgent,
  217. factory_id AS FactoryId, company_id AS CompanyId, tenant_id AS TenantId
  218. FROM crm_seorderentry
  219. WHERE Id = @Id AND tenant_id = @TenantId AND IsDeleted = 0
  220. LIMIT 1
  221. """,
  222. new SugarParameter("@Id", entryId),
  223. new SugarParameter("@TenantId", tenantId));
  224. return rows.FirstOrDefault();
  225. }
  226. private async Task<OrderWorkOrderGenerationService.OrderHeader?> LoadOrderHeaderAsync(long orderId, long tenantId)
  227. {
  228. var rows = await _db.Ado.SqlQueryAsync<OrderWorkOrderGenerationService.OrderHeader>(
  229. """
  230. SELECT Id, bill_no AS BillNo, custom_no AS CustomNo, urgent AS Urgent,
  231. factory_id AS FactoryId, tenant_id AS TenantId
  232. FROM crm_seorder
  233. WHERE Id = @Id AND tenant_id = @TenantId AND IsDeleted = 0
  234. LIMIT 1
  235. """,
  236. new SugarParameter("@Id", orderId),
  237. new SugarParameter("@TenantId", tenantId));
  238. return rows.FirstOrDefault();
  239. }
  240. /// <summary>
  241. /// 计算工单齐套率并回写到领料单:
  242. /// NbrMaster.MFRate = MIN(UseQty / Qty)(所有有效物料 erp_cls!=4),上限 1.0;
  243. /// NbrDetail.SatisfyQty = (QtyOrd - QtyRec) * MFRate。
  244. /// </summary>
  245. public async Task WriteBackMFRateAsync(long tenantId, string workOrd)
  246. {
  247. if (string.IsNullOrWhiteSpace(workOrd)) return;
  248. // 1. 取最新一次齐套检查的 examine_id
  249. var examineId = await _db.Ado.GetLongAsync(
  250. """
  251. SELECT Id FROM b_examine_result
  252. WHERE tenant_id = @TenantId AND morder_no = @WorkOrd AND IsDeleted = 0
  253. ORDER BY create_time DESC
  254. LIMIT 1
  255. """,
  256. new List<SugarParameter>
  257. {
  258. new("@TenantId", tenantId),
  259. new("@WorkOrd", workOrd)
  260. });
  261. if (examineId <= 0) return;
  262. // 2. 查询所有有效物料行(is_use=1, erp_cls!=4, qty>0)
  263. var materials = await _db.Ado.SqlQueryAsync<MFRateMaterialRow>(
  264. """
  265. SELECT IFNULL(use_qty, 0) AS UseQty, IFNULL(qty, 0) AS Qty
  266. FROM b_bom_child_examine
  267. WHERE examine_id = @ExamineId AND is_use = 1 AND IFNULL(erp_cls, 0) <> 4 AND IFNULL(qty, 0) > 0
  268. """,
  269. new SugarParameter("@ExamineId", examineId));
  270. // 3. 计算 MFRate = MIN(UseQty / Qty),上限 1.0
  271. decimal? mfRate = null;
  272. if (materials.Count > 0)
  273. {
  274. decimal minRate = 1.0m;
  275. foreach (var mat in materials)
  276. {
  277. if (mat.Qty <= 0) continue;
  278. var rate = mat.UseQty / mat.Qty;
  279. if (rate < minRate) minRate = rate;
  280. }
  281. mfRate = Math.Round(Math.Min(minRate, 1.0m), 5, MidpointRounding.AwayFromZero);
  282. }
  283. // 4. 更新 NbrMaster.MFRate
  284. await _db.Ado.ExecuteCommandAsync(
  285. """
  286. UPDATE NbrMaster
  287. SET MFRate = @MFRate, UpdateTime = NOW()
  288. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND Type = 'SM'
  289. AND IFNULL(IsActive, 0) = 1
  290. """,
  291. new SugarParameter("@MFRate", (object?)mfRate ?? DBNull.Value),
  292. new SugarParameter("@TenantId", tenantId),
  293. new SugarParameter("@WorkOrd", workOrd));
  294. // 5. 更新 NbrDetail.SatisfyQty = GREATEST(QtyOrd - QtyRec, 0) * MFRate
  295. if (mfRate.HasValue)
  296. {
  297. await _db.Ado.ExecuteCommandAsync(
  298. """
  299. UPDATE NbrDetail
  300. SET SatisfyQty = ROUND(GREATEST(IFNULL(QtyOrd, 0) - IFNULL(QtyRec, 0), 0) * @MFRate, 5),
  301. UpdateTime = NOW()
  302. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND Type = 'SM'
  303. AND IFNULL(IsActive, 0) = 1 AND UPPER(IFNULL(Status, '')) <> 'C'
  304. """,
  305. new SugarParameter("@MFRate", mfRate.Value),
  306. new SugarParameter("@TenantId", tenantId),
  307. new SugarParameter("@WorkOrd", workOrd));
  308. }
  309. }
  310. // ──────────────── 内部类 ────────────────
  311. private sealed class MFRateMaterialRow
  312. {
  313. public decimal UseQty { get; set; }
  314. public decimal Qty { get; set; }
  315. }
  316. private sealed class WorkOrdMasterRow
  317. {
  318. public long RecID { get; set; }
  319. public string WorkOrd { get; set; } = string.Empty;
  320. public long BusinessId { get; set; }
  321. public string? SalesJob { get; set; }
  322. public long? MorderId { get; set; }
  323. // WorkOrdMaster 字段
  324. public string? WoItemNum { get; set; }
  325. public string? WoItemName { get; set; }
  326. public decimal WoQtyOrded { get; set; }
  327. public DateTime? WoDueDate { get; set; }
  328. public int WoUrgent { get; set; }
  329. public string? WoCustNo { get; set; }
  330. public string? WoBomFormula { get; set; }
  331. public long WoTenantId { get; set; }
  332. // mes_morder 字段
  333. public string? MorderBomNumber { get; set; }
  334. public string? MorderProductCode { get; set; }
  335. public string? MorderProductName { get; set; }
  336. public string? MorderModel { get; set; }
  337. public string? MorderUnit { get; set; }
  338. }
  339. public sealed class KittingCheckResult
  340. {
  341. public int CheckedCount { get; set; }
  342. public int KittedCount { get; set; }
  343. public int ShortageCount { get; set; }
  344. }
  345. public sealed class SingleKittingCheckResult
  346. {
  347. public string WorkOrd { get; set; } = string.Empty;
  348. public bool IsKitted { get; set; }
  349. public int ShortageLineCount { get; set; }
  350. public string MaterialSituation { get; set; } = string.Empty;
  351. }
  352. }