ProcurementPipelineService.cs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. using Admin.NET.Plugin.AiDOP.Infrastructure;
  2. using Admin.NET.Plugin.AiDOP.ProcurementExecution;
  3. using Yitter.IdGenerator;
  4. namespace Admin.NET.Plugin.AiDOP.Supply;
  5. /// <summary>
  6. /// S4 采购执行闭环:缺料生成 PR → 合并 → 要货令转 DO/PO → 外部推送日志。
  7. /// 对应旧系统 AutoTransferDoOrPo / AutomaticPrAdjustDate 业务意图。
  8. /// </summary>
  9. [ApiDescriptionSettings(Order = 309, Description = "采购执行闭环")]
  10. [Route("api/Supply")]
  11. [AllowAnonymous]
  12. [NonUnify]
  13. public class ProcurementPipelineService : IDynamicApiController, ITransient
  14. {
  15. private readonly ISqlSugarClient _db;
  16. private readonly UserManager _userManager;
  17. private readonly PurchaseRequestFromShortageService _shortageService;
  18. private readonly PurchaseRequestMergeService _mergeService;
  19. private readonly NumberRuleService _numberRuleService;
  20. private readonly PurchaseOrderTransferService _transferService;
  21. private readonly PurchaseRequestExternalPushService _pushService;
  22. private readonly AidopActionRunLogWriter _runLog;
  23. private readonly S4MdpSyncTransformService _s4MdpSync;
  24. private readonly S4ReadPathConsistencyService _readPathCheck;
  25. public ProcurementPipelineService(
  26. ISqlSugarClient db,
  27. UserManager userManager,
  28. PurchaseRequestFromShortageService shortageService,
  29. PurchaseRequestMergeService mergeService,
  30. NumberRuleService numberRuleService,
  31. PurchaseOrderTransferService transferService,
  32. PurchaseRequestExternalPushService pushService,
  33. AidopActionRunLogWriter runLog,
  34. S4MdpSyncTransformService s4MdpSync,
  35. S4ReadPathConsistencyService readPathCheck)
  36. {
  37. _db = db;
  38. _userManager = userManager;
  39. _shortageService = shortageService;
  40. _mergeService = mergeService;
  41. _numberRuleService = numberRuleService;
  42. _transferService = transferService;
  43. _pushService = pushService;
  44. _runLog = runLog;
  45. _s4MdpSync = s4MdpSync;
  46. _readPathCheck = readPathCheck;
  47. }
  48. /// <summary>执行采购闭环(合并待处理 PR、转单、写 QadTracking)。</summary>
  49. [DisplayName("采购执行闭环")]
  50. [HttpPost("procurement/execute-pipeline")]
  51. public async Task<ProcurementPipelineResult> ExecutePipeline(
  52. [FromQuery] string? domain,
  53. [FromQuery] bool createFromShortage = true,
  54. [FromQuery] bool mergeHistorical = true,
  55. [FromQuery] bool enableExternalPush = true)
  56. {
  57. var tenantId = ResolveTenantId(domain);
  58. var account = _userManager.Account ?? "system";
  59. var logId = await _runLog.StartAsync("S4_PROCUREMENT_PIPELINE", tenantId, "tenant", tenantId, domain ?? tenantId.ToString());
  60. try
  61. {
  62. var result = await ExecuteCoreAsync(tenantId, account, createFromShortage, mergeHistorical, enableExternalPush);
  63. result.ReadPathConsistency = await _readPathCheck.CheckTenantAsync(tenantId);
  64. result.Warnings.AddRange(await TryTriggerS4MdpRefreshAsync(result));
  65. await _runLog.SuccessAsync(logId, result.Message, result);
  66. return result;
  67. }
  68. catch (Exception ex)
  69. {
  70. await _runLog.FailedAsync(logId, ex.Message, new { tenantId, domain });
  71. throw Oops.Oh(ex.Message);
  72. }
  73. }
  74. /// <summary>S4 读路径一致性检查(运行表 vs DWD)。</summary>
  75. [DisplayName("S4读路径一致性检查")]
  76. [HttpGet("procurement/read-path-consistency")]
  77. public async Task<S4ReadPathConsistencyResult> ReadPathConsistency([FromQuery] string? domain)
  78. {
  79. var tenantId = ResolveTenantId(domain);
  80. return await _readPathCheck.CheckTenantAsync(tenantId);
  81. }
  82. public async Task<ProcurementPipelineResult> ExecuteCoreAsync(
  83. long tenantId,
  84. string account,
  85. bool createFromShortage,
  86. bool mergeHistorical = true,
  87. bool enableExternalPush = true)
  88. {
  89. var result = new ProcurementPipelineResult();
  90. var now = DateTime.Now;
  91. await _db.Ado.BeginTranAsync();
  92. try
  93. {
  94. var newRequests = new List<PurchaseRequestMain>();
  95. var deleteVouchers = new List<long>();
  96. if (createFromShortage)
  97. {
  98. var candidates = await _shortageService.BuildFromResourceCheckAsync(tenantId, account);
  99. result.ShortageItemCount = candidates.Count;
  100. // 幂等处理:对比已有 PR,更新/删除/保留
  101. var (toAdd, toDelete) = await _shortageService.ReconcileAsync(tenantId, candidates, account);
  102. newRequests = toAdd;
  103. deleteVouchers = toDelete;
  104. result.PrUpdatedCount = candidates.Count - toAdd.Count - toDelete.Count;
  105. }
  106. if (newRequests.Count > 0)
  107. {
  108. // 保留各工单独立 PR + 创建合并 PR(数量重新计算)
  109. var split = await _mergeService.SplitMergeWithRecalcAsync(newRequests, account);
  110. var individualPrs = split.IndividualRequests;
  111. var mergedPrs = split.MergedRequests;
  112. // 分配编号
  113. await AssignPrNumbersAsync(individualPrs, account);
  114. await AssignPrNumbersAsync(mergedPrs, account);
  115. // 独立 PR: state=5(参考,不参与 DO/PO 转单)
  116. foreach (var pr in individualPrs) pr.State = 5;
  117. // 关联独立 PR 到合并 PR 编号
  118. foreach (var group in individualPrs.GroupBy(x => new { x.IcitemId, x.PrPurchaseId }))
  119. {
  120. var merged = mergedPrs.FirstOrDefault(m =>
  121. m.IcitemId == group.Key.IcitemId && m.PrPurchaseId == group.Key.PrPurchaseId);
  122. if (merged is not null)
  123. {
  124. foreach (var ind in group)
  125. ind.IndividualPrReferBillNo = merged.PrBillNo;
  126. }
  127. }
  128. // 插入独立 PR(参考)
  129. await InsertIndividualPurchaseRequestsAsync(individualPrs);
  130. // 插入合并 PR(可转单)
  131. await InsertPurchaseRequestsAsync(mergedPrs);
  132. result.PrCreatedCount = mergedPrs.Count;
  133. result.IndividualPrCount = individualPrs.Count;
  134. result.PrMergeReducedCount = Math.Max(0, individualPrs.Count - mergedPrs.Count);
  135. }
  136. if (mergeHistorical)
  137. {
  138. var historicalMerge = await _mergeService.MergeTenantPendingAsync(tenantId, account);
  139. result.PrMergeReducedCount += historicalMerge.ReducedCount;
  140. result.HistoricalPrMergedGroups = historicalMerge.MergedGroupCount;
  141. result.PrCreatedCount += historicalMerge.CreatedPrCount;
  142. }
  143. // 删除已无缺料的旧 PR
  144. if (deleteVouchers.Count > 0)
  145. {
  146. foreach (var v in deleteVouchers)
  147. {
  148. await _db.Ado.ExecuteCommandAsync(
  149. """
  150. UPDATE srm_pr_main
  151. SET IsDeleted = 1, update_by_name = @User, update_time = @Now
  152. WHERE voucher = @Voucher
  153. """,
  154. new SugarParameter("@User", account),
  155. new SugarParameter("@Now", DateTime.Now),
  156. new SugarParameter("@Voucher", v));
  157. }
  158. result.PrDeletedCount = deleteVouchers.Count;
  159. }
  160. var pending = await LoadPendingPurchaseRequestsAsync(tenantId);
  161. result.PendingPrCount = pending.Count;
  162. if (pending.Any(x => x.IsRequireGoods == 1))
  163. {
  164. var transfer = await _transferService.TransferGeneratedRequireGoodsAsync(
  165. pending.Where(x => x.IsRequireGoods == 1).ToList(), account);
  166. result.PoCreatedCount = transfer.CreatedOrderCount;
  167. result.PoTransferredPrCount = transfer.TransferredPrCount;
  168. result.PoOccupyRehangedCount = transfer.PoOccupyRehangedCount;
  169. result.CreatedPoNumbers = transfer.CreatedOrders;
  170. await PersistPrStateUpdatesAsync(transfer.TransferredPrIds, 4, account);
  171. }
  172. var pushCandidates = pending
  173. .Where(x => x.IsRequireGoods == 0 && (x.State ?? 0) == 1)
  174. .ToList();
  175. if (enableExternalPush && pushCandidates.Count > 0)
  176. {
  177. var push = await _pushService.CreateQadTrackingForGeneratedRequestsAsync(pushCandidates, account);
  178. result.QadTrackingCount = push.TrackingCount;
  179. result.QadTrackingSkippedCount = push.SkippedCandidateCount;
  180. result.Warnings.AddRange(push.Warnings);
  181. await PersistPrStateUpdatesAsync(push.PushedPrIds, 2, account);
  182. }
  183. await _db.Ado.CommitTranAsync();
  184. }
  185. catch
  186. {
  187. await _db.Ado.RollbackTranAsync();
  188. throw;
  189. }
  190. result.Message = BuildMessage(result);
  191. return result;
  192. }
  193. private async Task<List<PurchaseRequestMain>> LoadPendingPurchaseRequestsAsync(long tenantId)
  194. {
  195. var windowEnd = DateTime.Today.AddDays(35);
  196. var rows = await _db.Ado.SqlQueryAsync<PurchaseRequestMain>(
  197. """
  198. SELECT
  199. Id, pr_billno AS PrBillNo, pr_purchaseid AS PrPurchaseId,
  200. pr_purchasenumber AS PrPurchaseNumber, pr_purchasename AS PrPurchaseName,
  201. pr_purchaser AS PrPurchaser, pr_purchaser_num AS PrPurchaserNum,
  202. pr_rqty AS PrRqty, pr_aqty AS PrAqty, pr_sqty AS PrSqty,
  203. icitem_id AS IcitemId, icitem_name AS IcitemName,
  204. pr_ssend_date AS PrSsendDate, pr_sarrive_date AS PrSarriveDate,
  205. pr_unit AS PrUnit, state AS State, pr_type AS PrType,
  206. currencytype AS CurrencyType, tenant_id AS TenantId,
  207. factory_id AS FactoryId, org_id AS OrgId, company_id AS CompanyId,
  208. IsRequireGoods, supplier_type AS SupplierType, IsDeleted
  209. FROM srm_pr_main
  210. WHERE tenant_id = @TenantId
  211. AND IFNULL(IsDeleted, 0) = 0
  212. AND IFNULL(state, 0) = 1
  213. AND IFNULL(analogcalcversion, '') = ''
  214. AND pr_ssend_date IS NOT NULL
  215. AND pr_ssend_date <= @WindowEnd
  216. ORDER BY pr_ssend_date, Id
  217. """,
  218. new SugarParameter("@TenantId", tenantId),
  219. new SugarParameter("@WindowEnd", windowEnd));
  220. return rows;
  221. }
  222. private async Task AssignPrNumbersAsync(List<PurchaseRequestMain> requests, string account)
  223. {
  224. foreach (var group in requests.GroupBy(x => x.TenantId.ToString()))
  225. {
  226. var rows = group.ToList();
  227. var numbers = await _numberRuleService.NextBatchInCurrentTransactionAsync("PR", group.Key, rows.Count, account);
  228. if (numbers.Count < rows.Count)
  229. throw Oops.Oh("采购申请编号生成失败");
  230. for (var i = 0; i < rows.Count; i++)
  231. rows[i].PrBillNo = numbers[i].Trim();
  232. }
  233. }
  234. private async Task InsertPurchaseRequestsAsync(List<PurchaseRequestMain> requests)
  235. {
  236. foreach (var pr in requests)
  237. {
  238. await _db.Ado.ExecuteCommandAsync(
  239. """
  240. INSERT INTO srm_pr_main
  241. (Id,pr_billno,pr_mono,entity_id,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
  242. pr_rqty,pr_aqty,pr_sqty,icitem_id,icitem_name,pr_ssend_date,pr_sarrive_date,pr_unit,state,pr_type,currencytype,
  243. create_by_name,create_time,update_by_name,update_time,tenant_id,factory_id,org_id,IsDeleted,company_id,IsRequireGoods,supplier_type)
  244. VALUES
  245. (@Id,@PrBillNo,@PrMono,@EntityId,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
  246. @PrRqty,@PrAqty,@PrSqty,@IcitemId,@IcitemName,@PrSsendDate,@PrSarriveDate,@PrUnit,@State,@PrType,@CurrencyType,
  247. @CreateByName,@CreateTime,@UpdateByName,@UpdateTime,@TenantId,@FactoryId,@OrgId,0,@CompanyId,@IsRequireGoods,@SupplierType)
  248. """,
  249. new SugarParameter("@Id", pr.Id),
  250. new SugarParameter("@PrBillNo", pr.PrBillNo),
  251. new SugarParameter("@PrMono", pr.PrMono ?? (object)DBNull.Value),
  252. new SugarParameter("@EntityId", pr.EntityId ?? (object)DBNull.Value),
  253. new SugarParameter("@PrPurchaseId", pr.PrPurchaseId),
  254. new SugarParameter("@PrPurchaseNumber", pr.PrPurchaseNumber),
  255. new SugarParameter("@PrPurchaseName", pr.PrPurchaseName),
  256. new SugarParameter("@PrPurchaser", pr.PrPurchaser),
  257. new SugarParameter("@PrPurchaserNum", pr.PrPurchaserNum),
  258. new SugarParameter("@PrRqty", pr.PrRqty),
  259. new SugarParameter("@PrAqty", pr.PrAqty),
  260. new SugarParameter("@PrSqty", pr.PrSqty),
  261. new SugarParameter("@IcitemId", pr.IcitemId),
  262. new SugarParameter("@IcitemName", pr.IcitemName),
  263. new SugarParameter("@PrSsendDate", pr.PrSsendDate),
  264. new SugarParameter("@PrSarriveDate", pr.PrSarriveDate),
  265. new SugarParameter("@PrUnit", pr.PrUnit),
  266. new SugarParameter("@State", pr.State ?? 1),
  267. new SugarParameter("@PrType", pr.PrType ?? 3),
  268. new SugarParameter("@CurrencyType", pr.CurrencyType),
  269. new SugarParameter("@CreateByName", pr.CreateByName),
  270. new SugarParameter("@CreateTime", pr.CreateTime),
  271. new SugarParameter("@UpdateByName", pr.UpdateByName),
  272. new SugarParameter("@UpdateTime", pr.UpdateTime),
  273. new SugarParameter("@TenantId", pr.TenantId),
  274. new SugarParameter("@FactoryId", pr.FactoryId),
  275. new SugarParameter("@OrgId", pr.OrgId),
  276. new SugarParameter("@CompanyId", pr.CompanyId ?? 1000),
  277. new SugarParameter("@IsRequireGoods", pr.IsRequireGoods),
  278. new SugarParameter("@SupplierType", pr.SupplierType));
  279. }
  280. }
  281. private async Task InsertIndividualPurchaseRequestsAsync(List<PurchaseRequestMain> requests)
  282. {
  283. foreach (var pr in requests)
  284. {
  285. await _db.Ado.ExecuteCommandAsync(
  286. """
  287. INSERT INTO srm_pr_main
  288. (Id,pr_billno,pr_mono,entity_id,pr_purchaseid,pr_purchasenumber,pr_purchasename,pr_purchaser,pr_purchaser_num,
  289. pr_rqty,pr_aqty,pr_sqty,icitem_id,icitem_name,pr_ssend_date,pr_sarrive_date,pr_unit,state,pr_type,currencytype,
  290. create_by_name,create_time,update_by_name,update_time,tenant_id,factory_id,org_id,IsDeleted,company_id,
  291. IsRequireGoods,supplier_type,refer_pr_billno)
  292. VALUES
  293. (@Id,@PrBillNo,@PrMono,@EntityId,@PrPurchaseId,@PrPurchaseNumber,@PrPurchaseName,@PrPurchaser,@PrPurchaserNum,
  294. @PrRqty,@PrAqty,@PrSqty,@IcitemId,@IcitemName,@PrSsendDate,@PrSarriveDate,@PrUnit,@State,@PrType,@CurrencyType,
  295. @CreateByName,@CreateTime,@UpdateByName,@UpdateTime,@TenantId,@FactoryId,@OrgId,0,@CompanyId,
  296. @IsRequireGoods,@SupplierType,@ReferPrBillNo)
  297. """,
  298. new SugarParameter("@Id", pr.Id),
  299. new SugarParameter("@PrBillNo", pr.PrBillNo),
  300. new SugarParameter("@PrMono", pr.PrMono ?? (object)DBNull.Value),
  301. new SugarParameter("@EntityId", pr.EntityId ?? (object)DBNull.Value),
  302. new SugarParameter("@PrPurchaseId", pr.PrPurchaseId),
  303. new SugarParameter("@PrPurchaseNumber", pr.PrPurchaseNumber),
  304. new SugarParameter("@PrPurchaseName", pr.PrPurchaseName),
  305. new SugarParameter("@PrPurchaser", pr.PrPurchaser),
  306. new SugarParameter("@PrPurchaserNum", pr.PrPurchaserNum),
  307. new SugarParameter("@PrRqty", pr.PrRqty),
  308. new SugarParameter("@PrAqty", pr.PrAqty),
  309. new SugarParameter("@PrSqty", pr.PrSqty),
  310. new SugarParameter("@IcitemId", pr.IcitemId),
  311. new SugarParameter("@IcitemName", pr.IcitemName),
  312. new SugarParameter("@PrSsendDate", pr.PrSsendDate),
  313. new SugarParameter("@PrSarriveDate", pr.PrSarriveDate),
  314. new SugarParameter("@PrUnit", pr.PrUnit),
  315. new SugarParameter("@State", pr.State ?? 5),
  316. new SugarParameter("@PrType", pr.PrType ?? 3),
  317. new SugarParameter("@CurrencyType", pr.CurrencyType),
  318. new SugarParameter("@CreateByName", pr.CreateByName),
  319. new SugarParameter("@CreateTime", pr.CreateTime),
  320. new SugarParameter("@UpdateByName", pr.UpdateByName),
  321. new SugarParameter("@UpdateTime", pr.UpdateTime),
  322. new SugarParameter("@TenantId", pr.TenantId),
  323. new SugarParameter("@FactoryId", pr.FactoryId),
  324. new SugarParameter("@OrgId", pr.OrgId),
  325. new SugarParameter("@CompanyId", pr.CompanyId ?? 1000),
  326. new SugarParameter("@IsRequireGoods", pr.IsRequireGoods),
  327. new SugarParameter("@SupplierType", pr.SupplierType),
  328. new SugarParameter("@ReferPrBillNo", pr.IndividualPrReferBillNo ?? (object)DBNull.Value));
  329. }
  330. }
  331. private async Task PersistPrStateUpdatesAsync(IReadOnlyList<long> prIds, int state, string account)
  332. {
  333. if (prIds.Count == 0) return;
  334. foreach (var id in prIds.Distinct())
  335. {
  336. await _db.Ado.ExecuteCommandAsync(
  337. """
  338. UPDATE srm_pr_main
  339. SET state = @State, update_by_name = @User, update_time = @Now
  340. WHERE Id = @Id
  341. """,
  342. new SugarParameter("@State", state),
  343. new SugarParameter("@User", account),
  344. new SugarParameter("@Now", DateTime.Now),
  345. new SugarParameter("@Id", id));
  346. }
  347. }
  348. private long ResolveTenantId(string? domain)
  349. {
  350. if (!string.IsNullOrWhiteSpace(domain) && long.TryParse(domain.Trim(), out var tid) && tid > 0)
  351. return tid;
  352. return AidopTenantHelper.Resolve(App.HttpContext);
  353. }
  354. private async Task<List<string>> TryTriggerS4MdpRefreshAsync(ProcurementPipelineResult result)
  355. {
  356. var warnings = new List<string>();
  357. if (result.PrCreatedCount == 0 && result.PoCreatedCount == 0 && result.QadTrackingCount == 0)
  358. return warnings;
  359. try
  360. {
  361. var mdp = await _s4MdpSync.RunFullAsync(triggerType: "PROCUREMENT_PIPELINE");
  362. result.S4MdpRefreshed = true;
  363. result.S4MdpBatchId = mdp.BatchId;
  364. }
  365. catch (Exception ex)
  366. {
  367. warnings.Add($"S4 MDP/DWD 刷新未完成:{ex.Message}");
  368. }
  369. return warnings;
  370. }
  371. private static string BuildMessage(ProcurementPipelineResult r)
  372. {
  373. var parts = new List<string>();
  374. if (r.IndividualPrCount > 0) parts.Add($"独立 PR {r.IndividualPrCount}");
  375. if (r.PrCreatedCount > 0) parts.Add($"合并 PR {r.PrCreatedCount}");
  376. if (r.PrUpdatedCount > 0) parts.Add($"更新 PR {r.PrUpdatedCount}");
  377. if (r.PrDeletedCount > 0) parts.Add($"删除 PR {r.PrDeletedCount}");
  378. if (r.PoCreatedCount > 0) parts.Add($"转 DO/PO {r.PoCreatedCount}");
  379. if (r.QadTrackingCount > 0) parts.Add($"外部推送 {r.QadTrackingCount}");
  380. return parts.Count > 0 ? string.Join(",", parts) : "无待处理采购申请";
  381. }
  382. }
  383. public sealed class ProcurementPipelineResult
  384. {
  385. public int ShortageItemCount { get; set; }
  386. public int IndividualPrCount { get; set; }
  387. public int PrCreatedCount { get; set; }
  388. public int PrUpdatedCount { get; set; }
  389. public int PrDeletedCount { get; set; }
  390. public int PrMergeReducedCount { get; set; }
  391. public int HistoricalPrMergedGroups { get; set; }
  392. public int PendingPrCount { get; set; }
  393. public int PoCreatedCount { get; set; }
  394. public int PoTransferredPrCount { get; set; }
  395. public int PoOccupyRehangedCount { get; set; }
  396. public int QadTrackingCount { get; set; }
  397. public int QadTrackingSkippedCount { get; set; }
  398. public List<string> CreatedPoNumbers { get; set; } = new();
  399. public List<string> Warnings { get; set; } = new();
  400. public bool S4MdpRefreshed { get; set; }
  401. public string? S4MdpBatchId { get; set; }
  402. public S4ReadPathConsistencyResult? ReadPathConsistency { get; set; }
  403. public string Message { get; set; } = string.Empty;
  404. }