ShippingPlanService.cs 23 KB

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