ProductDesignService.cs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571
  1. using Yitter.IdGenerator;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Sales;
  3. namespace Admin.NET.Plugin.AiDOP.Order;
  4. /// <summary>
  5. /// 产品设计服务(常规/非标;合同维度跟踪设计负责人、图号、BOM、工艺、图纸计划/实际时间)
  6. /// 路由前缀:/api/Order/productdesign/...
  7. ///
  8. /// 【租户安全边界】需认证访问(已移除类级 <c>[AllowAnonymous]</c>);租户一律经
  9. /// <c>AidopTenantScope.ResolveOrThrow</c> 从认证后 JWT 解析,无有效租户即拒绝,
  10. /// 不读前端 tenantId、无默认回退(原实现:类级匿名 + 直接取 <c>_userManager.TenantId</c>,
  11. /// 匿名时 TenantId=0 使租户过滤失效/查不到数据)。
  12. /// </summary>
  13. [ApiDescriptionSettings(Order = 255, Description = "产品设计")]
  14. [Route("api/Order")]
  15. [NonUnify]
  16. public class ProductDesignService : IDynamicApiController, ITransient
  17. {
  18. private readonly ISqlSugarClient _db;
  19. private readonly SqlSugarRepository<ProductDesign> _designRep;
  20. private readonly SqlSugarRepository<ProductDesignBom> _bomRep;
  21. private readonly SqlSugarRepository<ProductDesignRouting> _routingRep;
  22. private readonly UserManager _userManager;
  23. public ProductDesignService(
  24. ISqlSugarClient db,
  25. SqlSugarRepository<ProductDesign> designRep,
  26. SqlSugarRepository<ProductDesignBom> bomRep,
  27. SqlSugarRepository<ProductDesignRouting> routingRep,
  28. UserManager userManager)
  29. {
  30. _db = db;
  31. _designRep = designRep;
  32. _bomRep = bomRep;
  33. _routingRep = routingRep;
  34. _userManager = userManager;
  35. }
  36. [DisplayName("获取产品设计列表")]
  37. [HttpGet("productdesign/list")]
  38. public async Task<object> GetProductDesignList([FromQuery] ProductDesignListInput input)
  39. {
  40. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  41. var q = _db.Queryable<ProductDesign>()
  42. .Where(u => u.TenantId == tenantId)
  43. .WhereIF(!string.IsNullOrWhiteSpace(input.BillNo), u => u.BillNo.Contains(input.BillNo!.Trim()))
  44. .WhereIF(!string.IsNullOrWhiteSpace(input.ContractNo), u => u.ContractNo != null && u.ContractNo.Contains(input.ContractNo!.Trim()))
  45. .WhereIF(input.ProductKind is 1 or 2, u => u.ProductKind == input.ProductKind!.Value)
  46. .WhereIF(!string.IsNullOrWhiteSpace(input.DesignLeadName), u => u.DesignLeadName != null && u.DesignLeadName.Contains(input.DesignLeadName!.Trim()));
  47. var paged = await q.OrderByDescending(u => u.Id).ToPagedListAsync(input.Page, input.PageSize);
  48. var list = paged.Items.Select(u => new
  49. {
  50. id = u.Id,
  51. billNo = u.BillNo,
  52. contractNo = u.ContractNo,
  53. productKind = u.ProductKind,
  54. designLeadAccount = u.DesignLeadAccount,
  55. designLeadName = u.DesignLeadName,
  56. drawingNo = u.DrawingNo,
  57. drawingPlanStart = u.DrawingPlanStart?.ToString("yyyy-MM-dd HH:mm"),
  58. drawingPlanEnd = u.DrawingPlanEnd?.ToString("yyyy-MM-dd HH:mm"),
  59. drawingActualStart = u.DrawingActualStart?.ToString("yyyy-MM-dd HH:mm"),
  60. drawingActualEnd = u.DrawingActualEnd?.ToString("yyyy-MM-dd HH:mm"),
  61. drawingDesignCycle = u.DrawingDesignCycle,
  62. applicant = u.Applicant,
  63. applyDate = u.ApplyDate?.ToString("yyyy-MM-dd"),
  64. productModel = u.ProductModel,
  65. itemNum = u.ItemNum,
  66. productName = u.ProductName,
  67. createTime = u.CreateTime?.ToString("yyyy-MM-dd HH:mm:ss"),
  68. updateTime = u.UpdateTime?.ToString("yyyy-MM-dd HH:mm:ss"),
  69. }).ToList();
  70. return new { total = paged.Total, page = input.Page, pageSize = input.PageSize, list };
  71. }
  72. [DisplayName("获取产品设计详情")]
  73. [HttpGet("productdesign/{id:long}")]
  74. public async Task<object> GetProductDesignDetail(long id)
  75. {
  76. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  77. var m = await _designRep.GetFirstAsync(u => u.Id == id && u.TenantId == tenantId)
  78. ?? throw Oops.Oh("产品设计记录不存在");
  79. var boms = await _bomRep.GetListAsync(u => u.ProductDesignId == id && u.TenantId == tenantId);
  80. var routings = await _routingRep.GetListAsync(u => u.ProductDesignId == id && u.TenantId == tenantId);
  81. return new
  82. {
  83. id = m.Id,
  84. billNo = m.BillNo,
  85. contractNo = m.ContractNo,
  86. productKind = m.ProductKind,
  87. designLeadAccount = m.DesignLeadAccount,
  88. designLeadName = m.DesignLeadName,
  89. drawingNo = m.DrawingNo,
  90. drawingPlanStart = m.DrawingPlanStart?.ToString("yyyy-MM-dd HH:mm:ss"),
  91. drawingPlanEnd = m.DrawingPlanEnd?.ToString("yyyy-MM-dd HH:mm:ss"),
  92. drawingActualStart = m.DrawingActualStart?.ToString("yyyy-MM-dd HH:mm:ss"),
  93. drawingActualEnd = m.DrawingActualEnd?.ToString("yyyy-MM-dd HH:mm:ss"),
  94. drawingDesignCycle = m.DrawingDesignCycle,
  95. applicant = m.Applicant,
  96. applyDate = m.ApplyDate?.ToString("yyyy-MM-dd"),
  97. productModel = m.ProductModel,
  98. itemNum = m.ItemNum,
  99. productName = m.ProductName,
  100. language = m.Language,
  101. lineRemark = m.LineRemark,
  102. createUser = m.CreateUser,
  103. createTime = m.CreateTime?.ToString("yyyy-MM-dd HH:mm:ss"),
  104. updateUser = m.UpdateUser,
  105. updateTime = m.UpdateTime?.ToString("yyyy-MM-dd HH:mm:ss"),
  106. boms = boms.OrderBy(x => x.Seq).ThenBy(x => x.Id).Select(x => new
  107. {
  108. id = x.Id.ToString(),
  109. parentBomId = x.ParentBomId.HasValue ? x.ParentBomId.Value.ToString() : null,
  110. seq = x.Seq,
  111. itemNum = x.ItemNum,
  112. itemName = x.ItemName,
  113. processCode = x.ProcessCode,
  114. fixedLossQty = x.FixedLossQty,
  115. batchNo = x.BatchNo,
  116. }),
  117. routings = routings.OrderBy(x => x.Seq).ThenBy(x => x.Id).Select(x => new
  118. {
  119. id = x.Id,
  120. seq = x.Seq,
  121. opName = x.OpName,
  122. opCode = x.OpCode,
  123. isKeyProcess = x.IsKeyProcess,
  124. productionLine = x.ProductionLine,
  125. routeCode = x.RouteCode,
  126. }),
  127. };
  128. }
  129. [DisplayName("保存产品设计")]
  130. [ApiDescriptionSettings(Name = "SaveProductDesign"), HttpPost("productdesign/save")]
  131. public async Task<object> SaveProductDesign([FromBody] ProductDesignSaveInput input)
  132. {
  133. if (input.ProductKind is not (1 or 2))
  134. throw Oops.Oh("产品类型无效,请选择常规产品或非标产品");
  135. // 产品编码必填
  136. if (string.IsNullOrWhiteSpace(input.ItemNum))
  137. throw Oops.Oh("产品编码不能为空");
  138. // BOM 子表至少一条
  139. if (input.Boms == null || input.Boms.Count == 0)
  140. throw Oops.Oh("制造 BOM 至少需要一条数据");
  141. // 工艺子表至少一条
  142. if (input.Routings == null || input.Routings.Count == 0)
  143. throw Oops.Oh("工艺路线至少需要一条数据");
  144. // BOM 物料编码必填
  145. if (input.Boms.Any(b => string.IsNullOrWhiteSpace(b.ItemNum)))
  146. throw Oops.Oh("制造 BOM 中物料编码不能为空");
  147. // 工艺工序代码必填
  148. if (input.Routings.Any(r => string.IsNullOrWhiteSpace(r.OpCode)))
  149. throw Oops.Oh("工艺路线中工序代码不能为空");
  150. var now = DateTime.Now;
  151. var user = _userManager.Account ?? "system";
  152. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  153. await _db.Ado.BeginTranAsync();
  154. try
  155. {
  156. long designId;
  157. if (input.Id is null or 0)
  158. {
  159. var month = now.ToString("yyyyMM");
  160. var maxSeq = await _db.Ado.GetIntAsync(
  161. $"SELECT IFNULL(MAX(CAST(SUBSTRING(BillNo, 9) AS UNSIGNED)), 0) FROM ado_product_design WHERE BillNo LIKE 'PD{month}%' AND tenant_id = @TenantId",
  162. new SugarParameter("@TenantId", tenantId));
  163. var billNo = $"PD{month}{(maxSeq + 1):D4}";
  164. designId = YitIdHelper.NextId();
  165. var entity = MapMaster(new ProductDesign { Id = designId, BillNo = billNo, TenantId = tenantId }, input, user, now, isNew: true);
  166. await _designRep.InsertAsync(entity);
  167. await SaveBomsAsync(designId, tenantId, input.Boms ?? new List<ProductDesignBomInput>(), isNew: true);
  168. await SaveRoutingsAsync(designId, tenantId, input.Routings ?? new List<ProductDesignRoutingInput>(), isNew: true);
  169. await _db.Ado.CommitTranAsync();
  170. return new { id = designId, billNo, message = "新增成功" };
  171. }
  172. var existing = await _designRep.GetFirstAsync(u => u.Id == input.Id!.Value && u.TenantId == tenantId)
  173. ?? throw Oops.Oh("产品设计记录不存在");
  174. designId = existing.Id;
  175. MapMaster(existing, input, user, now, isNew: false);
  176. await _designRep.UpdateAsync(existing);
  177. await SaveBomsAsync(designId, tenantId, input.Boms ?? new List<ProductDesignBomInput>(), isNew: false);
  178. await SaveRoutingsAsync(designId, tenantId, input.Routings ?? new List<ProductDesignRoutingInput>(), isNew: false);
  179. await _db.Ado.CommitTranAsync();
  180. return new { id = designId, billNo = existing.BillNo, message = "编辑成功" };
  181. }
  182. catch
  183. {
  184. await _db.Ado.RollbackTranAsync();
  185. throw;
  186. }
  187. }
  188. [DisplayName("删除产品设计")]
  189. [ApiDescriptionSettings(Name = "DeleteProductDesign"), HttpPost("productdesign/delete")]
  190. public async Task<object> DeleteProductDesign([FromBody] ProductDesignDeleteInput input)
  191. {
  192. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  193. var entity = await _designRep.GetFirstAsync(u => u.Id == input.Id && u.TenantId == tenantId)
  194. ?? throw Oops.Oh("产品设计记录不存在");
  195. await _db.Ado.BeginTranAsync();
  196. try
  197. {
  198. await _bomRep.DeleteAsync(u => u.ProductDesignId == entity.Id && u.TenantId == tenantId);
  199. await _routingRep.DeleteAsync(u => u.ProductDesignId == entity.Id && u.TenantId == tenantId);
  200. await _designRep.DeleteAsync(u => u.Id == entity.Id);
  201. await _db.Ado.CommitTranAsync();
  202. return new { message = "删除成功" };
  203. }
  204. catch
  205. {
  206. await _db.Ado.RollbackTranAsync();
  207. throw;
  208. }
  209. }
  210. [DisplayName("根据产品编码获取BOM和工艺")]
  211. [HttpGet("productdesign/bom-routing")]
  212. public async Task<BomAndRoutingOutput> GetBomAndRouting([FromQuery] string itemNum)
  213. {
  214. if (string.IsNullOrWhiteSpace(itemNum))
  215. return new BomAndRoutingOutput();
  216. // 🔴 租户硬门:本方法的两段裸 SQL 走 _db.Ado.SqlQueryAsync,**完全绕过** SqlSugar 的
  217. // QueryFilter(后者只作用于 Queryable<T>),必须自己带 tenant_id。
  218. // 租户经 AidopTenantScope.ResolveOrThrow 严格解析(仅认证后 JWT),无有效租户直接拒绝。
  219. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  220. // 查询图纸设计周期
  221. var drawingDesignCycle = await _db.Queryable<AdoS0ItemMaster>()
  222. .Where(x => x.TenantId == tenantId && x.ItemNum == itemNum && x.IsActive == true)
  223. .Select(x => x.DrawingDesign)
  224. .FirstAsync();
  225. // 🔴 七处 tenant_id 谓词缺一不可(2026-09-08 实测,逐条见下)。
  226. //
  227. // **只在最外层 WHERE 过滤是无效的**,有三层原因:
  228. // ① 语法上不成立 —— CTE 的列清单里根本没有 tenant_id;
  229. // ② 即便把 tenant_id 加进 CTE 列再只在外层过滤,仍会残留"血统不明的自家行" ——
  230. // 实测料号 3124C0015:外层过滤版比正确版多出 15/15/150 行,这些行自身的
  231. // tenant_id 等于查询租户、外层删不掉,但它们是靠"经过外租户的边"才被走到的节点
  232. // (ItemMaster 有 663 个跨租户重码,递归中间层一旦串到别人的图上就会这样回落);
  233. // ③ 最外层的 LEFT JOIN ItemMaster 本身就是独立放大器 —— 实测 452 行 CTE 经它膨胀到 1120 行。
  234. //
  235. // ⚠️ ④⑤⑦ 三处**必须放在 ON 而不是 WHERE**:放 WHERE 会把无匹配的 NULL 行滤掉,
  236. // LEFT JOIN 退化成 INNER JOIN,没有物料主数据/产线的 BOM 行会整行消失。
  237. var bomSql = @"
  238. WITH RECURSIVE temp(ParentItem,ComponentItem,op,qty,StructureType,QtyConsumed) AS (
  239. SELECT ParentItem,ComponentItem,op,qty,StructureType,QtyConsumed
  240. FROM ProductStructureMaster WHERE ParentItem=@itemNum AND tenant_id=@tenantId
  241. UNION ALL
  242. SELECT c.ParentItem,c.ComponentItem,c.op,
  243. CAST(c.qty+(c.qty*(c.Scrap+c.QtyExchd)/100) AS DECIMAL(15,8)) AS qty,
  244. c.StructureType,c.QtyConsumed
  245. FROM ProductStructureMaster c
  246. INNER JOIN temp p ON c.ParentItem=p.ComponentItem
  247. INNER JOIN ItemMaster parentIm ON c.ParentItem=parentIm.ItemNum AND parentIm.PurMfg<>'P'
  248. AND parentIm.tenant_id=@tenantId
  249. WHERE c.tenant_id=@tenantId
  250. )
  251. SELECT psm.ParentItem,psm.ComponentItem AS ItemNum,im.Descr AS ItemName,
  252. CASE WHEN IFNULL(pso.Op,0)=0 THEN psm.Op ELSE pso.Op END AS Op,
  253. psm.qty AS Qty,psm.StructureType,im.EMTType AS EmtType,psm.QtyConsumed
  254. FROM temp psm
  255. LEFT JOIN ItemMaster im ON psm.ComponentItem=im.ItemNum
  256. AND im.tenant_id=@tenantId
  257. LEFT JOIN ProductStructureOp pso ON pso.ParentItem=psm.ParentItem
  258. AND pso.ComponentItem=psm.ComponentItem AND pso.ProductItem=@itemNum
  259. AND pso.tenant_id=@tenantId";
  260. var routingSql = @"
  261. SELECT r.Descr,r.Op,r.ParentOp,CAST(r.MilestoneOp AS CHAR(5)) AS MilestoneOp,p.Line,r.RouteCode
  262. FROM RoutingOpDetail as r left join ProdLineDetail as p on r.RoutingCode=p.Part and r.Op=p.Op
  263. and p.tenant_id=@tenantId
  264. WHERE r.RoutingCode=@itemNum AND r.tenant_id=@tenantId ORDER BY r.Op";
  265. var boms = await _db.Ado.SqlQueryAsync<BomQueryRow>(bomSql, new { itemNum, tenantId });
  266. var routings = await _db.Ado.SqlQueryAsync<RoutingQueryRow>(routingSql, new { itemNum, tenantId });
  267. return new BomAndRoutingOutput
  268. {
  269. Boms = boms,
  270. Routings = routings,
  271. DrawingDesignCycle = drawingDesignCycle
  272. };
  273. }
  274. private static ProductDesign MapMaster(ProductDesign entity, ProductDesignSaveInput input, string user, DateTime now, bool isNew)
  275. {
  276. entity.ContractNo = string.IsNullOrWhiteSpace(input.ContractNo) ? null : input.ContractNo.Trim();
  277. entity.ProductKind = input.ProductKind;
  278. entity.DesignLeadAccount = string.IsNullOrWhiteSpace(input.DesignLeadAccount) ? null : input.DesignLeadAccount.Trim();
  279. entity.DesignLeadName = string.IsNullOrWhiteSpace(input.DesignLeadName) ? null : input.DesignLeadName.Trim();
  280. entity.DrawingNo = string.IsNullOrWhiteSpace(input.DrawingNo) ? null : input.DrawingNo.Trim();
  281. entity.DrawingPlanStart = ParseOptionalDate(input.DrawingPlanStart);
  282. entity.DrawingPlanEnd = ParseOptionalDate(input.DrawingPlanEnd);
  283. entity.DrawingActualStart = ParseOptionalDate(input.DrawingActualStart);
  284. entity.DrawingActualEnd = ParseOptionalDate(input.DrawingActualEnd);
  285. entity.DrawingDesignCycle = input.DrawingDesignCycle;
  286. entity.Applicant = string.IsNullOrWhiteSpace(input.Applicant) ? null : input.Applicant.Trim();
  287. entity.ApplyDate = ParseOptionalDate(input.ApplyDate);
  288. entity.ProductModel = string.IsNullOrWhiteSpace(input.ProductModel) ? null : input.ProductModel.Trim();
  289. entity.ItemNum = string.IsNullOrWhiteSpace(input.ItemNum) ? null : input.ItemNum.Trim();
  290. entity.ProductName = string.IsNullOrWhiteSpace(input.ProductName) ? null : input.ProductName.Trim();
  291. entity.Language = string.IsNullOrWhiteSpace(input.Language) ? null : input.Language.Trim();
  292. entity.Qty = null;
  293. entity.LineRemark = string.IsNullOrWhiteSpace(input.LineRemark) ? null : input.LineRemark.Trim();
  294. if (isNew)
  295. {
  296. entity.CreateUser = user;
  297. entity.CreateTime = now;
  298. entity.IsActive = 1;
  299. }
  300. else
  301. {
  302. entity.UpdateUser = user;
  303. entity.UpdateTime = now;
  304. }
  305. return entity;
  306. }
  307. private static DateTime? ParseOptionalDate(string? s)
  308. {
  309. if (string.IsNullOrWhiteSpace(s)) return null;
  310. return DateTime.TryParse(s.Trim(), out var dt) ? dt : null;
  311. }
  312. private async Task SaveBomsAsync(long designId, long tenantId, List<ProductDesignBomInput> items, bool isNew)
  313. {
  314. var dbRows = isNew ? new List<ProductDesignBom>()
  315. : await _bomRep.GetListAsync(u => u.ProductDesignId == designId && u.TenantId == tenantId);
  316. var dbById = dbRows.ToDictionary(u => u.Id);
  317. if (items.Any(x => !x.Id.HasValue))
  318. throw Oops.Oh("BOM 每行必须包含 Id(已保存行为正数,新行请使用负数临时 Id)");
  319. var ordered = OrderBomsForSave(items);
  320. var tempToReal = new Dictionary<long, long>();
  321. var keptIds = new HashSet<long>();
  322. var idx = 0;
  323. foreach (var d in ordered)
  324. {
  325. idx++;
  326. var seq = d.Seq ?? idx;
  327. var parentResolved = ResolveBomParentId(d.ParentBomId, tempToReal);
  328. if (d.Id is > 0 && dbById.TryGetValue(d.Id.Value, out var existing))
  329. {
  330. if (existing.ProductDesignId != designId) continue;
  331. existing.ParentBomId = parentResolved;
  332. existing.Seq = seq;
  333. existing.ItemNum = string.IsNullOrWhiteSpace(d.ItemNum) ? null : d.ItemNum.Trim();
  334. existing.ItemName = string.IsNullOrWhiteSpace(d.ItemName) ? null : d.ItemName.Trim();
  335. existing.ProcessCode = string.IsNullOrWhiteSpace(d.ProcessCode) ? null : d.ProcessCode.Trim();
  336. existing.Qty = d.Qty;
  337. existing.FixedLossQty = d.FixedLossQty;
  338. existing.BatchNo = string.IsNullOrWhiteSpace(d.BatchNo) ? null : d.BatchNo.Trim();
  339. await _bomRep.UpdateAsync(existing);
  340. keptIds.Add(existing.Id);
  341. }
  342. else if (d.Id is < 0)
  343. {
  344. var newId = YitIdHelper.NextId();
  345. tempToReal[d.Id.Value] = newId;
  346. var row = new ProductDesignBom
  347. {
  348. Id = newId,
  349. ProductDesignId = designId,
  350. TenantId = tenantId,
  351. ParentBomId = parentResolved,
  352. Seq = seq,
  353. ItemNum = string.IsNullOrWhiteSpace(d.ItemNum) ? null : d.ItemNum.Trim(),
  354. ItemName = string.IsNullOrWhiteSpace(d.ItemName) ? null : d.ItemName.Trim(),
  355. ProcessCode = string.IsNullOrWhiteSpace(d.ProcessCode) ? null : d.ProcessCode.Trim(),
  356. Qty = d.Qty,
  357. FixedLossQty = d.FixedLossQty,
  358. BatchNo = string.IsNullOrWhiteSpace(d.BatchNo) ? null : d.BatchNo.Trim(),
  359. };
  360. await _bomRep.InsertAsync(row);
  361. keptIds.Add(newId);
  362. }
  363. }
  364. foreach (var row in dbRows.Where(u => !keptIds.Contains(u.Id)))
  365. await _bomRep.DeleteAsync(u => u.Id == row.Id && u.TenantId == tenantId);
  366. }
  367. /// <summary>按父子顺序排列:父(含库中已有 Id)先于子;负数临时 Id 先于引用它的子行。</summary>
  368. private static List<ProductDesignBomInput> OrderBomsForSave(List<ProductDesignBomInput> items)
  369. {
  370. var withId = items.Where(x => x.Id.HasValue).ToList();
  371. var inBatch = withId.Select(x => x.Id!.Value).ToHashSet();
  372. var done = new HashSet<long>();
  373. var result = new List<ProductDesignBomInput>();
  374. var remaining = withId.ToList();
  375. bool CanTake(ProductDesignBomInput r)
  376. {
  377. var p = r.ParentBomId;
  378. if (p is null or 0) return true;
  379. if (p < 0) return done.Contains(p.Value);
  380. if (inBatch.Contains(p.Value)) return done.Contains(p.Value);
  381. return true;
  382. }
  383. while (remaining.Count > 0)
  384. {
  385. var batch = remaining.Where(CanTake).ToList();
  386. if (batch.Count == 0)
  387. throw Oops.Oh("BOM 父子关系无效(循环或缺失父节点)");
  388. foreach (var b in batch)
  389. {
  390. result.Add(b);
  391. remaining.Remove(b);
  392. if (b.Id.HasValue)
  393. done.Add(b.Id.Value);
  394. }
  395. }
  396. return result;
  397. }
  398. private static long? ResolveBomParentId(long? parentBomId, Dictionary<long, long> tempToReal)
  399. {
  400. if (parentBomId is null or 0) return null;
  401. if (parentBomId.Value < 0)
  402. {
  403. if (tempToReal.TryGetValue(parentBomId.Value, out var real))
  404. return real;
  405. throw Oops.Oh("BOM 父节点未找到,请检查父子顺序");
  406. }
  407. return parentBomId;
  408. }
  409. private async Task SaveRoutingsAsync(long designId, long tenantId, List<ProductDesignRoutingInput> items, bool isNew)
  410. {
  411. var dbRows = isNew ? new List<ProductDesignRouting>()
  412. : await _routingRep.GetListAsync(u => u.ProductDesignId == designId && u.TenantId == tenantId);
  413. var dbById = dbRows.ToDictionary(u => u.Id);
  414. var inputIds = new HashSet<long>(items.Where(d => d.Id is > 0).Select(d => d.Id!.Value));
  415. var idx = 0;
  416. foreach (var d in items)
  417. {
  418. idx++;
  419. var seq = d.Seq ?? idx;
  420. if (d.Id is > 0 && dbById.TryGetValue(d.Id.Value, out var existing))
  421. {
  422. if (existing.ProductDesignId != designId) continue;
  423. existing.Seq = seq;
  424. existing.OpName = string.IsNullOrWhiteSpace(d.OpName) ? null : d.OpName.Trim();
  425. existing.OpCode = string.IsNullOrWhiteSpace(d.OpCode) ? null : d.OpCode.Trim();
  426. existing.ParentOpCode = null;
  427. existing.IsKeyProcess = d.IsKeyProcess;
  428. existing.ProductionLine = string.IsNullOrWhiteSpace(d.ProductionLine) ? null : d.ProductionLine.Trim();
  429. existing.RouteCode = string.IsNullOrWhiteSpace(d.RouteCode) ? null : d.RouteCode.Trim();
  430. await _routingRep.UpdateAsync(existing);
  431. }
  432. else
  433. {
  434. var row = new ProductDesignRouting
  435. {
  436. Id = YitIdHelper.NextId(),
  437. ProductDesignId = designId,
  438. TenantId = tenantId,
  439. Seq = seq,
  440. OpName = string.IsNullOrWhiteSpace(d.OpName) ? null : d.OpName.Trim(),
  441. OpCode = string.IsNullOrWhiteSpace(d.OpCode) ? null : d.OpCode.Trim(),
  442. ParentOpCode = null,
  443. IsKeyProcess = d.IsKeyProcess,
  444. ProductionLine = string.IsNullOrWhiteSpace(d.ProductionLine) ? null : d.ProductionLine.Trim(),
  445. RouteCode = string.IsNullOrWhiteSpace(d.RouteCode) ? null : d.RouteCode.Trim(),
  446. };
  447. await _routingRep.InsertAsync(row);
  448. }
  449. }
  450. foreach (var toDelete in dbRows.Where(u => !inputIds.Contains(u.Id)))
  451. await _routingRep.DeleteAsync(u => u.Id == toDelete.Id && u.TenantId == tenantId);
  452. }
  453. /// <summary>获取合同下拉选项</summary>
  454. [DisplayName("获取合同下拉选项")]
  455. [HttpGet("productdesign/contract-options")]
  456. public async Task<List<object>> GetContractOptions([FromQuery] string? keyword)
  457. {
  458. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  459. var sql = string.IsNullOrWhiteSpace(keyword)
  460. ? "SELECT BillNo, CONCAT(BillNo, ' | ', IFNULL(Title,'')) AS Label FROM ado_contract_review WHERE tenant_id = @tenantId ORDER BY RecID DESC LIMIT 50"
  461. : "SELECT BillNo, CONCAT(BillNo, ' | ', IFNULL(Title,'')) AS Label FROM ado_contract_review WHERE tenant_id = @tenantId AND (BillNo LIKE @kw OR Title LIKE @kw) ORDER BY RecID DESC LIMIT 50";
  462. var rows = await _db.Ado.SqlQueryAsync<ContractOptionRow>(sql,
  463. new { tenantId, kw = string.IsNullOrWhiteSpace(keyword) ? null : $"%{keyword.Trim()}%" });
  464. return rows.Select(r => (object)new { value = r.BillNo, label = r.Label }).ToList();
  465. }
  466. /// <summary>获取用户下拉选项</summary>
  467. [DisplayName("获取用户下拉选项")]
  468. [HttpGet("productdesign/user-options")]
  469. public async Task<List<object>> GetUserOptions([FromQuery] string? keyword)
  470. {
  471. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  472. var sql = string.IsNullOrWhiteSpace(keyword)
  473. ? "SELECT Id, Account, RealName FROM SysUser WHERE TenantId = @tenantId AND Status = 1 ORDER BY OrderNo LIMIT 200"
  474. : "SELECT Id, Account, RealName FROM SysUser WHERE TenantId = @tenantId AND Status = 1 AND (Account LIKE @kw OR RealName LIKE @kw) ORDER BY OrderNo LIMIT 200";
  475. var rows = await _db.Ado.SqlQueryAsync<UserOptionRow>(sql,
  476. new { tenantId, kw = string.IsNullOrWhiteSpace(keyword) ? null : $"%{keyword.Trim()}%" });
  477. return rows.Select(r => (object)new { value = r.Account, label = $"{r.RealName} ({r.Account})" }).ToList();
  478. }
  479. /// <summary>获取产线下拉选项</summary>
  480. [DisplayName("获取产线下拉选项")]
  481. [HttpGet("productdesign/line-options")]
  482. public async Task<List<object>> GetLineOptions([FromQuery] string? keyword)
  483. {
  484. var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);
  485. var sql = string.IsNullOrWhiteSpace(keyword)
  486. ? "SELECT Line, `Describe` FROM LineMaster WHERE tenant_id = @tenantId AND IsActive = 1 ORDER BY Line LIMIT 200"
  487. : "SELECT Line, `Describe` FROM LineMaster WHERE tenant_id = @tenantId AND IsActive = 1 AND (Line LIKE @kw OR `Describe` LIKE @kw) ORDER BY Line LIMIT 200";
  488. var rows = await _db.Ado.SqlQueryAsync<LineOptionRow>(sql,
  489. new { tenantId, kw = string.IsNullOrWhiteSpace(keyword) ? null : $"%{keyword.Trim()}%" });
  490. return rows.Select(r => (object)new { value = r.Line, label = $"{r.Line} | {r.Describe}" }).ToList();
  491. }
  492. private sealed class ContractOptionRow
  493. {
  494. public string BillNo { get; set; } = string.Empty;
  495. public string Label { get; set; } = string.Empty;
  496. }
  497. private sealed class UserOptionRow
  498. {
  499. public long Id { get; set; }
  500. public string Account { get; set; } = string.Empty;
  501. public string RealName { get; set; } = string.Empty;
  502. }
  503. private sealed class LineOptionRow
  504. {
  505. public string Line { get; set; } = string.Empty;
  506. public string Describe { get; set; } = string.Empty;
  507. }
  508. }