ShippingPlanService.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. namespace Admin.NET.Plugin.AiDOP.Order;
  2. /// <summary>
  3. /// 出货计划服务 📦
  4. /// 路由前缀:/api/Order/shippingplan/...
  5. /// </summary>
  6. [ApiDescriptionSettings(Order = 290, Description = "出货计划")]
  7. [Route("api/Order")]
  8. [AllowAnonymous]
  9. [NonUnify]
  10. public class ShippingPlanService : IDynamicApiController, ITransient
  11. {
  12. private readonly ISqlSugarClient _db;
  13. private readonly SqlSugarRepository<ShippingPlan> _planRep;
  14. private readonly SqlSugarRepository<ShippingPlanDetail> _detailRep;
  15. private readonly SqlSugarRepository<AsnShipperMaster> _asnMasterRep;
  16. private readonly SqlSugarRepository<AsnShipperDetail> _asnDetailRep;
  17. private readonly UserManager _userManager;
  18. public ShippingPlanService(
  19. ISqlSugarClient db,
  20. SqlSugarRepository<ShippingPlan> planRep,
  21. SqlSugarRepository<ShippingPlanDetail> detailRep,
  22. SqlSugarRepository<AsnShipperMaster> asnMasterRep,
  23. SqlSugarRepository<AsnShipperDetail> asnDetailRep,
  24. UserManager userManager)
  25. {
  26. _db = db;
  27. _planRep = planRep;
  28. _detailRep = detailRep;
  29. _asnMasterRep = asnMasterRep;
  30. _asnDetailRep = asnDetailRep;
  31. _userManager = userManager;
  32. }
  33. // ══════════════════════════════════════════════════════════════
  34. // 列表 GET /api/Order/shippingplan/list
  35. // ══════════════════════════════════════════════════════════════
  36. /// <summary>获取出货计划分页列表 📦</summary>
  37. [DisplayName("获取出货计划列表")]
  38. [HttpGet("shippingplan/list")]
  39. public async Task<object> GetShippingPlanList([FromQuery] ShippingPlanListInput input)
  40. {
  41. var pars = new List<SugarParameter>();
  42. var conditions = new List<string>();
  43. if (!string.IsNullOrWhiteSpace(input.OrdNbr))
  44. {
  45. conditions.Add("b.OrdNbr LIKE @OrdNbr");
  46. pars.Add(new SugarParameter("@OrdNbr", $"%{input.OrdNbr.Trim()}%"));
  47. }
  48. if (!string.IsNullOrWhiteSpace(input.BillNo))
  49. {
  50. conditions.Add("b.bill_no LIKE @BillNo");
  51. pars.Add(new SugarParameter("@BillNo", $"%{input.BillNo.Trim()}%"));
  52. }
  53. if (!string.IsNullOrWhiteSpace(input.Country))
  54. {
  55. conditions.Add("b.Country = @Country");
  56. pars.Add(new SugarParameter("@Country", input.Country.Trim()));
  57. }
  58. if (!string.IsNullOrWhiteSpace(input.ShippingDateFrom))
  59. {
  60. conditions.Add("a.ShippingDate >= @ShippingDateFrom");
  61. pars.Add(new SugarParameter("@ShippingDateFrom", input.ShippingDateFrom.Trim()));
  62. }
  63. if (!string.IsNullOrWhiteSpace(input.CustomNo))
  64. {
  65. conditions.Add("b.CustomNo LIKE @CustomNo");
  66. pars.Add(new SugarParameter("@CustomNo", $"%{input.CustomNo.Trim()}%"));
  67. }
  68. if (!string.IsNullOrWhiteSpace(input.ItemNum))
  69. {
  70. conditions.Add("b.ItemNum LIKE @ItemNum");
  71. pars.Add(new SugarParameter("@ItemNum", $"%{input.ItemNum.Trim()}%"));
  72. }
  73. var whereClause = conditions.Count > 0 ? "WHERE " + string.Join(" AND ", conditions) : "";
  74. var baseSql = $"""
  75. SELECT
  76. a.ShippingSite,
  77. a.LotSerial,
  78. a.ShippingDate,
  79. a.ShippingAddress,
  80. a.Consignee,
  81. a.Telephone,
  82. a.RecID AS Id,
  83. b.OrdNbr,
  84. b.bill_no AS BillNo,
  85. b.OrdDate,
  86. b.Country,
  87. b.CustomNo,
  88. b.CustomName,
  89. b.ItemNum,
  90. b.ItemName,
  91. b.Specification,
  92. b.Qty,
  93. b.Packaging,
  94. CAST(c.RecID AS CHAR) AS AsnRecID,
  95. CAST(b.RecID AS CHAR) AS Sid,
  96. b.Volume,
  97. b.Weight
  98. FROM ShippingPlan a
  99. LEFT JOIN ShippingPlanDetail b ON a.RecID = b.plan_id
  100. LEFT JOIN ASNBOLShipperDetail c ON b.ItemNum = c.ContainerItem
  101. AND b.bill_no = c.ordnbr
  102. AND c.shtype = 'SH'
  103. AND c.Typed <> 'S'
  104. AND c.IsActive = 1
  105. {whereClause}
  106. """;
  107. var offset = (input.Page - 1) * input.PageSize;
  108. var total = await _db.Ado.GetIntAsync(
  109. $"SELECT COUNT(*) FROM ({baseSql}) AS t", pars);
  110. var list = await _db.Ado.SqlQueryAsync<ShippingPlanListRow>(
  111. $"SELECT * FROM ({baseSql}) AS t ORDER BY t.Id DESC LIMIT {input.PageSize} OFFSET {offset}",
  112. pars);
  113. return new { total, page = input.Page, pageSize = input.PageSize, list };
  114. }
  115. // ══════════════════════════════════════════════════════════════
  116. // 详情 GET /api/Order/shippingplan/{id}
  117. // ══════════════════════════════════════════════════════════════
  118. /// <summary>获取出货计划详情(含明细)📦</summary>
  119. [DisplayName("获取出货计划详情")]
  120. [HttpGet("shippingplan/{id}")]
  121. public async Task<object> GetShippingPlanDetail(int id)
  122. {
  123. var plan = await _planRep.GetFirstAsync(u => u.RecID == id)
  124. ?? throw Oops.Oh("出货计划不存在");
  125. var details = await _detailRep.GetListAsync(u => u.PlanId == id);
  126. return new
  127. {
  128. recID = plan.RecID,
  129. lotSerial = plan.LotSerial,
  130. shippingDate = plan.ShippingDate?.ToString("yyyy-MM-dd"),
  131. shippingSite = plan.ShippingSite,
  132. consignee = plan.Consignee,
  133. shippingAddress = plan.ShippingAddress,
  134. telephone = plan.Telephone,
  135. remark = plan.Remark,
  136. details = details.Select(d => new
  137. {
  138. recID = d.RecID,
  139. ordNbr = d.OrdNbr,
  140. billNo = d.BillNo,
  141. customNo = d.CustomNo,
  142. customName = d.CustomName,
  143. ordDate = d.OrdDate?.ToString("yyyy-MM-dd"),
  144. country = d.Country,
  145. itemNum = d.ItemNum,
  146. itemName = d.ItemName,
  147. specification = d.Specification,
  148. qty = d.Qty,
  149. weight = d.Weight,
  150. volume = d.Volume,
  151. packaging = d.Packaging,
  152. remark = d.Remark,
  153. })
  154. };
  155. }
  156. // ══════════════════════════════════════════════════════════════
  157. // 保存(新增/编辑)POST /api/Order/shippingplan/save
  158. // ══════════════════════════════════════════════════════════════
  159. /// <summary>保存出货计划(新增或编辑)📦</summary>
  160. [DisplayName("保存出货计划")]
  161. [ApiDescriptionSettings(Name = "SaveShippingPlan"), HttpPost("shippingplan/save")]
  162. public async Task<object> SaveShippingPlan([FromBody] ShippingPlanSaveInput input)
  163. {
  164. var now = DateTime.Now;
  165. var user = _userManager.Account ?? "system";
  166. if (input.RecID is null or 0)
  167. {
  168. // ── 新增:参照 SysJobService.AddJobDetail ──
  169. var nextPlanId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM ShippingPlan") + 1;
  170. var entity = new ShippingPlan
  171. {
  172. RecID = nextPlanId,
  173. Domain = "8010",
  174. Priority = 0,
  175. LotSerial = input.LotSerial,
  176. ShippingDate = string.IsNullOrWhiteSpace(input.ShippingDate) ? null : DateTime.Parse(input.ShippingDate),
  177. ShippingSite = input.ShippingSite,
  178. Consignee = input.Consignee,
  179. ShippingAddress = input.ShippingAddress,
  180. Telephone = input.Telephone,
  181. Remark = input.Remark,
  182. IsActive = 1,
  183. IsConfirm = 1,
  184. CreateUser = user,
  185. CreateTime = now,
  186. };
  187. await _planRep.InsertAsync(entity);
  188. await SaveDetailsAsync(nextPlanId, input.Details, user, now, isNew: true);
  189. return new { id = nextPlanId, message = "新增成功" };
  190. }
  191. else
  192. {
  193. // ── 编辑:参照 SysJobService.UpdateJobDetail ──
  194. var entity = await _planRep.GetFirstAsync(u => u.RecID == input.RecID.Value)
  195. ?? throw Oops.Oh("出货计划不存在");
  196. entity.LotSerial = input.LotSerial;
  197. entity.ShippingDate = string.IsNullOrWhiteSpace(input.ShippingDate) ? null : DateTime.Parse(input.ShippingDate);
  198. entity.ShippingSite = input.ShippingSite;
  199. entity.Consignee = input.Consignee;
  200. entity.ShippingAddress = input.ShippingAddress;
  201. entity.Telephone = input.Telephone;
  202. entity.Remark = input.Remark;
  203. entity.UpdateUser = user;
  204. entity.UpdateTime = now;
  205. await _planRep.UpdateAsync(entity);
  206. await SaveDetailsAsync(input.RecID.Value, input.Details, user, now, isNew: false);
  207. return new { id = input.RecID, message = "编辑成功" };
  208. }
  209. }
  210. /// <summary>
  211. /// 明细三路合并(参照 SeOrderService.SaveEntriesAsync):
  212. /// ① DB有且入参有(按 RecID 匹配)→ 更新
  213. /// ② DB无但入参有 → 新增
  214. /// ③ DB有但入参无 → 删除
  215. /// </summary>
  216. private async Task SaveDetailsAsync(int planId, List<ShippingPlanDetailInput> details,
  217. string user, DateTime now, bool isNew)
  218. {
  219. var dbDetails = isNew ? new List<ShippingPlanDetail>()
  220. : await _detailRep.GetListAsync(u => u.PlanId == planId);
  221. var dbById = dbDetails.ToDictionary(u => u.RecID);
  222. var inputIds = new HashSet<int>(details.Where(d => d.RecID is > 0).Select(d => d.RecID!.Value));
  223. // RecID 列无 AUTO_INCREMENT,手动取当前最大值后顺序递增
  224. var nextId = await _db.Ado.GetIntAsync("SELECT IFNULL(MAX(RecID), 0) FROM ShippingPlanDetail") + 1;
  225. foreach (var d in details)
  226. {
  227. if (d.RecID > 0 && dbById.TryGetValue(d.RecID!.Value, out var existing))
  228. {
  229. // ① 更新
  230. existing.OrdNbr = d.OrdNbr;
  231. existing.BillNo = d.BillNo;
  232. existing.CustomNo = d.CustomNo;
  233. existing.CustomName = d.CustomName;
  234. existing.OrdDate = string.IsNullOrWhiteSpace(d.OrdDate) ? null : DateTime.Parse(d.OrdDate);
  235. existing.Country = d.Country;
  236. existing.ItemNum = d.ItemNum;
  237. existing.ItemName = d.ItemName;
  238. existing.Specification = d.Specification;
  239. existing.Qty = d.Qty;
  240. existing.Weight = d.Weight;
  241. existing.Volume = d.Volume;
  242. existing.Packaging = d.Packaging;
  243. existing.Remark = d.Remark;
  244. existing.UpdateUser = user;
  245. existing.UpdateTime = now;
  246. await _detailRep.UpdateAsync(existing);
  247. }
  248. else
  249. {
  250. // ② 新增
  251. var detail = new ShippingPlanDetail
  252. {
  253. RecID = nextId++,
  254. PlanId = planId,
  255. Domain = "8010",
  256. SentryId = long.TryParse(d.SentryId, out var sid) ? sid : null,
  257. OrdNbr = d.OrdNbr,
  258. BillNo = d.BillNo,
  259. CustomNo = d.CustomNo,
  260. CustomName = d.CustomName,
  261. OrdDate = string.IsNullOrWhiteSpace(d.OrdDate) ? null : DateTime.Parse(d.OrdDate),
  262. Country = d.Country,
  263. ItemNum = d.ItemNum,
  264. ItemName = d.ItemName,
  265. Specification = d.Specification,
  266. Qty = d.Qty,
  267. Weight = d.Weight,
  268. Volume = d.Volume,
  269. Packaging = d.Packaging,
  270. Remark = d.Remark,
  271. IsActive = 1,
  272. IsConfirm = 1,
  273. CreateUser = user,
  274. CreateTime = now,
  275. };
  276. await _detailRep.InsertAsync(detail);
  277. }
  278. }
  279. // ③ 删除:DB有、入参无
  280. foreach (var toDelete in dbDetails.Where(u => !inputIds.Contains(u.RecID)))
  281. {
  282. await _detailRep.DeleteAsync(u => u.RecID == toDelete.RecID);
  283. }
  284. }
  285. // ══════════════════════════════════════════════════════════════
  286. // 销售出库 POST /api/Order/shippingplan/ship
  287. // ══════════════════════════════════════════════════════════════
  288. /// <summary>销售出库(按出货计划生成 ASN 发货单)📦</summary>
  289. [DisplayName("销售出库")]
  290. [ApiDescriptionSettings(Name = "ShipShippingPlan"), HttpPost("shippingplan/ship")]
  291. public async Task<object> ShipShippingPlan([FromBody] ShippingPlanShipInput input)
  292. {
  293. var ids = ParseIds(input.Ids);
  294. if (ids.Count == 0)
  295. throw Oops.Oh("ids 不能为空");
  296. var tenantId = _userManager.TenantId;
  297. var account = _userManager.Account ?? "system";
  298. var now = DateTime.Now;
  299. var plans = await _planRep.GetListAsync(u => ids.Contains(u.RecID) && u.TenantId == tenantId);
  300. var planIds = plans.Select(u => u.RecID).ToHashSet();
  301. var details = await _detailRep.GetListAsync(u =>
  302. u.TenantId == tenantId &&
  303. ((u.PlanId.HasValue && planIds.Contains(u.PlanId.Value)) || ids.Contains(u.RecID)));
  304. if (details.Count == 0)
  305. throw Oops.Oh("未找到可出库的出货计划明细");
  306. var missingPlanIds = details
  307. .Where(u => u.PlanId.HasValue && !planIds.Contains(u.PlanId.Value))
  308. .Select(u => u.PlanId!.Value)
  309. .Distinct()
  310. .ToList();
  311. if (missingPlanIds.Count > 0)
  312. {
  313. var extraPlans = await _planRep.GetListAsync(u => missingPlanIds.Contains(u.RecID) && u.TenantId == tenantId);
  314. plans.AddRange(extraPlans);
  315. planIds = plans.Select(u => u.RecID).ToHashSet();
  316. }
  317. var planById = plans.ToDictionary(u => u.RecID);
  318. var shippableRows = details
  319. .Where(u => u.PlanId.HasValue && planById.ContainsKey(u.PlanId.Value))
  320. .Where(u => !string.IsNullOrWhiteSpace(u.BillNo) && !string.IsNullOrWhiteSpace(u.ItemNum))
  321. .ToList();
  322. if (shippableRows.Count == 0)
  323. throw Oops.Oh("出货计划明细缺少订单号或物料编号,不能出库");
  324. var skipped = 0;
  325. var createdMasters = 0;
  326. var createdDetails = 0;
  327. var createdShipIds = new List<string>();
  328. _db.Ado.BeginTran();
  329. try
  330. {
  331. foreach (var group in shippableRows.GroupBy(u => u.BillNo!.Trim()))
  332. {
  333. var rows = new List<ShippingPlanDetail>();
  334. foreach (var row in group)
  335. {
  336. var exists = await _asnDetailRep.IsAnyAsync(u =>
  337. u.TenantId == tenantId &&
  338. u.ShType == "SH" &&
  339. u.Typed != "S" &&
  340. u.IsActive == 1 &&
  341. u.OrdNbr == row.BillNo &&
  342. u.ContainerItem == row.ItemNum);
  343. if (exists)
  344. {
  345. skipped++;
  346. continue;
  347. }
  348. rows.Add(row);
  349. }
  350. if (rows.Count == 0)
  351. continue;
  352. var first = rows[0];
  353. var firstPlan = planById[first.PlanId!.Value];
  354. var shipId = await GenerateShipIdAsync(tenantId);
  355. var master = new AsnShipperMaster
  356. {
  357. Domain = firstPlan.Domain,
  358. Id = shipId,
  359. OrdNbr = group.Key,
  360. SoldTo = first.CustomNo,
  361. ShipDate = firstPlan.ShippingDate,
  362. Status = string.Empty,
  363. Remark = $"由出货计划生成:{string.Join(",", rows.Select(u => u.RecID))}",
  364. ShType = "SH",
  365. Typed = string.Empty,
  366. IsActive = 1,
  367. IsConfirm = 0,
  368. Site = firstPlan.ShippingSite,
  369. GrossWeight = rows.Sum(u => u.Weight ?? 0),
  370. NetWeight = rows.Sum(u => u.Weight ?? 0),
  371. Volume = rows.Sum(u => u.Volume ?? 0),
  372. CreateUser = account,
  373. CreateTime = now,
  374. TenantId = tenantId
  375. };
  376. var inserted = await _asnMasterRep.AsInsertable(master).ExecuteReturnEntityAsync();
  377. createdMasters++;
  378. createdShipIds.Add(shipId);
  379. for (var i = 0; i < rows.Count; i++)
  380. {
  381. var row = rows[i];
  382. var plan = planById[row.PlanId!.Value];
  383. var detail = new AsnShipperDetail
  384. {
  385. Domain = row.Domain ?? plan.Domain,
  386. Id = shipId,
  387. AsnShipperRecID = inserted.RecID,
  388. Line = i + 1,
  389. OrdNbr = row.BillNo,
  390. ContainerItem = row.ItemNum,
  391. Descr = row.ItemName,
  392. LotSerial = plan.LotSerial,
  393. QtyToShip = row.Qty,
  394. PickingQty = null,
  395. RealQty = null,
  396. Status = string.Empty,
  397. Remark = BuildShipDetailRemark(row),
  398. ShType = "SH",
  399. Typed = string.Empty,
  400. IsActive = 1,
  401. IsConfirm = 0,
  402. ShipDate = plan.ShippingDate,
  403. Site = plan.ShippingSite,
  404. CreateUser = account,
  405. CreateTime = now,
  406. TenantId = tenantId
  407. };
  408. await _asnDetailRep.InsertAsync(detail);
  409. createdDetails++;
  410. }
  411. }
  412. _db.Ado.CommitTran();
  413. }
  414. catch
  415. {
  416. _db.Ado.RollbackTran();
  417. throw;
  418. }
  419. if (createdDetails == 0)
  420. return new { message = "勾选明细已生成过销售出库单", createdMasters, createdDetails, skipped };
  421. return new { message = "销售出库执行成功", createdMasters, createdDetails, skipped, shipIds = createdShipIds };
  422. }
  423. private static List<int> ParseIds(string ids)
  424. {
  425. return ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  426. .Select(u => int.TryParse(u, out var id) ? id : 0)
  427. .Where(u => u > 0)
  428. .Distinct()
  429. .ToList();
  430. }
  431. private static string BuildShipDetailRemark(ShippingPlanDetail row)
  432. {
  433. var source = $"来源出货计划明细:{row.RecID}";
  434. return string.IsNullOrWhiteSpace(row.Remark) ? source : $"{row.Remark}; {source}";
  435. }
  436. /// <summary>
  437. /// 生成发货单号:SH + yyyyMMdd + 4位流水号,例如 SH202605210001。
  438. /// </summary>
  439. private async Task<string> GenerateShipIdAsync(long tenantId)
  440. {
  441. var today = DateTime.Now.ToString("yyyyMMdd");
  442. var prefix = $"SH{today}";
  443. var maxId = await _db.Queryable<AsnShipperMaster>()
  444. .Where(u => u.TenantId == tenantId && u.Id != null && u.Id.StartsWith(prefix))
  445. .MaxAsync(u => u.Id);
  446. var next = 1;
  447. if (!string.IsNullOrWhiteSpace(maxId) &&
  448. maxId.Length >= prefix.Length + 4 &&
  449. int.TryParse(maxId[^4..], out var current))
  450. {
  451. next = current + 1;
  452. }
  453. return $"{prefix}{next:D4}";
  454. }
  455. // ──────────────── 内部查询结果映射类 ────────────────
  456. private sealed class ShippingPlanListRow
  457. {
  458. public int Id { get; set; }
  459. public string? LotSerial { get; set; }
  460. public string? OrdNbr { get; set; }
  461. public string? BillNo { get; set; }
  462. public DateTime? OrdDate { get; set; }
  463. public string? Country { get; set; }
  464. public string? CustomNo { get; set; }
  465. public string? CustomName { get; set; }
  466. public string? ItemNum { get; set; }
  467. public string? ItemName { get; set; }
  468. public string? Specification { get; set; }
  469. public decimal? Qty { get; set; }
  470. public decimal? Weight { get; set; }
  471. public decimal? Volume { get; set; }
  472. public string? Packaging { get; set; }
  473. public string? ShippingSite { get; set; }
  474. public DateTime? ShippingDate { get; set; }
  475. public string? ShippingAddress { get; set; }
  476. public string? Consignee { get; set; }
  477. public string? Telephone { get; set; }
  478. public string? AsnRecID { get; set; }
  479. public string? Sid { get; set; }
  480. }
  481. }