PurchaseReturnService.cs 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724
  1. using Admin.NET.Plugin.AiDOP.ProcurementExecution.Dto;
  2. using Admin.NET.Plugin.AiDOP.ProcurementExecution.Entity;
  3. using Admin.NET.Plugin.AiDOP.Supply;
  4. namespace Admin.NET.Plugin.AiDOP.ProcurementExecution;
  5. /// <summary>
  6. /// S4 采购退货单(PurOrdRctMaster / PurOrdRctDetail,RctType=pt)
  7. /// 路由:/api/ProcurementExecution/purchase-return/...
  8. ///
  9. /// 【租户安全边界】租户一律经 <see cref="AidopTenantScope.ResolveOrThrow"/> 从认证后 JWT 解析,
  10. /// 无有效租户即拒绝,不读前端 tenantId、无默认回退。
  11. /// <para>
  12. /// 原实现:类级 <c>[AllowAnonymous]</c> + 全类 <c>AidopTenantHelper.Resolve(App.HttpContext)</c>。
  13. /// 后者的解析顺序为 requestTenantId &gt; JWT claim &gt; <b>query 参数 tenantId</b> &gt; <b>DefaultTenantId 兜底</b>,
  14. /// 其自身注释即写明「允许前端指定租户且有默认回退,不得用于业务数据的查询与写入」。
  15. /// 两者叠加的后果:匿名(无 Token、无 Cookie)<c>?tenantId=&lt;任意租户&gt;</c> 即可读写该租户的采购退货单
  16. /// (含 purchase-return/save、purchase-return/delete 写端点)。
  17. /// </para>
  18. /// </summary>
  19. [ApiDescriptionSettings(Order = 321, Description = "S4采购退货单")]
  20. [Route("api/ProcurementExecution")]
  21. [NonUnify]
  22. public class PurchaseReturnService : IDynamicApiController, ITransient
  23. {
  24. /// <summary>采购退货单号:PT + yyyyMMdd + 4 位流水,如 PT202605190001。</summary>
  25. private const int PtReceiverSerialWidth = 4;
  26. private readonly ISqlSugarClient _db;
  27. private readonly SqlSugarRepository<PurOrdRctMaster> _masterRep;
  28. private readonly SqlSugarRepository<PurOrdRctDetail> _detailRep;
  29. private readonly UserManager _userManager;
  30. public PurchaseReturnService(
  31. ISqlSugarClient db,
  32. SqlSugarRepository<PurOrdRctMaster> masterRep,
  33. SqlSugarRepository<PurOrdRctDetail> detailRep,
  34. UserManager userManager)
  35. {
  36. _db = db;
  37. _masterRep = masterRep;
  38. _detailRep = detailRep;
  39. _userManager = userManager;
  40. }
  41. private static string NormalizeOrderBy(string field, string order)
  42. {
  43. var col = (field ?? "").Trim();
  44. var ord = string.Equals(order?.Trim(), "desc", StringComparison.OrdinalIgnoreCase) ? "DESC" : "ASC";
  45. var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
  46. {
  47. // 注意:列表 SQL 外层包了 SELECT * FROM (...) t,因此 ORDER BY 只能引用外层可见列名
  48. ["receiver"] = "Receiver",
  49. ["rctdate"] = "RctDate",
  50. ["ordnbr"] = "OrdNbr",
  51. ["itemnum"] = "ItemNum",
  52. ["rctqty"] = "RctQty",
  53. ["suppname"] = "SuppName",
  54. };
  55. if (!map.TryGetValue(col, out var sqlCol))
  56. return "ORDER BY RctDate DESC, DetailRecId ASC";
  57. return $"ORDER BY {sqlCol} {ord}, DetailRecId ASC";
  58. }
  59. [DisplayName("采购退货单分页列表")]
  60. [HttpGet("purchase-return/list")]
  61. public async Task<object> GetList([FromQuery] PurchaseReturnListInput input)
  62. {
  63. var page = input.Page <= 0 ? 1 : input.Page;
  64. var pageSize = input.PageSize <= 0 ? 10 : input.PageSize;
  65. var offset = (page - 1) * pageSize;
  66. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  67. var cond = new List<string> { "p.RctType='pt'", "p.IsActive=1", "p.tenant_id = @TenantId" };
  68. var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
  69. if (!string.IsNullOrWhiteSpace(input.Receiver))
  70. {
  71. cond.Add("p.Receiver LIKE @Receiver");
  72. pars.Add(new SugarParameter("@Receiver", $"%{input.Receiver.Trim()}%"));
  73. }
  74. if (!string.IsNullOrWhiteSpace(input.Supp))
  75. {
  76. cond.Add("p.Supp=@Supp");
  77. pars.Add(new SugarParameter("@Supp", input.Supp.Trim()));
  78. }
  79. if (!string.IsNullOrWhiteSpace(input.OrdNbr))
  80. {
  81. cond.Add("p.OrdNbr LIKE @OrdNbr");
  82. pars.Add(new SugarParameter("@OrdNbr", $"%{input.OrdNbr.Trim()}%"));
  83. }
  84. if (!string.IsNullOrWhiteSpace(input.ItemNum))
  85. {
  86. cond.Add("d1.ItemNum LIKE @ItemNum");
  87. pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
  88. }
  89. var where = string.Join(" AND ", cond);
  90. var baseSql = $"""
  91. SELECT
  92. p.RecID AS RecID,
  93. IFNULL(d1.RecID, 0) AS DetailRecId,
  94. p.RctType AS RctType,
  95. p.Receiver AS Receiver,
  96. p.RctDate AS RctDate,
  97. p.OrdNbr AS OrdNbr,
  98. p.TaxClass AS TaxClass,
  99. IFNULL(p.TaxIn, 0) AS TaxIn,
  100. p.Curr AS Curr,
  101. p.Terms AS Terms,
  102. IFNULL(p.IsPlan, 0) AS IsPlan,
  103. TRIM(CONCAT(TRIM(IFNULL(p.Department,'')),' ',TRIM(IFNULL(d.Descr,'')))) AS DepartmentDescr,
  104. TRIM(CONCAT(TRIM(IFNULL(p.Supp,'')),' ',TRIM(IFNULL(s.SortName,'')))) AS SuppName,
  105. d1.ItemNum AS ItemNum,
  106. IFNULL(i.Descr,'') AS Descr,
  107. IFNULL(i.Descr1,'') AS Descr1,
  108. IFNULL(d1.RctQty, 0) AS RctQty
  109. FROM PurOrdRctMaster p
  110. LEFT JOIN PurOrdRctDetail d1 ON p.RecID = d1.PurOrdRctRecID AND d1.RctType='pt' AND d1.IsActive=1
  111. LEFT JOIN SuppMaster s ON p.Supp = s.Supp
  112. LEFT JOIN DepartmentMaster d ON p.Department = d.Department
  113. LEFT JOIN ItemMaster i ON d1.ItemNum = i.ItemNum AND (p.tenant_id = i.tenant_id OR p.tenant_id IS NULL)
  114. WHERE {where}
  115. """;
  116. var orderBy = NormalizeOrderBy(input.SortField, input.SortOrder);
  117. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ({baseSql}) t", pars);
  118. var list = await _db.Ado.SqlQueryAsync<PurchaseReturnListRow>(
  119. $"SELECT * FROM ({baseSql}) t {orderBy} LIMIT {pageSize} OFFSET {offset}", pars);
  120. return new { total, page, pageSize, list };
  121. }
  122. [DisplayName("采购退货供应商下拉")]
  123. [HttpGet("purchase-return/options/suppliers")]
  124. public async Task<object> GetSuppliers()
  125. {
  126. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  127. var rows = await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
  128. """
  129. SELECT Supp AS `Value`,
  130. TRIM(CONCAT(TRIM(IFNULL(Supp,'')),' ',TRIM(IFNULL(SortName,'')))) AS `Label`
  131. FROM SuppMaster
  132. WHERE tenant_id = @TenantId
  133. ORDER BY Supp
  134. """,
  135. new SugarParameter("@TenantId", tenantId));
  136. return rows;
  137. }
  138. [DisplayName("收货单号下拉(rc)")]
  139. [HttpGet("purchase-return/options/rc-receivers")]
  140. public async Task<object> GetRcReceivers([FromQuery] string supp)
  141. {
  142. if (string.IsNullOrWhiteSpace(supp))
  143. return Array.Empty<object>();
  144. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  145. var rows = await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
  146. """
  147. SELECT p.Receiver AS `Value`,
  148. TRIM(CONCAT(TRIM(IFNULL(p.Receiver,'')),' ',
  149. TRIM(IFNULL(d.LotSerial,'')),' ',
  150. TRIM(IFNULL(d.ItemNum,'')),' ',
  151. TRIM(IFNULL(i.Descr,'')))) AS `Label`
  152. FROM PurOrdRctMaster p
  153. LEFT JOIN PurOrdRctDetail d
  154. ON p.RctType = d.RctType AND p.Receiver = d.Receiver
  155. LEFT JOIN ItemMaster i ON d.ItemNum = i.ItemNum AND (p.tenant_id = i.tenant_id OR p.tenant_id IS NULL)
  156. WHERE p.RctType = 'rc' AND p.Supp = @Supp AND d.RecID > 0
  157. AND p.tenant_id = @TenantId
  158. GROUP BY p.Receiver, d.LotSerial, d.ItemNum, i.Descr, p.Domain
  159. HAVING SUM(d.QtyReceived) > IFNULL((
  160. SELECT SUM(rd.QtyReceived) FROM PurOrdRctDetail rd
  161. JOIN PurOrdRctMaster rm ON rd.PurOrdRctRecID = rm.RecID AND rd.RctType = rm.RctType
  162. WHERE rm.RctType = 'pt' AND rm.OrdNbr = p.Receiver AND rd.IsActive = 1
  163. AND rd.ItemNum = d.ItemNum AND IFNULL(rd.LotSerial,'') = IFNULL(d.LotSerial,'')
  164. AND rm.tenant_id = @TenantId
  165. ), 0)
  166. ORDER BY p.Receiver DESC
  167. """,
  168. new SugarParameter("@Supp", supp.Trim()),
  169. new SugarParameter("@TenantId", tenantId));
  170. return rows;
  171. }
  172. [DisplayName("按收货单加载明细模板")]
  173. [HttpGet("purchase-return/rc-detail-lines")]
  174. public async Task<object> GetRcDetailLines([FromQuery] string receiver)
  175. {
  176. if (string.IsNullOrWhiteSpace(receiver))
  177. throw Oops.Oh("请先选择收货单号");
  178. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  179. var rows = await _db.Ado.SqlQueryAsync<PurchaseReturnRcLineRow>(
  180. """
  181. SELECT
  182. p.Domain AS Domain,
  183. p.Department AS Department,
  184. p.Terms AS Terms,
  185. p.TermsofTrade AS TermsofTrade,
  186. d.ItemNum AS ItemNum,
  187. d.UM AS UM,
  188. d.OrdNbr AS OrdNbr,
  189. CAST(d.OrdLine AS SIGNED) AS OrdLine,
  190. d.QtyOrded AS QtyOrded,
  191. d.Supp AS Supp,
  192. d.LotSerial AS LotSerial,
  193. CASE WHEN UPPER(IFNULL(d.Potype,'')) = 'PW' THEN d.Supp ELSE d.Location END AS Location,
  194. d.QtyReceived AS QtyReceived,
  195. d.Potype AS Potype,
  196. d.Typed AS Typed,
  197. p.TaxClass AS TaxClass,
  198. IFNULL(p.TaxIn, 0) AS TaxIn,
  199. p.Curr AS Curr,
  200. DATE_FORMAT(CURDATE(), '%Y-%m-%d') AS RctDate,
  201. d.UMConversion AS UMConversion,
  202. d.POCost AS POCost,
  203. d.QtyReceived AS QtyReturn,
  204. d.Receiver AS RctNbr,
  205. CAST(d.Line AS SIGNED) AS RctLine,
  206. d.CustPO AS CustPO,
  207. d.BlanketLine AS BlanketLine,
  208. IFNULL(d.RctQty, 0) AS RctQty,
  209. CAST(d.Line AS SIGNED) AS Line
  210. FROM PurOrdRctMaster p
  211. LEFT JOIN PurOrdRctDetail d
  212. ON p.RctType = d.RctType AND p.Receiver = d.Receiver
  213. WHERE p.RctType = 'rc' AND p.Receiver = @Receiver AND d.RecID > 0
  214. AND p.tenant_id = @TenantId
  215. ORDER BY d.Line
  216. """,
  217. new SugarParameter("@Receiver", receiver.Trim()),
  218. new SugarParameter("@TenantId", tenantId));
  219. PurchaseReturnRcLineRow head = null;
  220. if (rows.Count > 0)
  221. head = rows[0];
  222. return new { head, lines = rows };
  223. }
  224. [DisplayName("部门下拉")]
  225. [HttpGet("purchase-return/options/departments")]
  226. public async Task<object> GetDepartments()
  227. {
  228. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  229. return await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
  230. """
  231. SELECT Department AS `Value`,
  232. TRIM(CONCAT(TRIM(IFNULL(Department,'')),' ',TRIM(IFNULL(Descr,'')))) AS `Label`
  233. FROM DepartmentMaster
  234. WHERE tenant_id = @TenantId
  235. ORDER BY Department
  236. """,
  237. new SugarParameter("@TenantId", tenantId));
  238. }
  239. [DisplayName("库位下拉")]
  240. [HttpGet("purchase-return/options/locations")]
  241. public async Task<object> GetLocations()
  242. {
  243. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  244. return await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
  245. """
  246. SELECT Location AS `Value`,
  247. TRIM(CONCAT(TRIM(IFNULL(Location,'')),' ',TRIM(IFNULL(Descr,'')))) AS `Label`
  248. FROM LocationMaster
  249. WHERE IFNULL(Typed,'') <> 'Supp' AND tenant_id = @TenantId
  250. ORDER BY Location
  251. """,
  252. new SugarParameter("@TenantId", tenantId));
  253. }
  254. [DisplayName("退货原因下拉")]
  255. [HttpGet("purchase-return/options/qc-reasons")]
  256. public async Task<object> GetQcReasons()
  257. {
  258. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  259. return await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
  260. """
  261. SELECT Val AS `Value`,
  262. CONCAT(IFNULL(Val,''),'_',IFNULL(Comments,'')) AS `Label`
  263. FROM GeneralizedCodeMaster
  264. WHERE FldName = 'SAPTransReason'
  265. AND IFNULL(Ufld1,'') <> ''
  266. AND LOCATE('122', IFNULL(Ufld1,'')) > 0
  267. AND tenant_id = @TenantId
  268. ORDER BY Val
  269. """,
  270. new SugarParameter("@TenantId", tenantId));
  271. }
  272. [DisplayName("物料选择分页")]
  273. [HttpGet("purchase-return/item-options")]
  274. public async Task<object> GetItems([FromQuery] KeywordPageInput input)
  275. {
  276. var page = input.Page <= 0 ? 1 : input.Page;
  277. var pageSize = input.PageSize <= 0 ? 20 : input.PageSize;
  278. var offset = (page - 1) * pageSize;
  279. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  280. var pars = new List<SugarParameter> { new("@TenantId", tenantId) };
  281. var where = "1=1 AND tenant_id = @TenantId";
  282. if (!string.IsNullOrWhiteSpace(input.Keyword))
  283. {
  284. where += " AND (ItemNum LIKE @Kw OR Descr LIKE @Kw OR Descr1 LIKE @Kw)";
  285. pars.Add(new SugarParameter("@Kw", $"%{input.Keyword.Trim()}%"));
  286. }
  287. var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM ItemMaster WHERE {where}", pars);
  288. var list = await _db.Ado.SqlQueryAsync<PurchaseReturnKvItem>(
  289. $"""
  290. SELECT ItemNum AS `Value`,
  291. TRIM(CONCAT(IFNULL(Descr,''),' ',IFNULL(Descr1,''))) AS `Label`
  292. FROM ItemMaster
  293. WHERE {where}
  294. ORDER BY ItemNum
  295. LIMIT {pageSize} OFFSET {offset}
  296. """,
  297. pars);
  298. return new { total, page, pageSize, list };
  299. }
  300. [DisplayName("采购退货单详情")]
  301. [HttpGet("purchase-return/{id:int}")]
  302. public async Task<object> GetDetail(int id)
  303. {
  304. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  305. var master = await _masterRep.GetFirstAsync(m => m.RecID == id && m.RctType == "pt" && m.TenantId == tenantId)
  306. ?? throw Oops.Oh("单据不存在");
  307. var m = await _db.Ado.SqlQueryAsync<PurchaseReturnMasterDto>(
  308. """
  309. SELECT
  310. m.RecID AS RecID,
  311. m.Domain AS Domain,
  312. m.Receiver AS Receiver,
  313. DATE_FORMAT(m.RctDate,'%Y-%m-%d') AS RctDate,
  314. m.Supp AS Supp,
  315. m.OrdNbr AS OrdNbr,
  316. m.Department AS Department,
  317. IFNULL(m.Terms,'') AS Terms,
  318. IFNULL(m.Curr,'') AS Curr,
  319. IFNULL(m.TaxClass,'') AS TaxClass,
  320. IFNULL(m.TaxIn, 0) AS TaxIn,
  321. IFNULL(m.TermsofTrade,'') AS TermsofTrade,
  322. IFNULL(m.Remark,'') AS Remark,
  323. IFNULL(m.IsPlan, 0) AS IsPlan
  324. FROM PurOrdRctMaster m
  325. WHERE m.RecID = @Id AND m.tenant_id = @TenantId
  326. LIMIT 1
  327. """,
  328. new SugarParameter("@Id", id),
  329. new SugarParameter("@TenantId", tenantId));
  330. var head = m.FirstOrDefault() ?? throw Oops.Oh("单据不存在");
  331. var detailRows = await _db.Queryable<PurOrdRctDetail>()
  332. .Where(d => d.PurOrdRctRecID == id && d.RctType == "pt" && d.IsActive && d.TenantId == tenantId)
  333. .OrderBy(d => d.Line)
  334. .ToListAsync();
  335. var details = detailRows.Select(ToDetailDto).ToList();
  336. return new { master = head, details };
  337. }
  338. private static PurchaseReturnDetailDto ToDetailDto(PurOrdRctDetail d)
  339. {
  340. return new PurchaseReturnDetailDto
  341. {
  342. RecID = d.RecID,
  343. Line = d.Line,
  344. ItemNum = d.ItemNum,
  345. OrdNbr = d.OrdNbr,
  346. OrdLine = d.OrdLine,
  347. QtyOrded = d.QtyOrded,
  348. QtyReceived = d.QtyReceived,
  349. RctQty = d.RctQty,
  350. LotSerial = d.LotSerial,
  351. UM = d.UM,
  352. Location = d.Location,
  353. QCDescr = d.QCDescr,
  354. Remark = d.Remark,
  355. RctDate = d.RctDate?.ToString("yyyy-MM-dd"),
  356. Typed = d.Typed,
  357. TaxIn = d.TaxIn,
  358. TaxClass = d.TaxClass,
  359. Curr = d.Curr,
  360. Potype = d.Potype,
  361. IsActive = d.IsActive,
  362. IsConfirm = d.IsConfirm,
  363. UMConversion = d.UMConversion,
  364. POCost = d.POCost,
  365. RctNbr = d.RctNbr,
  366. RctLine = d.RctLine,
  367. CustPO = d.CustPO,
  368. BlanketLine = d.BlanketLine,
  369. QtyReturn = d.QtyReturn,
  370. Supp = d.Supp,
  371. };
  372. }
  373. [DisplayName("保存采购退货单")]
  374. [HttpPost("purchase-return/save")]
  375. public async Task<object> Save([FromBody] PurchaseReturnSaveInput input)
  376. {
  377. if (input == null) throw Oops.Oh("参数无效");
  378. if (string.IsNullOrWhiteSpace(input.Supp)) throw Oops.Oh("请选择供应商");
  379. if (string.IsNullOrWhiteSpace(input.OrdNbr)) throw Oops.Oh("请选择收货单号");
  380. if (string.IsNullOrWhiteSpace(input.Department)) throw Oops.Oh("请选择部门");
  381. if (input.Details == null || input.Details.Count == 0) throw Oops.Oh("请维护退货明细");
  382. var now = DateTime.Now;
  383. var account = _userManager.Account ?? "system";
  384. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  385. var domain = string.IsNullOrWhiteSpace(input.Domain)
  386. ? await ResolveRcDomainAsync(input.OrdNbr.Trim())
  387. : input.Domain.Trim();
  388. if (string.IsNullOrWhiteSpace(domain))
  389. domain = "100";
  390. // 校验退货数量不超过收货单可退余量
  391. await ValidateReturnQuantities(input.OrdNbr.Trim(), input.Id, input.Details, tenantId);
  392. if (input.Id is null or 0)
  393. {
  394. try
  395. {
  396. _db.Ado.BeginTran();
  397. var receiver = await AllocReceiverAsync();
  398. var rctDate = ParseDate(input.RctDate) ?? now.Date;
  399. var master = new PurOrdRctMaster
  400. {
  401. Domain = domain,
  402. RctType = "pt",
  403. Receiver = receiver,
  404. RctDate = rctDate,
  405. OrdNbr = input.OrdNbr.Trim(),
  406. Supp = input.Supp.Trim(),
  407. Department = input.Department.Trim(),
  408. Remark = input.Remark?.Trim(),
  409. Terms = input.Terms?.Trim(),
  410. Curr = input.Curr?.Trim(),
  411. TaxClass = input.TaxClass?.Trim(),
  412. TaxIn = input.TaxIn,
  413. TermsofTrade = input.TermsofTrade?.Trim(),
  414. Typed = "C",
  415. IsActive = true,
  416. IsConfirm = false,
  417. IsPlan = true,
  418. TenantId = tenantId,
  419. CreateUser = account,
  420. CreateTime = now,
  421. UpdateUser = account,
  422. UpdateTime = now,
  423. };
  424. // PurOrdRctMaster 无 AUTO_INCREMENT,需手动生成 RecID
  425. master.RecID = await _db.Ado.GetIntAsync(
  426. "SELECT IFNULL(MAX(RecID), 0) + 1 FROM PurOrdRctMaster");
  427. await _masterRep.InsertAsync(master);
  428. await SaveDetailsAsync(master.RecID, domain, receiver, "pt", input.Details, account, now);
  429. _db.Ado.CommitTran();
  430. return new { id = master.RecID, receiver, message = "新增成功" };
  431. }
  432. catch
  433. {
  434. _db.Ado.RollbackTran();
  435. throw;
  436. }
  437. }
  438. var existing = await _masterRep.GetFirstAsync(m => m.RecID == input.Id!.Value && m.RctType == "pt" && m.TenantId == tenantId)
  439. ?? throw Oops.Oh("单据不存在");
  440. if (!existing.IsPlan)
  441. throw Oops.Oh("该单据不允许修改");
  442. existing.RctDate = ParseDate(input.RctDate) ?? existing.RctDate;
  443. existing.OrdNbr = input.OrdNbr.Trim();
  444. existing.Supp = input.Supp.Trim();
  445. existing.Department = input.Department.Trim();
  446. existing.Remark = input.Remark?.Trim();
  447. existing.Terms = input.Terms?.Trim();
  448. existing.Curr = input.Curr?.Trim();
  449. existing.TaxClass = input.TaxClass?.Trim();
  450. existing.TaxIn = input.TaxIn;
  451. existing.TermsofTrade = input.TermsofTrade?.Trim();
  452. existing.Domain = domain;
  453. existing.UpdateUser = account;
  454. existing.UpdateTime = now;
  455. await _masterRep.UpdateAsync(existing);
  456. await SaveDetailsAsync(existing.RecID, existing.Domain, existing.Receiver, "pt", input.Details, account, now);
  457. return new { id = existing.RecID, receiver = existing.Receiver, message = "保存成功" };
  458. }
  459. private async Task SaveDetailsAsync(
  460. int masterRecId,
  461. string domain,
  462. string receiver,
  463. string rctType,
  464. List<PurchaseReturnDetailInput> entries,
  465. string account,
  466. DateTime now)
  467. {
  468. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  469. var dbRows = await _detailRep.GetListAsync(d =>
  470. d.PurOrdRctRecID == masterRecId && d.RctType == rctType && d.TenantId == tenantId);
  471. var dbById = dbRows.ToDictionary(d => d.RecID);
  472. var inputIds = new HashSet<int>(entries.Where(e => e.RecID is > 0).Select(e => e.RecID!.Value));
  473. // PurOrdRctDetail 无 AUTO_INCREMENT,提前取一次 MAX 并在循环中递增
  474. var nextDetailId = await _db.Ado.GetIntAsync(
  475. "SELECT IFNULL(MAX(RecID), 0) + 1 FROM PurOrdRctDetail");
  476. for (var i = 0; i < entries.Count; i++)
  477. {
  478. var e = entries[i];
  479. var lineNo = e.Line > 0 ? e.Line : (short)(i + 1);
  480. if (string.IsNullOrWhiteSpace(e.ItemNum))
  481. throw Oops.Oh($"第{i + 1}行:请填写物料");
  482. var loc = string.IsNullOrWhiteSpace(e.Location) ? "00" : e.Location.Trim();
  483. if (e.RecID is > 0 && dbById.TryGetValue(e.RecID.Value, out var row))
  484. {
  485. ApplyDetailInput(row, e, domain, receiver, masterRecId, account, now, lineNo, loc);
  486. row.UpdateUser = account;
  487. row.UpdateTime = now;
  488. await _detailRep.UpdateAsync(row);
  489. }
  490. else
  491. {
  492. var detailNew = new PurOrdRctDetail();
  493. ApplyDetailInput(detailNew, e, domain, receiver, masterRecId, account, now, lineNo, loc);
  494. detailNew.RecID = nextDetailId++;
  495. detailNew.TenantId = tenantId;
  496. detailNew.CreateUser = account;
  497. detailNew.CreateTime = now;
  498. detailNew.UpdateUser = account;
  499. detailNew.UpdateTime = now;
  500. await _detailRep.InsertAsync(detailNew);
  501. }
  502. }
  503. foreach (var del in dbRows.Where(d => !inputIds.Contains(d.RecID)))
  504. await _detailRep.DeleteAsync(d => d.RecID == del.RecID);
  505. }
  506. private static void ApplyDetailInput(
  507. PurOrdRctDetail row,
  508. PurchaseReturnDetailInput e,
  509. string domain,
  510. string receiver,
  511. int masterRecId,
  512. string account,
  513. DateTime now,
  514. short lineNo,
  515. string location)
  516. {
  517. row.Domain = domain;
  518. row.RctType = "pt";
  519. row.Receiver = receiver;
  520. row.PurOrdRctRecID = masterRecId;
  521. row.Line = lineNo;
  522. row.Dimension1 = "";
  523. row.Dimension2 = "";
  524. row.ItemNum = e.ItemNum?.Trim();
  525. row.OrdNbr = string.IsNullOrWhiteSpace(e.OrdNbr) ? "" : e.OrdNbr.Trim();
  526. row.OrdLine = (short)e.OrdLine;
  527. row.RctDate = ParseDate(e.RctDate);
  528. row.UM = e.UM;
  529. row.QtyOrded = e.QtyOrded;
  530. row.QtyReceived = e.QtyReceived;
  531. row.RctQty = e.RctQty;
  532. row.LotSerial = e.LotSerial;
  533. row.Location = location;
  534. row.QCDescr = e.QCDescr;
  535. row.Remark = e.Remark;
  536. row.Typed = string.IsNullOrWhiteSpace(e.Typed) ? "C" : e.Typed.Trim();
  537. row.TaxIn = e.TaxIn;
  538. row.TaxClass = e.TaxClass;
  539. row.Curr = e.Curr;
  540. row.Potype = string.IsNullOrWhiteSpace(e.Potype) ? "P" : e.Potype.Trim();
  541. row.IsActive = true;
  542. row.IsConfirm = e.IsConfirm;
  543. row.UMConversion = e.UMConversion;
  544. row.POCost = e.POCost;
  545. row.RctNbr = e.RctNbr;
  546. row.RctLine = (short)e.RctLine;
  547. row.CustPO = e.CustPO;
  548. row.BlanketLine = e.BlanketLine;
  549. row.QtyReturn = e.QtyReturn;
  550. row.Supp = e.Supp;
  551. }
  552. [DisplayName("删除采购退货单(软删除)")]
  553. [HttpPost("purchase-return/delete/{id:int}")]
  554. public async Task<object> SoftDelete(int id)
  555. {
  556. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  557. var m = await _masterRep.GetFirstAsync(x => x.RecID == id && x.RctType == "pt" && x.TenantId == tenantId)
  558. ?? throw Oops.Oh("单据不存在");
  559. if (!m.IsPlan)
  560. throw Oops.Oh("该单据不允许删除");
  561. m.IsActive = false;
  562. m.UpdateUser = _userManager.Account ?? "system";
  563. m.UpdateTime = DateTime.Now;
  564. await _masterRep.UpdateAsync(m);
  565. await _db.Ado.ExecuteCommandAsync(
  566. "UPDATE PurOrdRctDetail SET IsActive=0, UpdateUser=@U, UpdateTime=@T WHERE PurOrdRctRecID=@Id AND RctType='pt' AND tenant_id=@TenantId",
  567. new SugarParameter("@U", m.UpdateUser),
  568. new SugarParameter("@T", m.UpdateTime),
  569. new SugarParameter("@Id", id),
  570. new SugarParameter("@TenantId", tenantId));
  571. return new { message = "已删除" };
  572. }
  573. private async Task<string> ResolveRcDomainAsync(string rcReceiver)
  574. {
  575. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  576. var d = await _db.Ado.GetStringAsync(
  577. "SELECT Domain FROM PurOrdRctMaster WHERE RctType='rc' AND Receiver=@R AND tenant_id=@TenantId LIMIT 1",
  578. new SugarParameter("@R", rcReceiver),
  579. new SugarParameter("@TenantId", tenantId));
  580. return d;
  581. }
  582. private Task<string> AllocReceiverAsync() =>
  583. PurOrdNumberGenerator.NextRctReceiverAsync(_db, "PT", "pt", DateTime.Today, PtReceiverSerialWidth);
  584. private static DateTime? ParseDate(string raw)
  585. {
  586. if (string.IsNullOrWhiteSpace(raw)) return null;
  587. return DateTime.TryParse(raw.Trim(), out var d) ? d.Date : null;
  588. }
  589. /// <summary>
  590. /// 校验退货数量不超过收货单可退余量。
  591. /// 对每行按 ItemNum + LotSerial 分组,比较:已有退货总量 + 本次退货量 ≤ 收货量。
  592. /// 编辑时排除当前退货单自身数量。
  593. /// </summary>
  594. private async Task ValidateReturnQuantities(
  595. string rcReceiver, int? editingId, List<PurchaseReturnDetailInput> details, long tenantId)
  596. {
  597. // 1. 取收货单明细:按 (ItemNum, LotSerial) 汇总收货数量
  598. var rcLines = await _db.Ado.SqlQueryAsync<dynamic>(
  599. """
  600. SELECT ItemNum, IFNULL(LotSerial,'') AS LotSerial, SUM(QtyReceived) AS QtyReceived
  601. FROM PurOrdRctDetail
  602. WHERE RctType='rc' AND Receiver=@Receiver AND tenant_id=@TenantId
  603. GROUP BY ItemNum, IFNULL(LotSerial,'')
  604. """,
  605. new SugarParameter("@Receiver", rcReceiver),
  606. new SugarParameter("@TenantId", tenantId));
  607. if (!rcLines.Any()) return; // 收货单无明细则跳过校验
  608. var received = new Dictionary<string, decimal>();
  609. foreach (var rc in rcLines)
  610. {
  611. var key = $"{(string)rc.ItemNum}|{(string)rc.LotSerial}";
  612. received[key] = (decimal)rc.QtyReceived;
  613. }
  614. // 2. 取已有退货总量(排除编辑中的当前退货单)
  615. var excludeSql = editingId.HasValue ? " AND rm.RecID <> @ExcludeId" : "";
  616. var existingReturns = await _db.Ado.SqlQueryAsync<dynamic>(
  617. $"""
  618. SELECT rd.ItemNum, IFNULL(rd.LotSerial,'') AS LotSerial, SUM(rd.QtyReceived) AS ReturnedQty
  619. FROM PurOrdRctDetail rd
  620. JOIN PurOrdRctMaster rm ON rd.PurOrdRctRecID = rm.RecID AND rd.RctType = rm.RctType
  621. WHERE rm.RctType='pt' AND rm.OrdNbr=@Receiver AND rd.IsActive=1{excludeSql}
  622. AND rm.tenant_id=@TenantId
  623. GROUP BY rd.ItemNum, IFNULL(rd.LotSerial,'')
  624. """,
  625. editingId.HasValue
  626. ? new List<SugarParameter>
  627. {
  628. new("@Receiver", rcReceiver),
  629. new("@ExcludeId", editingId.Value),
  630. new("@TenantId", tenantId)
  631. }
  632. : new List<SugarParameter>
  633. {
  634. new("@Receiver", rcReceiver),
  635. new("@TenantId", tenantId)
  636. });
  637. var returned = new Dictionary<string, decimal>();
  638. foreach (var rt in existingReturns)
  639. {
  640. var key = $"{(string)rt.ItemNum}|{(string)rt.LotSerial}";
  641. returned[key] = (decimal)rt.ReturnedQty;
  642. }
  643. // 3. 逐行校验
  644. for (var i = 0; i < details.Count; i++)
  645. {
  646. var d = details[i];
  647. var key = $"{d.ItemNum?.Trim()}|{d.LotSerial?.Trim() ?? ""}";
  648. if (!received.TryGetValue(key, out var rcQty)) continue;
  649. var alreadyReturned = returned.GetValueOrDefault(key, 0m);
  650. var thisQty = d.QtyReceived;
  651. if (alreadyReturned + thisQty > rcQty)
  652. {
  653. var available = rcQty - alreadyReturned;
  654. throw Oops.Oh($"第 {i + 1} 行退货数量({thisQty})超过可退余量({available}),收货数量={rcQty},已退数量={alreadyReturned}");
  655. }
  656. }
  657. }
  658. }