ShippingPlanService.cs 22 KB

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