ProcessOutsourceOrderService.cs 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850
  1. using Admin.NET.Plugin.AiDOP.DataPlatform;
  2. namespace Admin.NET.Plugin.AiDOP.Supply;
  3. /// <summary>
  4. /// 工序外协订单服务
  5. ///
  6. /// 【租户安全边界】需认证访问(已移除类级 <c>[AllowAnonymous]</c>);
  7. /// 租户一律经 <c>AidopTenantScope.ResolveOrThrow</c> 从认证后 JWT 解析,
  8. /// 无有效租户即拒绝,不读前端 tenantId、无默认回退。
  9. /// </summary>
  10. [ApiDescriptionSettings(Order = 311, Description = "工序外协订单")]
  11. [Route("api/Supply")]
  12. [NonUnify]
  13. public class ProcessOutsourceOrderService : IDynamicApiController, ITransient
  14. {
  15. private const int PwPurOrdSerialWidth = 4;
  16. private readonly ISqlSugarClient _db;
  17. private readonly UserManager _userManager;
  18. private readonly MdpNeutralSourceGate _neutralGate;
  19. public ProcessOutsourceOrderService(ISqlSugarClient db, UserManager userManager, MdpNeutralSourceGate neutralGate)
  20. {
  21. _db = db;
  22. _userManager = userManager;
  23. _neutralGate = neutralGate;
  24. }
  25. private long ResolveTenantOrThrow() => Infrastructure.AidopTenantScope.ResolveOrThrow(_userManager);
  26. [DisplayName("工序外协订单列表")]
  27. [HttpGet("process-outsource-order/list")]
  28. public async Task<object> GetList([FromQuery] ProcessOutsourceOrderListInput input)
  29. {
  30. var page = input.Page <= 0 ? 1 : input.Page;
  31. var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
  32. var offset = (page - 1) * pageSize;
  33. var where = new List<string> { "p.Potype='PW'", "p.IsActive<=1", "IFNULL(p.`Usage`,'')='外协'" };
  34. var pars = new List<SugarParameter>();
  35. var tenantId = ResolveTenantOrThrow();
  36. where.Add("p.tenant_id = @TenantId");
  37. pars.Add(new SugarParameter("@TenantId", tenantId));
  38. if (!string.IsNullOrWhiteSpace(input.PurOrd))
  39. {
  40. where.Add("p.PurOrd LIKE @PurOrd");
  41. pars.Add(new SugarParameter("@PurOrd", $"%{input.PurOrd.Trim()}%"));
  42. }
  43. if (!string.IsNullOrWhiteSpace(input.WorkOrd))
  44. {
  45. where.Add("p.WorkOrd LIKE @WorkOrd");
  46. pars.Add(new SugarParameter("@WorkOrd", $"%{input.WorkOrd.Trim()}%"));
  47. }
  48. if (!string.IsNullOrWhiteSpace(input.Buyer))
  49. {
  50. where.Add("p.Buyer=@Buyer");
  51. pars.Add(new SugarParameter("@Buyer", input.Buyer.Trim()));
  52. }
  53. if (!string.IsNullOrWhiteSpace(input.Supp))
  54. {
  55. where.Add("p.Supp=@Supp");
  56. pars.Add(new SugarParameter("@Supp", input.Supp.Trim()));
  57. }
  58. var baseSql = $"""
  59. SELECT
  60. p.RecID AS Id,
  61. p.PurOrd AS PurOrd,
  62. p.WorkOrd AS WorkOrd,
  63. p.ERPWorkOrd AS ERPWorkOrd,
  64. CONCAT(TRIM(IFNULL(p.Supp,'')),' ',TRIM(IFNULL(s.SortName,''))) AS SuppName,
  65. p.Buyer AS BuyerCode,
  66. CONCAT(TRIM(IFNULL(p.Buyer,'')),' ',TRIM(IFNULL(e.Name,''))) AS Buyer,
  67. f.ItemNum AS ItemNum,
  68. i.Descr AS ItemName,
  69. CONCAT(TRIM(CAST(IFNULL(r.Op,0) AS CHAR(20))),' ',TRIM(IFNULL(r.Descr,''))) AS Op,
  70. p.OrdDate AS OrdDate,
  71. f.QtyOrded AS QtyOrded,
  72. f.CumQtyBO AS CumQtyBO,
  73. f.DueDate AS DueDate,
  74. n.`Date` AS FlDate,
  75. CASE
  76. WHEN IFNULL(LENGTH(p.Status),0)=0 OR p.Buyer IS NULL THEN 'R'
  77. ELSE p.Status
  78. END AS Status,
  79. p.Supp AS Supp,
  80. p.ReqBy AS ReqBy,
  81. p.Department AS Department,
  82. p.`Usage` AS `Usage`,
  83. p.Curr AS Curr,
  84. p.Remark AS Remark,
  85. (CASE WHEN EXISTS(SELECT 1 FROM PurOrdDetail pd WHERE pd.PurOrd=p.PurOrd AND IFNULL(pd.QtyReceived,0)>0 AND pd.tenant_id=@TenantId) THEN 1 ELSE 0 END) AS HasReceived
  86. FROM PurOrdMaster p
  87. LEFT JOIN SuppMaster s ON p.Supp=s.Supp AND s.tenant_id=@TenantId
  88. LEFT JOIN DepartmentMaster d ON p.Department=d.Department AND d.tenant_id=@TenantId
  89. LEFT JOIN EmployeeMaster e ON p.Buyer=e.Employee AND e.tenant_id=@TenantId
  90. LEFT JOIN PurOrdDetail f ON p.PurOrd=f.PurOrd AND f.tenant_id=@TenantId
  91. LEFT JOIN RoutingOpDetail r ON f.Op=r.Op AND f.ItemNum=r.RoutingCode AND r.tenant_id=@TenantId
  92. LEFT JOIN ItemMaster i ON f.ItemNum=i.ItemNum AND i.tenant_id=@TenantId
  93. LEFT JOIN PurOrdRctMaster o ON o.OrdNbr=p.PurOrd AND o.tenant_id=@TenantId
  94. LEFT JOIN NbrMaster n ON p.WorkOrd=n.WorkOrd AND n.Type='CA' AND n.tenant_id=@TenantId
  95. WHERE {string.Join(" AND ", where)}
  96. """;
  97. var outerWhere = string.Empty;
  98. if (!string.IsNullOrWhiteSpace(input.Status))
  99. {
  100. outerWhere = "WHERE t.Status=@Status";
  101. pars.Add(new SugarParameter("@Status", input.Status.Trim()));
  102. }
  103. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseSql}) t {outerWhere}", pars);
  104. var list = await _db.Ado.SqlQueryAsync<ProcessOutsourceOrderListRow>(
  105. $"SELECT * FROM ({baseSql}) t {outerWhere} ORDER BY {BuildOrderBy(input.SortField, input.SortOrder)} LIMIT {pageSize} OFFSET {offset}",
  106. pars);
  107. return new { total, page, pageSize, list };
  108. }
  109. [DisplayName("工序外协订单详情")]
  110. [HttpGet("process-outsource-order/{id:int}")]
  111. public async Task<object> GetDetail(int id)
  112. {
  113. var tenantWhere = " AND p.tenant_id=@TenantId";
  114. var masterPars = new[] { new SugarParameter("@Id", id), new SugarParameter("@TenantId", ResolveTenantOrThrow()) };
  115. var master = (await _db.Ado.SqlQueryAsync<ProcessOutsourceOrderMasterRow>(
  116. $"""
  117. SELECT
  118. p.RecID AS Id,p.PurOrd AS PurOrd,p.OrdDate AS OrdDate,p.Supp AS Supp,p.ReqBy AS ReqBy,p.WorkOrd AS WorkOrd,
  119. p.Buyer AS Buyer,p.Department AS Department,p.`Usage` AS `Usage`,p.Curr AS Curr,p.Remark AS Remark,
  120. CASE WHEN IFNULL(LENGTH(p.Status),0)=0 OR p.Buyer IS NULL THEN 'R' ELSE p.Status END AS Status
  121. FROM PurOrdMaster p
  122. WHERE p.RecID=@Id{tenantWhere}
  123. LIMIT 1
  124. """,
  125. masterPars)).FirstOrDefault();
  126. if (master == null) throw Oops.Oh("记录不存在");
  127. var details = await _db.Ado.SqlQueryAsync<ProcessOutsourceOrderDetailRow>(
  128. """
  129. SELECT
  130. d.RecID AS Id,
  131. d.Line AS Line,
  132. d.ItemNum AS ItemNum,
  133. IFNULL(i.Descr,'') AS ItemName,
  134. d.Op AS Op,
  135. d.UM AS UM,
  136. d.Location AS Location,
  137. d.QtyOrded AS QtyOrded,
  138. d.QtyReceived AS QtyReceived,
  139. d.ReceiptQty AS ReceiptQty,
  140. d.DueDate AS DueDate,
  141. d.LotSerial AS LotSerial,
  142. d.Potype AS Potype,
  143. d.PurOrd AS PurOrd,
  144. d.PurOrdRecID AS PurOrdRecID
  145. FROM PurOrdDetail d
  146. LEFT JOIN ItemMaster i ON d.ItemNum=i.ItemNum AND i.tenant_id=@TenantId
  147. WHERE d.PurOrdRecID=@PurOrdRecID AND d.tenant_id=@TenantId
  148. ORDER BY d.Line,d.RecID
  149. """,
  150. new SugarParameter("@PurOrdRecID", id),
  151. new SugarParameter("@TenantId", ResolveTenantOrThrow()));
  152. return new { master, details };
  153. }
  154. [DisplayName("按工单生成工序外协订单")]
  155. [HttpPost("process-outsource-order/create-by-workord")]
  156. public async Task<object> CreateByWorkOrd([FromBody] ProcessOutsourceOrderCreateInput input)
  157. {
  158. if (string.IsNullOrWhiteSpace(input.WorkOrd)) throw Oops.Oh("请选择工单");
  159. var workOrd = input.WorkOrd.Trim();
  160. var tenantId = ResolveTenantOrThrow();
  161. // 先筛出“当前工单且未生成采购单”的外协供应商清单(等价于存储过程内 @v_temp_supp)。
  162. var suppliers = await _db.Ado.SqlQueryAsync<SuppRow>(
  163. """
  164. SELECT DISTINCT d.SupplierCode AS Supp
  165. FROM WorkOrdMaster m
  166. LEFT JOIN WorkOrdRouting r ON m.WorkOrd=r.WorkOrd
  167. LEFT JOIN RoutingOpDetail d ON r.Op=d.Op AND r.ItemNum=d.RoutingCode
  168. LEFT JOIN PurOrdMaster p ON m.WorkOrd=p.WorkOrd AND d.SupplierCode=p.Supp AND p.tenant_id=@TenantId
  169. WHERE r.ProcessOut=1
  170. AND m.WorkOrd=@WorkOrd
  171. AND m.tenant_id=@TenantId
  172. AND p.RecID IS NULL
  173. AND IFNULL(TRIM(d.SupplierCode),'')<>''
  174. ORDER BY d.SupplierCode
  175. """,
  176. new SugarParameter("@WorkOrd", workOrd),
  177. new SugarParameter("@TenantId", tenantId));
  178. if (suppliers.Count == 0)
  179. throw Oops.Oh("当前工单无需生成外协采购订单。");
  180. var domain = await _db.Ado.GetStringAsync(
  181. """
  182. SELECT IFNULL(TRIM(Domain),'')
  183. FROM WorkOrdMaster
  184. WHERE WorkOrd=@WorkOrd AND tenant_id=@TenantId
  185. LIMIT 1
  186. """,
  187. new SugarParameter("@WorkOrd", workOrd),
  188. new SugarParameter("@TenantId", tenantId)) ?? string.Empty;
  189. var created = new List<IdPurOrdRow>();
  190. try
  191. {
  192. _db.Ado.BeginTran();
  193. var purOrdNumbers = await PurOrdNumberGenerator.NextBatchAsync(
  194. _db, "PW", DateTime.Today, PwPurOrdSerialWidth, suppliers.Count);
  195. for (var i = 0; i < suppliers.Count; i++)
  196. {
  197. var supp = suppliers[i].Supp!.Trim();
  198. var purOrd = purOrdNumbers[i].Trim();
  199. var now = DateTime.Now;
  200. await _db.Ado.ExecuteCommandAsync(
  201. """
  202. INSERT INTO PurOrdMaster
  203. (
  204. Confirming, CreditTermsInt, Disc, ExchRate, EstVal, ExchRate1, ExchRate2,
  205. FixedPrice, FixedRate, Frt, PartialOK, AmtPrepaid, PrintPO, PST, Recurr,
  206. `Release`, Revision, Scheduled, ServiceCharge, SpecialCharge, Taxable,
  207. Tax1, Tax2, Tax3, TransportDays, IsActive, IsConfirm, Potype, IsChanged,
  208. TaxIn, Amt, IsPriceChanged,
  209. Buyer, Domain, PurOrd, OrdDate, ReqBy, Status, Supp, CreateUser, CreateTime,
  210. Department, `Usage`, FSTID, ERPWorkOrd, WorkOrd, tenant_id
  211. )
  212. SELECT
  213. 0, 0, 0, 1, 0, 1, 1,
  214. 0, 0, 0, 1, 0, 0, 0, 0,
  215. 0, 0, 0, 0, 0, 1,
  216. 0, 0, 0, 0, 1, 1, 'PW', 0,
  217. 1, 0, 0,
  218. @Buyer,
  219. IFNULL(TRIM(m.Domain),''),
  220. @PurOrd,
  221. @Now,
  222. 'PO',
  223. '',
  224. @Supp,
  225. @CreateUser,
  226. @Now,
  227. m.Department,
  228. '外协',
  229. 1,
  230. m.Batch,
  231. m.WorkOrd,
  232. @TenantId
  233. FROM WorkOrdMaster m
  234. WHERE m.WorkOrd=@WorkOrd AND m.tenant_id=@TenantId
  235. LIMIT 1
  236. """,
  237. new SugarParameter("@Buyer", _userManager.Account),
  238. new SugarParameter("@PurOrd", purOrd),
  239. new SugarParameter("@Now", now),
  240. new SugarParameter("@Supp", supp),
  241. new SugarParameter("@CreateUser", _userManager.Account),
  242. new SugarParameter("@WorkOrd", workOrd),
  243. new SugarParameter("@TenantId", ResolveTenantOrThrow()));
  244. var masterId = await _db.Ado.GetIntAsync(
  245. "SELECT IFNULL(MAX(RecID),0) FROM PurOrdMaster WHERE PurOrd=@PurOrd AND tenant_id=@TenantId",
  246. new SugarParameter("@PurOrd", purOrd),
  247. new SugarParameter("@TenantId", tenantId));
  248. if (masterId <= 0)
  249. throw Oops.Oh("采购订单生成失败。");
  250. var lines = await _db.Ado.SqlQueryAsync<CreateLineRow>(
  251. """
  252. SELECT
  253. IFNULL(i.PMBOM,'') AS PMBOM,
  254. IFNULL(r.Op,0) AS Op,
  255. r.ItemNum AS ItemNum,
  256. IFNULL(i.UM,'') AS UM,
  257. IFNULL(m.QtyOrded,0) AS QtyOrded,
  258. IFNULL(i.Rev,'') AS Rev,
  259. IFNULL(m.Department,'') AS Department,
  260. m.WorkOrd AS WorkOrd,
  261. IFNULL(i.Drawing,'') AS Drawing,
  262. IFNULL(m.RecID,0) AS WorkOrdID,
  263. IFNULL(d.OutsourcedLeadTime,0) AS ProcessOutDay
  264. FROM WorkOrdMaster m
  265. LEFT JOIN WorkOrdRouting r ON m.WorkOrd=r.WorkOrd AND r.tenant_id=@TenantId
  266. LEFT JOIN RoutingOpDetail d ON r.Op=d.Op AND r.ItemNum=d.RoutingCode AND d.tenant_id=@TenantId
  267. LEFT JOIN ItemMaster i ON r.ItemNum=i.ItemNum AND i.tenant_id=@TenantId
  268. WHERE r.ProcessOut=1
  269. AND m.WorkOrd=@WorkOrd
  270. AND m.tenant_id=@TenantId
  271. AND d.SupplierCode=@Supp
  272. ORDER BY r.RecID
  273. """,
  274. new SugarParameter("@WorkOrd", workOrd),
  275. new SugarParameter("@Supp", supp),
  276. new SugarParameter("@TenantId", tenantId));
  277. for (var lineIndex = 0; lineIndex < lines.Count; lineIndex++)
  278. {
  279. var row = lines[lineIndex];
  280. await _db.Ado.ExecuteCommandAsync(
  281. """
  282. INSERT INTO PurOrdDetail
  283. (
  284. QtyBO, RctCost, CreditTermsInt, UpdateCurrentCost, CumReceived1, CumReceived2,
  285. CumReceived3, CumReceived4, Disc, FixedPrice, InspectReq, SingleLot, SupplyPer,
  286. PurOrd, PST, PackingSlipQty, PayUMConv, PurCost, RctQty, QtyOrded, QtyReceived,
  287. QtyReturned, Active, QtyReleased, RctUMConversion, Scheduled, ScheduledChanged,
  288. SchedMRPReq, SafetyDays, SafetyHours, StdCost, Taxable, TaxIn, MaxTaxableAmt,
  289. TransportHours, UMConversion, VAT, IsActive, IsConfirm, Potype, IsChanged,
  290. TaxRate, IsRounding, ReceiptQty, BarCodeQty, IsClosed, QtyReturnedRefund, CumQtyBO,
  291. ActiveRlseID1, Domain, DueDate, Line, Location, Op, ItemNum, Status, UM, WorkOrdID,
  292. CreateUser, CreateTime, `Usage`, Rev, Department, WorkOrd, Drawing, PurOrdRecID, tenant_id
  293. )
  294. VALUES
  295. (
  296. 0, 0, 0, 0, 0, 0,
  297. 0, 0, 0, 0, 0, 0, 0,
  298. @PurOrd, 0, 0, 1, 0, 0, @QtyOrded, 0,
  299. 0, 1, 0, 1, 0, 0,
  300. 0, 0, 0, 0, 1, 1, 0,
  301. 0, 1, 0, 1, 1, 'PW', 0,
  302. 0, 0, 0, 0, 0, 0, 0,
  303. @ActiveRlseID1, @Domain, DATE_ADD(@Now, INTERVAL @ProcessOutDay DAY), @Line, '5008', @Op, @ItemNum, '', @UM, @WorkOrdID,
  304. @CreateUser, @Now, '外协', @Rev, @Department, @WorkOrd, @Drawing, @PurOrdRecID, @TenantId
  305. )
  306. """,
  307. new SugarParameter("@ActiveRlseID1", row.PMBOM),
  308. new SugarParameter("@Domain", domain),
  309. new SugarParameter("@Now", now),
  310. new SugarParameter("@ProcessOutDay", row.ProcessOutDay),
  311. new SugarParameter("@Line", lineIndex + 1),
  312. new SugarParameter("@PurOrd", purOrd),
  313. new SugarParameter("@Op", row.Op),
  314. new SugarParameter("@ItemNum", row.ItemNum),
  315. new SugarParameter("@QtyOrded", row.QtyOrded),
  316. new SugarParameter("@UM", row.UM),
  317. new SugarParameter("@WorkOrdID", row.WorkOrdID),
  318. new SugarParameter("@CreateUser", _userManager.Account),
  319. new SugarParameter("@Rev", row.Rev),
  320. new SugarParameter("@Department", row.Department),
  321. new SugarParameter("@WorkOrd", row.WorkOrd),
  322. new SugarParameter("@Drawing", row.Drawing),
  323. new SugarParameter("@PurOrdRecID", masterId),
  324. new SugarParameter("@TenantId", tenantId));
  325. }
  326. await UpsertProcessOutsourceMdpAsync(purOrd, tenantId, now);
  327. created.Add(new IdPurOrdRow { Id = masterId, PurOrd = purOrd });
  328. }
  329. _db.Ado.CommitTran();
  330. }
  331. catch
  332. {
  333. _db.Ado.RollbackTran();
  334. throw Oops.Oh("采购订单生成失败。");
  335. }
  336. var latest = created.OrderByDescending(x => x.Id).FirstOrDefault();
  337. if (latest == null) throw Oops.Oh("采购订单生成失败。");
  338. return new { id = latest.Id, purOrd = latest.PurOrd, message = "采购订单生成成功。" };
  339. }
  340. private async Task UpsertProcessOutsourceMdpAsync(string purOrd, long tenantId, DateTime now)
  341. {
  342. if (!await _neutralGate.AllowsAsync(tenantId, "OUTSOURCE", "DOPDEMORQ_SQLSERVER"))
  343. return;
  344. var batchId = $"S3_OUTSOURCE_{now:yyyyMMddHHmmss}";
  345. // S8-RULE01-AUTHORITY-SINGLE-WRITER:已移除对 mdp_std_purchase_order 的直写。
  346. //
  347. // 标准层现在是「本轮批次的镜像」:S3 全量转换按 @BatchId 收窄并淘汰不属于本批次的行。
  348. // 本旁路写入的行带的是 S3_OUTSOURCE_* 批次号,下一轮全量必然把它淘汰掉,
  349. // 留着它只会让标准层短暂出现一批镜像不认识的行,且与「单一 Authority 写入方」相矛盾。
  350. //
  351. // 采购订单行的标准层由 S3 从源表统一派生;本服务只负责写源单据。
  352. await _db.Ado.ExecuteCommandAsync(
  353. """
  354. INSERT INTO mdp_std_process_outsource_order
  355. (tenant_id, source_system, work_order, op_code, routing_code, supplier_code, po_no, po_line, order_qty, completed_qty, due_date, status, source_biz_key, sync_batch_id, sync_time)
  356. SELECT
  357. NULLIF(m.tenant_id, 0),
  358. COALESCE((SELECT r.source_system FROM mdp_source_table_registry r WHERE r.source_table = 'PurOrdMaster' LIMIT 1), NULLIF('',''), ''),
  359. IFNULL(m.WorkOrd, ''),
  360. CAST(IFNULL(d.Op, 0) AS CHAR),
  361. IFNULL(d.ItemNum, ''),
  362. IFNULL(m.Supp, ''),
  363. IFNULL(m.PurOrd, ''),
  364. CAST(IFNULL(d.Line, 0) AS CHAR),
  365. IFNULL(d.QtyOrded, 0),
  366. IFNULL(d.QtyReceived, IFNULL(d.RctQty, 0)),
  367. d.DueDate,
  368. CASE WHEN IFNULL(LENGTH(m.Status), 0) = 0 THEN 'R' ELSE m.Status END,
  369. CONCAT(IFNULL(m.PurOrd,''), '|', IFNULL(d.Line, 0)),
  370. @BatchId,
  371. @Now
  372. FROM PurOrdMaster m
  373. JOIN PurOrdDetail d ON m.RecID = d.PurOrdRecID
  374. WHERE m.PurOrd = @PurOrd
  375. AND m.tenant_id = @TenantId
  376. AND m.Potype = 'PW'
  377. AND IFNULL(m.WorkOrd, '') <> ''
  378. AND IFNULL(d.ItemNum, '') <> ''
  379. AND NULLIF(m.tenant_id, 0) IS NOT NULL
  380. ON DUPLICATE KEY UPDATE
  381. supplier_code=VALUES(supplier_code),
  382. po_no=VALUES(po_no),
  383. po_line=VALUES(po_line),
  384. order_qty=VALUES(order_qty),
  385. completed_qty=VALUES(completed_qty),
  386. due_date=VALUES(due_date),
  387. status=VALUES(status),
  388. sync_batch_id=VALUES(sync_batch_id),
  389. sync_time=VALUES(sync_time),
  390. update_time=CURRENT_TIMESTAMP
  391. """,
  392. new SugarParameter("@PurOrd", purOrd),
  393. new SugarParameter("@TenantId", tenantId),
  394. new SugarParameter("@BatchId", batchId),
  395. new SugarParameter("@Now", now));
  396. // S8-RULE01-AUTHORITY-SINGLE-WRITER:已移除对 dwd_supplier_delivery 的直写。
  397. //
  398. // 该表是 Rule01 的 Authority,必须只有一个运行期写入方(S3 的 MDP 转换)。
  399. // 这里原本直读 PurOrdMaster/PurOrdDetail/ItemMaster/SuppMaster 后直接 upsert DWD,
  400. // 且把 delivery_status 硬编码为 'OPEN' 并放进 ON DUPLICATE KEY UPDATE ——
  401. // 于是一次建单调用就能把 MDP 刚判定的 COMPLETED / DELAYED 无条件翻回 OPEN。
  402. //
  403. // 更严重的是它能凭空造出一个「最新快照」:Rule01 取 MAX(stat_date) 那一天的全部行,
  404. // 实测租户 797403760988229 的 2026-05-16 快照只有 2 行、且全部出自本旁路 ——
  405. // MDP 停摆时,一次业务 API 调用就让 Rule01 在 2 行残缺数据上评估,而快照看起来还很新鲜。
  406. //
  407. // 业务侧只负责写源单据;DWD 由 S3 全量转换派生,最长 60 分钟可见延迟。
  408. // 若将来需要更快可见,用 ModuleRebuildService.EnqueueAsync("S3", ...) 在**事务提交之后**
  409. // 触发一次正式重建,而不是重新引入第二个写入方。
  410. }
  411. [DisplayName("保存工序外协订单")]
  412. [HttpPost("process-outsource-order/save")]
  413. public async Task<object> Save([FromBody] ProcessOutsourceOrderSaveInput input)
  414. {
  415. if (input.Id <= 0) throw Oops.Oh("缺少主键");
  416. if (string.IsNullOrWhiteSpace(input.PurOrd)) throw Oops.Oh("采购单号不能为空");
  417. if (string.IsNullOrWhiteSpace(input.Supp)) throw Oops.Oh("供应商不能为空");
  418. if (string.IsNullOrWhiteSpace(input.Department)) throw Oops.Oh("部门不能为空");
  419. var exists = await _db.Ado.GetIntAsync("SELECT COUNT(1) FROM PurOrdMaster WHERE RecID=@Id AND tenant_id=@TenantId",
  420. new[] { new SugarParameter("@Id", input.Id), new SugarParameter("@TenantId", ResolveTenantOrThrow()) });
  421. if (exists <= 0) throw Oops.Oh("记录不存在");
  422. try
  423. {
  424. _db.Ado.BeginTran();
  425. await _db.Ado.ExecuteCommandAsync(
  426. """
  427. UPDATE PurOrdMaster
  428. SET OrdDate=@OrdDate,Supp=@Supp,ReqBy=@ReqBy,WorkOrd=@WorkOrd,Buyer=@Buyer,Department=@Department,`Usage`=@Usage,Curr=@Curr,Remark=@Remark,UpdateUser=@UpdateUser,UpdateTime=@UpdateTime
  429. WHERE RecID=@Id AND tenant_id=@TenantId
  430. """,
  431. new SugarParameter("@Id", input.Id),
  432. new SugarParameter("@OrdDate", ParseDate(input.OrdDate)),
  433. new SugarParameter("@Supp", input.Supp!.Trim()),
  434. new SugarParameter("@ReqBy", string.IsNullOrWhiteSpace(input.ReqBy) ? "PO" : input.ReqBy.Trim()),
  435. new SugarParameter("@WorkOrd", input.WorkOrd?.Trim()),
  436. new SugarParameter("@Buyer", string.IsNullOrWhiteSpace(input.Buyer) ? "110" : input.Buyer.Trim()),
  437. new SugarParameter("@Department", input.Department!.Trim()),
  438. new SugarParameter("@Usage", string.IsNullOrWhiteSpace(input.Usage) ? "外协" : input.Usage.Trim()),
  439. new SugarParameter("@Curr", input.Curr?.Trim()),
  440. new SugarParameter("@Remark", input.Remark?.Trim()),
  441. new SugarParameter("@UpdateUser", _userManager.Account),
  442. new SugarParameter("@UpdateTime", DateTime.Now),
  443. new SugarParameter("@TenantId", ResolveTenantOrThrow())
  444. );
  445. await SaveDetailsAsync(input.Id, input.PurOrd!.Trim(), input.WorkOrd, input.Details);
  446. _db.Ado.CommitTran();
  447. }
  448. catch
  449. {
  450. _db.Ado.RollbackTran();
  451. throw;
  452. }
  453. return new { id = input.Id, message = "保存成功" };
  454. }
  455. /// <summary>
  456. /// 明细三路合并:
  457. /// ① DB有且入参有(按 Id 匹配)→ 更新
  458. /// ② DB无但入参有 → 新增
  459. /// ③ DB有但入参无 → 删除
  460. /// </summary>
  461. private async Task SaveDetailsAsync(int masterId, string purOrd, string? workOrd, List<ProcessOutsourceOrderDetailInput> details)
  462. {
  463. var tenantId = ResolveTenantOrThrow();
  464. var dbDetails = await _db.Ado.SqlQueryAsync<DbDetailRow>(
  465. "SELECT RecID AS Id FROM PurOrdDetail WHERE PurOrdRecID=@PurOrdRecID AND tenant_id=@TenantId",
  466. new SugarParameter("@PurOrdRecID", masterId),
  467. new SugarParameter("@TenantId", tenantId));
  468. var dbById = dbDetails.ToDictionary(x => x.Id);
  469. var inputIds = new HashSet<int>(details.Where(x => x.Id.HasValue && x.Id.Value > 0).Select(x => x.Id!.Value));
  470. var maxLine = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(Line),0) FROM PurOrdDetail WHERE PurOrdRecID=@PurOrdRecID AND tenant_id=@TenantId", new SugarParameter("@PurOrdRecID", masterId), new SugarParameter("@TenantId", tenantId));
  471. var lineSeed = maxLine;
  472. for (var i = 0; i < details.Count; i++)
  473. {
  474. var item = details[i];
  475. if (string.IsNullOrWhiteSpace(item.ItemNum)) continue;
  476. var line = item.Line ?? (i + 1);
  477. if (line <= 0)
  478. {
  479. lineSeed++;
  480. line = lineSeed;
  481. }
  482. var itemInfo = (await _db.Ado.SqlQueryAsync<ItemLookupRow>(
  483. """
  484. SELECT ItemNum,Descr,UM,Location
  485. FROM ItemMaster
  486. WHERE ItemNum=@ItemNum
  487. LIMIT 1
  488. """,
  489. new SugarParameter("@ItemNum", item.ItemNum.Trim()))).FirstOrDefault();
  490. if (item.Id.HasValue && item.Id.Value > 0 && dbById.ContainsKey(item.Id.Value))
  491. {
  492. await _db.Ado.ExecuteCommandAsync(
  493. """
  494. UPDATE PurOrdDetail
  495. SET Line=@Line,ItemNum=@ItemNum,Descr=@Descr,Op=@Op,UM=@UM,Location=@Location,QtyOrded=@QtyOrded,QtyReceived=@QtyReceived,ReceiptQty=@ReceiptQty,DueDate=@DueDate,LotSerial=@LotSerial,Potype='PW',PurOrd=@PurOrd,PurOrdRecID=@PurOrdRecID,WorkOrd=@WorkOrd,UpdateUser=@UpdateUser,UpdateTime=@UpdateTime
  496. WHERE RecID=@Id AND tenant_id=@TenantId
  497. """,
  498. new SugarParameter("@Id", item.Id.Value),
  499. new SugarParameter("@Line", line),
  500. new SugarParameter("@ItemNum", item.ItemNum.Trim()),
  501. new SugarParameter("@Descr", itemInfo?.Descr),
  502. new SugarParameter("@Op", item.Op ?? 0),
  503. new SugarParameter("@UM", string.IsNullOrWhiteSpace(item.UM) ? itemInfo?.UM : item.UM!.Trim()),
  504. new SugarParameter("@Location", string.IsNullOrWhiteSpace(item.Location) ? itemInfo?.Location : item.Location!.Trim()),
  505. new SugarParameter("@QtyOrded", item.QtyOrded ?? 0),
  506. new SugarParameter("@QtyReceived", item.QtyReceived ?? 0),
  507. new SugarParameter("@ReceiptQty", item.ReceiptQty ?? 0),
  508. new SugarParameter("@DueDate", ParseDate(item.DueDate)),
  509. new SugarParameter("@LotSerial", item.LotSerial?.Trim()),
  510. new SugarParameter("@PurOrd", purOrd),
  511. new SugarParameter("@PurOrdRecID", masterId),
  512. new SugarParameter("@WorkOrd", workOrd?.Trim()),
  513. new SugarParameter("@UpdateUser", _userManager.Account),
  514. new SugarParameter("@UpdateTime", DateTime.Now),
  515. new SugarParameter("@TenantId", tenantId)
  516. );
  517. }
  518. else
  519. {
  520. await _db.Ado.ExecuteCommandAsync(
  521. """
  522. INSERT INTO PurOrdDetail
  523. (PurOrd,Line,ItemNum,Descr,Op,UM,Location,QtyOrded,QtyReceived,ReceiptQty,DueDate,LotSerial,Potype,PurOrdRecID,WorkOrd,Status,CreateUser,CreateTime,UpdateUser,UpdateTime,tenant_id)
  524. VALUES
  525. (@PurOrd,@Line,@ItemNum,@Descr,@Op,@UM,@Location,@QtyOrded,@QtyReceived,@ReceiptQty,@DueDate,@LotSerial,'PW',@PurOrdRecID,@WorkOrd,'R',@CreateUser,@CreateTime,@UpdateUser,@UpdateTime,@TenantId)
  526. """,
  527. new SugarParameter("@PurOrd", purOrd),
  528. new SugarParameter("@Line", line),
  529. new SugarParameter("@ItemNum", item.ItemNum.Trim()),
  530. new SugarParameter("@Descr", itemInfo?.Descr),
  531. new SugarParameter("@Op", item.Op ?? 0),
  532. new SugarParameter("@UM", string.IsNullOrWhiteSpace(item.UM) ? itemInfo?.UM : item.UM!.Trim()),
  533. new SugarParameter("@Location", string.IsNullOrWhiteSpace(item.Location) ? itemInfo?.Location : item.Location!.Trim()),
  534. new SugarParameter("@QtyOrded", item.QtyOrded ?? 0),
  535. new SugarParameter("@QtyReceived", item.QtyReceived ?? 0),
  536. new SugarParameter("@ReceiptQty", item.ReceiptQty ?? 0),
  537. new SugarParameter("@DueDate", ParseDate(item.DueDate)),
  538. new SugarParameter("@LotSerial", item.LotSerial?.Trim()),
  539. new SugarParameter("@PurOrdRecID", masterId),
  540. new SugarParameter("@WorkOrd", workOrd?.Trim()),
  541. new SugarParameter("@CreateUser", _userManager.Account),
  542. new SugarParameter("@CreateTime", DateTime.Now),
  543. new SugarParameter("@UpdateUser", _userManager.Account),
  544. new SugarParameter("@UpdateTime", DateTime.Now),
  545. new SugarParameter("@TenantId", ResolveTenantOrThrow())
  546. );
  547. }
  548. }
  549. foreach (var toDelete in dbDetails.Where(x => !inputIds.Contains(x.Id)))
  550. {
  551. await _db.Ado.ExecuteCommandAsync("DELETE FROM PurOrdDetail WHERE RecID=@Id AND tenant_id=@TenantId", new SugarParameter("@Id", toDelete.Id), new SugarParameter("@TenantId", tenantId));
  552. }
  553. }
  554. [DisplayName("删除工序外协订单")]
  555. [HttpPost("process-outsource-order/delete/{id:int}")]
  556. public async Task<object> Delete(int id, [FromQuery] string purOrd)
  557. {
  558. if (string.IsNullOrWhiteSpace(purOrd)) throw Oops.Oh("缺少采购单号");
  559. var ordNo = purOrd.Trim();
  560. var tenantId = ResolveTenantOrThrow();
  561. var hasReceipt = await _db.Ado.GetIntAsync(
  562. "SELECT COUNT(1) FROM PurOrdRctDetail WHERE OrdNbr=@PurOrd AND IFNULL(QtyReceived,0)>0 AND tenant_id=@TenantId",
  563. new[] { new SugarParameter("@PurOrd", ordNo), new SugarParameter("@TenantId", tenantId) });
  564. if (hasReceipt > 0) throw Oops.Oh("存在收货数量,不允许删除");
  565. var hasShip = await _db.Ado.GetIntAsync(
  566. "SELECT COUNT(1) FROM scm_shdzb WHERE po_bill=@PurOrd AND tenant_id=@TenantId",
  567. new[] { new SugarParameter("@PurOrd", ordNo), new SugarParameter("@TenantId", tenantId) });
  568. if (hasShip > 0) throw Oops.Oh("存在发货单,不允许删除");
  569. try
  570. {
  571. _db.Ado.BeginTran();
  572. await _db.Ado.ExecuteCommandAsync("DELETE FROM PurOrdDetail WHERE PurOrd=@PurOrd AND tenant_id=@TenantId",
  573. new SugarParameter("@PurOrd", ordNo), new SugarParameter("@TenantId", tenantId));
  574. await _db.Ado.ExecuteCommandAsync("DELETE FROM PurOrdMaster WHERE RecID=@Id AND PurOrd=@PurOrd AND tenant_id=@TenantId",
  575. new SugarParameter("@Id", id), new SugarParameter("@PurOrd", ordNo), new SugarParameter("@TenantId", tenantId));
  576. _db.Ado.CommitTran();
  577. }
  578. catch
  579. {
  580. _db.Ado.RollbackTran();
  581. throw;
  582. }
  583. return new { message = "删除成功" };
  584. }
  585. [DisplayName("采购组下拉")]
  586. [HttpGet("process-outsource-order/options/buyers")]
  587. public async Task<object> GetBuyerOptions()
  588. {
  589. var list = await _db.Ado.SqlQueryAsync<OptionRow>(
  590. """
  591. SELECT Employee AS Value, CONCAT(TRIM(Employee),' ',TRIM(IFNULL(Name,''))) AS Label
  592. FROM EmployeeMaster
  593. ORDER BY Employee
  594. """);
  595. return new { list };
  596. }
  597. [DisplayName("供应商下拉")]
  598. [HttpGet("process-outsource-order/options/suppliers")]
  599. public async Task<object> GetSupplierOptions()
  600. {
  601. var list = await _db.Ado.SqlQueryAsync<OptionRow>(
  602. """
  603. SELECT Supp AS Value, CONCAT(TRIM(Supp),' ',TRIM(IFNULL(SortName,''))) AS Label
  604. FROM SuppMaster
  605. ORDER BY Supp
  606. """);
  607. return new { list };
  608. }
  609. [DisplayName("部门下拉")]
  610. [HttpGet("process-outsource-order/options/departments")]
  611. public async Task<object> GetDepartmentOptions()
  612. {
  613. var list = await _db.Ado.SqlQueryAsync<OptionRow>(
  614. """
  615. SELECT Department AS Value, CONCAT(TRIM(Department),' ',TRIM(IFNULL(Descr,''))) AS Label
  616. FROM DepartmentMaster
  617. ORDER BY Department
  618. """);
  619. return new { list };
  620. }
  621. [DisplayName("币别下拉")]
  622. [HttpGet("process-outsource-order/options/currencies")]
  623. public async Task<object> GetCurrencyOptions()
  624. {
  625. var list = await _db.Ado.SqlQueryAsync<OptionRow>(
  626. """
  627. SELECT Val AS Value, CONCAT(TRIM(Val),' ',TRIM(IFNULL(Comments,''))) AS Label
  628. FROM GeneralizedCodeMaster
  629. WHERE FldName='Curr'
  630. ORDER BY Val
  631. """);
  632. return new { list };
  633. }
  634. [DisplayName("库位下拉")]
  635. [HttpGet("process-outsource-order/options/locations")]
  636. public async Task<object> GetLocationOptions()
  637. {
  638. var list = await _db.Ado.SqlQueryAsync<OptionRow>(
  639. """
  640. SELECT Location AS Value, CONCAT(TRIM(Location),' ',TRIM(IFNULL(Descr,''))) AS Label
  641. FROM LocationMaster
  642. WHERE IFNULL(Typed,'')<>'Supp'
  643. ORDER BY Location
  644. """);
  645. return new { list };
  646. }
  647. [DisplayName("工序下拉")]
  648. [HttpGet("process-outsource-order/options/ops")]
  649. public async Task<object> GetOpOptions([FromQuery] int purOrdRecId)
  650. {
  651. if (purOrdRecId <= 0) return new { list = new List<OptionRow>() };
  652. var list = await _db.Ado.SqlQueryAsync<OptionRow>(
  653. """
  654. SELECT DISTINCT CAST(Op AS CHAR(50)) AS Value, CONCAT(TRIM(CAST(Op AS CHAR(50))),' ',TRIM(IFNULL(Descr,''))) AS Label
  655. FROM RoutingOpDetail
  656. WHERE UDeci5!=0
  657. AND tenant_id=@TenantId
  658. AND RoutingCode IN (SELECT ItemNum FROM PurOrdDetail WHERE PurOrdRecID=@PurOrdRecID AND tenant_id=@TenantId)
  659. ORDER BY Value
  660. """,
  661. new SugarParameter("@PurOrdRecID", purOrdRecId),
  662. new SugarParameter("@TenantId", ResolveTenantOrThrow()));
  663. return new { list };
  664. }
  665. private static DateTime? ParseDate(string? v) => DateTime.TryParse(v, out var d) ? d : null;
  666. private static string BuildOrderBy(string? sortField, string? sortOrder)
  667. {
  668. var dir = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? "ASC" : "DESC";
  669. return sortField?.ToLowerInvariant() switch
  670. {
  671. "purord" => $"t.PurOrd {dir}",
  672. "workord" => $"t.WorkOrd {dir}",
  673. "suppname" => $"t.SuppName {dir}",
  674. "buyer" => $"t.Buyer {dir}",
  675. "itemnum" => $"t.ItemNum {dir}",
  676. "itemname" => $"t.ItemName {dir}",
  677. "op" => $"t.Op {dir}",
  678. "orddate" => $"t.OrdDate {dir}",
  679. "qtyorded" => $"t.QtyOrded {dir}",
  680. "cumqtybo" => $"t.CumQtyBO {dir}",
  681. "duedate" => $"t.DueDate {dir}",
  682. "fldate" => $"t.FlDate {dir}",
  683. "status" => $"t.Status {dir}",
  684. _ => "t.Id DESC"
  685. };
  686. }
  687. private sealed class ProcessOutsourceOrderListRow
  688. {
  689. public int Id { get; set; }
  690. public string? PurOrd { get; set; }
  691. public string? WorkOrd { get; set; }
  692. public string? ERPWorkOrd { get; set; }
  693. public string? SuppName { get; set; }
  694. public string? Buyer { get; set; }
  695. public string? BuyerCode { get; set; }
  696. public string? ItemNum { get; set; }
  697. public string? ItemName { get; set; }
  698. public string? Op { get; set; }
  699. public DateTime? OrdDate { get; set; }
  700. public decimal? QtyOrded { get; set; }
  701. public decimal? CumQtyBO { get; set; }
  702. public DateTime? DueDate { get; set; }
  703. public DateTime? FlDate { get; set; }
  704. public string? Status { get; set; }
  705. public string? Supp { get; set; }
  706. public string? ReqBy { get; set; }
  707. public string? Department { get; set; }
  708. public string? Usage { get; set; }
  709. public string? Curr { get; set; }
  710. public string? Remark { get; set; }
  711. public int HasReceived { get; set; }
  712. }
  713. private sealed class ProcessOutsourceOrderMasterRow
  714. {
  715. public int Id { get; set; }
  716. public string? PurOrd { get; set; }
  717. public DateTime? OrdDate { get; set; }
  718. public string? Supp { get; set; }
  719. public string? ReqBy { get; set; }
  720. public string? WorkOrd { get; set; }
  721. public string? Buyer { get; set; }
  722. public string? Department { get; set; }
  723. public string? Usage { get; set; }
  724. public string? Curr { get; set; }
  725. public string? Remark { get; set; }
  726. public string? Status { get; set; }
  727. }
  728. private sealed class ProcessOutsourceOrderDetailRow
  729. {
  730. public int Id { get; set; }
  731. public int? Line { get; set; }
  732. public string? ItemNum { get; set; }
  733. public string? ItemName { get; set; }
  734. public int? Op { get; set; }
  735. public string? UM { get; set; }
  736. public string? Location { get; set; }
  737. public decimal? QtyOrded { get; set; }
  738. public decimal? QtyReceived { get; set; }
  739. public decimal? ReceiptQty { get; set; }
  740. public DateTime? DueDate { get; set; }
  741. public string? LotSerial { get; set; }
  742. public string? Potype { get; set; }
  743. public string? PurOrd { get; set; }
  744. public int? PurOrdRecID { get; set; }
  745. }
  746. private sealed class OptionRow
  747. {
  748. public string? Value { get; set; }
  749. public string? Label { get; set; }
  750. }
  751. private sealed class IdPurOrdRow
  752. {
  753. public int Id { get; set; }
  754. public string? PurOrd { get; set; }
  755. }
  756. private sealed class SuppRow
  757. {
  758. public string? Supp { get; set; }
  759. }
  760. private sealed class CreateLineRow
  761. {
  762. public string? PMBOM { get; set; }
  763. public int Op { get; set; }
  764. public string? ItemNum { get; set; }
  765. public string? UM { get; set; }
  766. public decimal QtyOrded { get; set; }
  767. public string? Rev { get; set; }
  768. public string? Department { get; set; }
  769. public string? WorkOrd { get; set; }
  770. public string? Drawing { get; set; }
  771. public long WorkOrdID { get; set; }
  772. public int ProcessOutDay { get; set; }
  773. }
  774. private sealed class DbDetailRow
  775. {
  776. public int Id { get; set; }
  777. }
  778. private sealed class ItemLookupRow
  779. {
  780. public string? ItemNum { get; set; }
  781. public string? Descr { get; set; }
  782. public string? UM { get; set; }
  783. public string? Location { get; set; }
  784. }
  785. }