WorkOrderBomUploadService.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. using Admin.NET.Plugin.AiDOP.Infrastructure;
  2. using Microsoft.Extensions.Logging;
  3. namespace Admin.NET.Plugin.AiDOP.WorkOrder;
  4. /// <summary>
  5. /// 工单 BOM 上传服务 — 从 Excel 解析结果按(订单号+父物料号)匹配未下达工单,
  6. /// 批量写入 ItemMaster + WorkOrdDetail。
  7. /// 路由前缀:/api/WorkOrder/dispatch/bom/...
  8. ///
  9. /// 【租户安全边界】需认证访问;租户一律经
  10. /// <c>AidopTenantScope.ResolveOrThrow</c> 从认证后 JWT 解析。
  11. /// </summary>
  12. [ApiDescriptionSettings(Order = 258, Description = "工单BOM上传")]
  13. [Route("api/WorkOrder")]
  14. [NonUnify]
  15. public class WorkOrderBomUploadService : IDynamicApiController, ITransient
  16. {
  17. private readonly ISqlSugarClient _db;
  18. private readonly UserManager _userManager;
  19. private readonly ILogger<WorkOrderBomUploadService> _logger;
  20. public WorkOrderBomUploadService(
  21. ISqlSugarClient db,
  22. UserManager userManager,
  23. ILogger<WorkOrderBomUploadService> logger)
  24. {
  25. _db = db;
  26. _userManager = userManager;
  27. _logger = logger;
  28. }
  29. private long ResolveTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
  30. // ══════════════════════════════════════════════════════════════
  31. // POST /api/WorkOrder/dispatch/bom/check-materials
  32. // ══════════════════════════════════════════════════════════════
  33. /// <summary>校验 BOM 子物料是否在 ItemMaster 中存在。</summary>
  34. [DisplayName("BOM物料存在性校验")]
  35. [ApiDescriptionSettings(Name = "BomCheckMaterials"), HttpPost("dispatch/bom/check-materials")]
  36. public async Task<BomCheckResult> CheckMaterials([FromBody] BomCheckMaterialsInput input)
  37. {
  38. var tid = ResolveTenantId();
  39. if (input.Items == null || input.Items.Count == 0)
  40. throw Oops.Oh("BOM 物料列表为空");
  41. var itemNums = input.Items
  42. .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum))
  43. .Select(x => x.ItemNum.Trim())
  44. .Distinct()
  45. .ToList();
  46. if (itemNums.Count == 0)
  47. throw Oops.Oh("BOM 中无有效的物料编码");
  48. // 查询本租户下已存在的物料编码
  49. var existingSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  50. var pars = new List<SugarParameter> { new("@TenantId", tid) };
  51. for (var i = 0; i < itemNums.Count; i++)
  52. pars.Add(new SugarParameter($"@IN{i}", itemNums[i]));
  53. var inClause = string.Join(", ", Enumerable.Range(0, itemNums.Count).Select(i => $"@IN{i}"));
  54. var existing = await _db.Ado.SqlQueryAsync<string>(
  55. $"""
  56. SELECT DISTINCT ItemNum FROM ItemMaster
  57. WHERE tenant_id = @TenantId AND ItemNum IN ({inClause})
  58. """,
  59. pars);
  60. foreach (var e in existing)
  61. existingSet.Add(e);
  62. return new BomCheckResult
  63. {
  64. Existing = existingSet.ToList(),
  65. Missing = input.Items
  66. .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum) && !existingSet.Contains(x.ItemNum.Trim()))
  67. .GroupBy(x => x.ItemNum.Trim())
  68. .Select(g => g.First())
  69. .ToList()
  70. };
  71. }
  72. // ══════════════════════════════════════════════════════════════
  73. // POST /api/WorkOrder/dispatch/bom/upload
  74. // ══════════════════════════════════════════════════════════════
  75. /// <summary>
  76. /// 写入 BOM:按(订单号+父物料号)匹配未下达工单,可选新建 ItemMaster 缺失物料,
  77. /// 然后 Upsert WorkOrdDetail。
  78. /// </summary>
  79. [DisplayName("BOM写入工单物料明细")]
  80. [ApiDescriptionSettings(Name = "BomUpload"), HttpPost("dispatch/bom/upload")]
  81. public async Task<BomUploadResult> Upload([FromBody] BomUploadInput input)
  82. {
  83. var tid = ResolveTenantId();
  84. if (input.Items == null || input.Items.Count == 0)
  85. throw Oops.Oh("BOM 物料列表为空");
  86. var account = _userManager.Account ?? "system";
  87. var factoryRefId = await ResolveFactoryRefIdAsync(tid);
  88. // 1) 查找哪些子物料需要新建(ItemMaster 存在性检查)
  89. var itemNums = input.Items
  90. .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum))
  91. .Select(x => x.ItemNum.Trim())
  92. .Distinct()
  93. .ToList();
  94. var existingSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  95. if (itemNums.Count > 0)
  96. {
  97. var pars = new List<SugarParameter> { new("@TenantId", tid) };
  98. for (var i = 0; i < itemNums.Count; i++)
  99. pars.Add(new SugarParameter($"@IN{i}", itemNums[i]));
  100. var inClause = string.Join(", ", Enumerable.Range(0, itemNums.Count).Select(i => $"@IN{i}"));
  101. var existing = await _db.Ado.SqlQueryAsync<string>(
  102. $"SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id = @TenantId AND ItemNum IN ({inClause})",
  103. pars);
  104. foreach (var e in existing)
  105. existingSet.Add(e);
  106. }
  107. var newItemsCount = 0;
  108. // 2) 按(SalesOrderNo, ParentItemNum)分组 → 每组对应一个工单
  109. var groups = input.Items
  110. .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum)
  111. && !string.IsNullOrWhiteSpace(x.SalesOrderNo)
  112. && !string.IsNullOrWhiteSpace(x.ParentItemNum))
  113. .GroupBy(x => (SalesOrderNo: x.SalesOrderNo!.Trim(), ParentItemNum: x.ParentItemNum!.Trim()))
  114. .ToList();
  115. // 没有有效分组 key 的行视为无法匹配
  116. var unmatched = new List<string>();
  117. var noKeyItems = input.Items
  118. .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum)
  119. && (string.IsNullOrWhiteSpace(x.SalesOrderNo) || string.IsNullOrWhiteSpace(x.ParentItemNum)))
  120. .ToList();
  121. if (noKeyItems.Count > 0)
  122. unmatched.Add($"缺少订单号或父物料号:{noKeyItems.Count} 行");
  123. // 3) 预匹配:在写入之前先将所有分组匹配到工单,有未匹配则整体中止
  124. var matchedGroups = new List<(IGrouping<(string SalesOrderNo, string ParentItemNum), BomItemDto> Grp, WoInfo Wo)>();
  125. foreach (var grp in groups)
  126. {
  127. var woRow = await _db.Ado.SqlQuerySingleAsync<WoInfo>(
  128. """
  129. SELECT RecID AS RecId, WorkOrd, `Domain`, LOWER(TRIM(IFNULL(Status,''))) AS Status
  130. FROM WorkOrdMaster
  131. WHERE tenant_id = @TenantId
  132. AND TRIM(IFNULL(SalesJob,'')) = @SalesOrderNo
  133. AND TRIM(IFNULL(ItemNum,'')) = @ParentItemNum
  134. AND LOWER(TRIM(IFNULL(Status,''))) = 'p'
  135. LIMIT 1
  136. """,
  137. new SugarParameter("@TenantId", tid),
  138. new SugarParameter("@SalesOrderNo", grp.Key.SalesOrderNo),
  139. new SugarParameter("@ParentItemNum", grp.Key.ParentItemNum));
  140. if (woRow == null)
  141. unmatched.Add($"订单[{grp.Key.SalesOrderNo}] + 物料[{grp.Key.ParentItemNum}]");
  142. else
  143. matchedGroups.Add((grp, woRow));
  144. }
  145. // 有未匹配组 → 整体中止,不写入任何数据
  146. if (unmatched.Count > 0)
  147. {
  148. return new BomUploadResult
  149. {
  150. Inserted = 0, Updated = 0, NewItems = 0, Total = input.Items.Count,
  151. MatchedWorkOrders = 0, Unmatched = unmatched
  152. };
  153. }
  154. await _db.Ado.BeginTranAsync();
  155. try
  156. {
  157. // 4) 创建缺失物料到 ItemMaster
  158. if (input.CreateMissingItems)
  159. {
  160. var missingItems = input.Items
  161. .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum) && !existingSet.Contains(x.ItemNum.Trim()))
  162. .GroupBy(x => x.ItemNum.Trim())
  163. .Select(g => g.First())
  164. .ToList();
  165. var now = DateTime.Now;
  166. // 获取一个默认 Domain(取第一个匹配工单的,或兜底 8010)
  167. var defaultDomain = "8010";
  168. foreach (var item in missingItems)
  169. {
  170. var itemNum = item.ItemNum.Trim();
  171. await _db.Ado.ExecuteCommandAsync(
  172. """
  173. INSERT IGNORE INTO ItemMaster (
  174. tenant_id, ItemNum, Descr, Descr1, UM,
  175. Status, IsActive, Domain, factory_ref_id,
  176. CreateTime, CreateUser, UpdateTime, UpdateUser
  177. ) VALUES (
  178. @TenantId, @ItemNum, @Descr, @Descr1, @UM,
  179. '', 1, @Domain, @FactoryRefId,
  180. @Now, @User, @Now, @User
  181. )
  182. """,
  183. new SugarParameter("@TenantId", tid),
  184. new SugarParameter("@ItemNum", itemNum),
  185. new SugarParameter("@Descr", item.Descr ?? string.Empty),
  186. new SugarParameter("@Descr1", item.Descr1 ?? (object)DBNull.Value),
  187. new SugarParameter("@UM", item.UM ?? (object)DBNull.Value),
  188. new SugarParameter("@Domain", defaultDomain),
  189. new SugarParameter("@FactoryRefId", factoryRefId),
  190. new SugarParameter("@Now", now),
  191. new SugarParameter("@User", account));
  192. newItemsCount++;
  193. existingSet.Add(itemNum);
  194. }
  195. }
  196. // 5) 逐组写入 WorkOrdDetail(已预匹配,无需再查)
  197. var inserted = 0;
  198. var updated = 0;
  199. var matchedWorkOrders = 0;
  200. foreach (var (grp, woRow) in matchedGroups)
  201. {
  202. var domain = string.IsNullOrWhiteSpace(woRow.Domain) ? "8010" : woRow.Domain;
  203. // 加载该工单已有的 WorkOrdDetail
  204. var existingDetails = await _db.Ado.SqlQueryAsync<ExistingDetailRow>(
  205. """
  206. SELECT RecID, ItemNum, QtyRequired
  207. FROM WorkOrdDetail
  208. WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1
  209. """,
  210. new SugarParameter("@TenantId", tid),
  211. new SugarParameter("@WorkOrd", woRow.WorkOrd));
  212. var existingMap = existingDetails.ToDictionary(x => x.ItemNum, x => x, StringComparer.OrdinalIgnoreCase);
  213. var line = existingDetails.Count + 1;
  214. var now2 = DateTime.Now;
  215. // 按子物料 ItemNum 去重(同一物料累加需求数量)
  216. var groupedItems = grp
  217. .GroupBy(x => x.ItemNum.Trim())
  218. .Select(g => new
  219. {
  220. ItemNum = g.Key,
  221. Descr = g.First().Descr,
  222. Descr1 = g.First().Descr1,
  223. UM = g.First().UM,
  224. QtyRequired = g.Sum(x => x.QtyRequired),
  225. Op = g.First().Op
  226. })
  227. .ToList();
  228. foreach (var item in groupedItems)
  229. {
  230. var itemNum = item.ItemNum.Trim();
  231. if (existingMap.TryGetValue(itemNum, out var ex))
  232. {
  233. // 已有 → UPDATE QtyRequired
  234. await _db.Ado.ExecuteCommandAsync(
  235. """
  236. UPDATE WorkOrdDetail
  237. SET QtyRequired = @QtyRequired, UpdateUser = @User, UpdateTime = @Now
  238. WHERE RecID = @RecID AND tenant_id = @TenantId
  239. """,
  240. new SugarParameter("@QtyRequired", item.QtyRequired),
  241. new SugarParameter("@User", account),
  242. new SugarParameter("@Now", now2),
  243. new SugarParameter("@RecID", ex.RecID),
  244. new SugarParameter("@TenantId", tid));
  245. updated++;
  246. }
  247. else
  248. {
  249. // 新增 → INSERT
  250. await _db.Ado.ExecuteCommandAsync(
  251. """
  252. INSERT INTO WorkOrdDetail (
  253. WorkOrdMasterRecID, `Domain`, WorkOrd, LineNum, ItemNum, Op,
  254. Location, QtyRequired, QtyPosted, UM, IsActive, IsConfirm,
  255. CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
  256. ) VALUES (
  257. @MasterRecId, @Domain, @WorkOrd, @LineNum, @ItemNum, @Op,
  258. @Location, @QtyRequired, 0, @UM, 1, 0,
  259. @User, @Now, @User, @Now, @TenantId
  260. )
  261. """,
  262. new SugarParameter("@MasterRecId", woRow.RecId),
  263. new SugarParameter("@Domain", domain),
  264. new SugarParameter("@WorkOrd", woRow.WorkOrd),
  265. new SugarParameter("@LineNum", line),
  266. new SugarParameter("@ItemNum", itemNum),
  267. new SugarParameter("@Op", item.Op),
  268. new SugarParameter("@Location", DBNull.Value),
  269. new SugarParameter("@QtyRequired", item.QtyRequired),
  270. new SugarParameter("@UM", item.UM ?? (object)DBNull.Value),
  271. new SugarParameter("@User", account),
  272. new SugarParameter("@Now", now2),
  273. new SugarParameter("@TenantId", tid));
  274. inserted++;
  275. line++;
  276. }
  277. }
  278. matchedWorkOrders++;
  279. _logger.LogInformation(
  280. "[BomUpload] workOrd={WorkOrd} salesOrderNo={SalesOrderNo} parentItem={ParentItem} items={Items}",
  281. woRow.WorkOrd, grp.Key.SalesOrderNo, grp.Key.ParentItemNum, groupedItems.Count);
  282. }
  283. await _db.Ado.CommitTranAsync();
  284. _logger.LogInformation(
  285. "[BomUpload] tenantId={TenantId} matched={Matched} inserted={Inserted} updated={Updated} newItems={NewItems} unmatched={Unmatched}",
  286. tid, matchedWorkOrders, inserted, updated, newItemsCount, unmatched.Count);
  287. return new BomUploadResult
  288. {
  289. Inserted = inserted,
  290. Updated = updated,
  291. NewItems = newItemsCount,
  292. Total = input.Items.Count,
  293. MatchedWorkOrders = matchedWorkOrders,
  294. Unmatched = unmatched
  295. };
  296. }
  297. catch
  298. {
  299. await _db.Ado.RollbackTranAsync();
  300. throw;
  301. }
  302. }
  303. /// <summary>获取租户的 factory_ref_id(取 ItemMaster 中已有的最小 factory_ref_id,缺省 1)。</summary>
  304. private async Task<long> ResolveFactoryRefIdAsync(long tenantId)
  305. {
  306. var val = await _db.Ado.GetIntAsync(
  307. """
  308. SELECT IFNULL(MIN(factory_ref_id), 1) FROM ItemMaster
  309. WHERE tenant_id = @TenantId AND factory_ref_id > 0
  310. LIMIT 1
  311. """,
  312. new SugarParameter("@TenantId", tenantId));
  313. return val <= 0 ? 1 : val;
  314. }
  315. private sealed class WoInfo
  316. {
  317. public long RecId { get; set; }
  318. public string WorkOrd { get; set; } = string.Empty;
  319. public string? Domain { get; set; }
  320. public string? Status { get; set; }
  321. }
  322. private sealed class ExistingDetailRow
  323. {
  324. public long RecID { get; set; }
  325. public string ItemNum { get; set; } = string.Empty;
  326. public decimal QtyRequired { get; set; }
  327. }
  328. }