| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363 |
- using Admin.NET.Plugin.AiDOP.Infrastructure;
- using Microsoft.Extensions.Logging;
- namespace Admin.NET.Plugin.AiDOP.WorkOrder;
- /// <summary>
- /// 工单 BOM 上传服务 — 从 Excel 解析结果按(订单号+父物料号)匹配未下达工单,
- /// 批量写入 ItemMaster + WorkOrdDetail。
- /// 路由前缀:/api/WorkOrder/dispatch/bom/...
- ///
- /// 【租户安全边界】需认证访问;租户一律经
- /// <c>AidopTenantScope.ResolveOrThrow</c> 从认证后 JWT 解析。
- /// </summary>
- [ApiDescriptionSettings(Order = 258, Description = "工单BOM上传")]
- [Route("api/WorkOrder")]
- [NonUnify]
- public class WorkOrderBomUploadService : IDynamicApiController, ITransient
- {
- private readonly ISqlSugarClient _db;
- private readonly UserManager _userManager;
- private readonly ILogger<WorkOrderBomUploadService> _logger;
- public WorkOrderBomUploadService(
- ISqlSugarClient db,
- UserManager userManager,
- ILogger<WorkOrderBomUploadService> logger)
- {
- _db = db;
- _userManager = userManager;
- _logger = logger;
- }
- private long ResolveTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
- // ══════════════════════════════════════════════════════════════
- // POST /api/WorkOrder/dispatch/bom/check-materials
- // ══════════════════════════════════════════════════════════════
- /// <summary>校验 BOM 子物料是否在 ItemMaster 中存在。</summary>
- [DisplayName("BOM物料存在性校验")]
- [ApiDescriptionSettings(Name = "BomCheckMaterials"), HttpPost("dispatch/bom/check-materials")]
- public async Task<BomCheckResult> CheckMaterials([FromBody] BomCheckMaterialsInput input)
- {
- var tid = ResolveTenantId();
- if (input.Items == null || input.Items.Count == 0)
- throw Oops.Oh("BOM 物料列表为空");
- var itemNums = input.Items
- .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum))
- .Select(x => x.ItemNum.Trim())
- .Distinct()
- .ToList();
- if (itemNums.Count == 0)
- throw Oops.Oh("BOM 中无有效的物料编码");
- // 查询本租户下已存在的物料编码
- var existingSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
- var pars = new List<SugarParameter> { new("@TenantId", tid) };
- for (var i = 0; i < itemNums.Count; i++)
- pars.Add(new SugarParameter($"@IN{i}", itemNums[i]));
- var inClause = string.Join(", ", Enumerable.Range(0, itemNums.Count).Select(i => $"@IN{i}"));
- var existing = await _db.Ado.SqlQueryAsync<string>(
- $"""
- SELECT DISTINCT ItemNum FROM ItemMaster
- WHERE tenant_id = @TenantId AND ItemNum IN ({inClause})
- """,
- pars);
- foreach (var e in existing)
- existingSet.Add(e);
- return new BomCheckResult
- {
- Existing = existingSet.ToList(),
- Missing = input.Items
- .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum) && !existingSet.Contains(x.ItemNum.Trim()))
- .GroupBy(x => x.ItemNum.Trim())
- .Select(g => g.First())
- .ToList()
- };
- }
- // ══════════════════════════════════════════════════════════════
- // POST /api/WorkOrder/dispatch/bom/upload
- // ══════════════════════════════════════════════════════════════
- /// <summary>
- /// 写入 BOM:按(订单号+父物料号)匹配未下达工单,可选新建 ItemMaster 缺失物料,
- /// 然后 Upsert WorkOrdDetail。
- /// </summary>
- [DisplayName("BOM写入工单物料明细")]
- [ApiDescriptionSettings(Name = "BomUpload"), HttpPost("dispatch/bom/upload")]
- public async Task<BomUploadResult> Upload([FromBody] BomUploadInput input)
- {
- var tid = ResolveTenantId();
- if (input.Items == null || input.Items.Count == 0)
- throw Oops.Oh("BOM 物料列表为空");
- var account = _userManager.Account ?? "system";
- var factoryRefId = await ResolveFactoryRefIdAsync(tid);
- // 1) 查找哪些子物料需要新建(ItemMaster 存在性检查)
- var itemNums = input.Items
- .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum))
- .Select(x => x.ItemNum.Trim())
- .Distinct()
- .ToList();
- var existingSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
- if (itemNums.Count > 0)
- {
- var pars = new List<SugarParameter> { new("@TenantId", tid) };
- for (var i = 0; i < itemNums.Count; i++)
- pars.Add(new SugarParameter($"@IN{i}", itemNums[i]));
- var inClause = string.Join(", ", Enumerable.Range(0, itemNums.Count).Select(i => $"@IN{i}"));
- var existing = await _db.Ado.SqlQueryAsync<string>(
- $"SELECT DISTINCT ItemNum FROM ItemMaster WHERE tenant_id = @TenantId AND ItemNum IN ({inClause})",
- pars);
- foreach (var e in existing)
- existingSet.Add(e);
- }
- var newItemsCount = 0;
- // 2) 按(SalesOrderNo, ParentItemNum)分组 → 每组对应一个工单
- var groups = input.Items
- .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum)
- && !string.IsNullOrWhiteSpace(x.SalesOrderNo)
- && !string.IsNullOrWhiteSpace(x.ParentItemNum))
- .GroupBy(x => (SalesOrderNo: x.SalesOrderNo!.Trim(), ParentItemNum: x.ParentItemNum!.Trim()))
- .ToList();
- // 没有有效分组 key 的行视为无法匹配
- var unmatched = new List<string>();
- var noKeyItems = input.Items
- .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum)
- && (string.IsNullOrWhiteSpace(x.SalesOrderNo) || string.IsNullOrWhiteSpace(x.ParentItemNum)))
- .ToList();
- if (noKeyItems.Count > 0)
- unmatched.Add($"缺少订单号或父物料号:{noKeyItems.Count} 行");
- // 3) 预匹配:在写入之前先将所有分组匹配到工单,有未匹配则整体中止
- var matchedGroups = new List<(IGrouping<(string SalesOrderNo, string ParentItemNum), BomItemDto> Grp, WoInfo Wo)>();
- foreach (var grp in groups)
- {
- var woRow = await _db.Ado.SqlQuerySingleAsync<WoInfo>(
- """
- SELECT RecID AS RecId, WorkOrd, `Domain`, LOWER(TRIM(IFNULL(Status,''))) AS Status
- FROM WorkOrdMaster
- WHERE tenant_id = @TenantId
- AND TRIM(IFNULL(SalesJob,'')) = @SalesOrderNo
- AND TRIM(IFNULL(ItemNum,'')) = @ParentItemNum
- AND LOWER(TRIM(IFNULL(Status,''))) = 'p'
- LIMIT 1
- """,
- new SugarParameter("@TenantId", tid),
- new SugarParameter("@SalesOrderNo", grp.Key.SalesOrderNo),
- new SugarParameter("@ParentItemNum", grp.Key.ParentItemNum));
- if (woRow == null)
- unmatched.Add($"订单[{grp.Key.SalesOrderNo}] + 物料[{grp.Key.ParentItemNum}]");
- else
- matchedGroups.Add((grp, woRow));
- }
- // 有未匹配组 → 整体中止,不写入任何数据
- if (unmatched.Count > 0)
- {
- return new BomUploadResult
- {
- Inserted = 0, Updated = 0, NewItems = 0, Total = input.Items.Count,
- MatchedWorkOrders = 0, Unmatched = unmatched
- };
- }
- await _db.Ado.BeginTranAsync();
- try
- {
- // 4) 创建缺失物料到 ItemMaster
- if (input.CreateMissingItems)
- {
- var missingItems = input.Items
- .Where(x => !string.IsNullOrWhiteSpace(x.ItemNum) && !existingSet.Contains(x.ItemNum.Trim()))
- .GroupBy(x => x.ItemNum.Trim())
- .Select(g => g.First())
- .ToList();
- var now = DateTime.Now;
- // 获取一个默认 Domain(取第一个匹配工单的,或兜底 8010)
- var defaultDomain = "8010";
- foreach (var item in missingItems)
- {
- var itemNum = item.ItemNum.Trim();
- await _db.Ado.ExecuteCommandAsync(
- """
- INSERT IGNORE INTO ItemMaster (
- tenant_id, ItemNum, Descr, Descr1, UM,
- Status, IsActive, Domain, factory_ref_id,
- CreateTime, CreateUser, UpdateTime, UpdateUser
- ) VALUES (
- @TenantId, @ItemNum, @Descr, @Descr1, @UM,
- '', 1, @Domain, @FactoryRefId,
- @Now, @User, @Now, @User
- )
- """,
- new SugarParameter("@TenantId", tid),
- new SugarParameter("@ItemNum", itemNum),
- new SugarParameter("@Descr", item.Descr ?? string.Empty),
- new SugarParameter("@Descr1", item.Descr1 ?? (object)DBNull.Value),
- new SugarParameter("@UM", item.UM ?? (object)DBNull.Value),
- new SugarParameter("@Domain", defaultDomain),
- new SugarParameter("@FactoryRefId", factoryRefId),
- new SugarParameter("@Now", now),
- new SugarParameter("@User", account));
- newItemsCount++;
- existingSet.Add(itemNum);
- }
- }
- // 5) 逐组写入 WorkOrdDetail(已预匹配,无需再查)
- var inserted = 0;
- var updated = 0;
- var matchedWorkOrders = 0;
- foreach (var (grp, woRow) in matchedGroups)
- {
- var domain = string.IsNullOrWhiteSpace(woRow.Domain) ? "8010" : woRow.Domain;
- // 加载该工单已有的 WorkOrdDetail
- var existingDetails = await _db.Ado.SqlQueryAsync<ExistingDetailRow>(
- """
- SELECT RecID, ItemNum, QtyRequired
- FROM WorkOrdDetail
- WHERE tenant_id = @TenantId AND WorkOrd = @WorkOrd AND IFNULL(IsActive, 0) = 1
- """,
- new SugarParameter("@TenantId", tid),
- new SugarParameter("@WorkOrd", woRow.WorkOrd));
- var existingMap = existingDetails.ToDictionary(x => x.ItemNum, x => x, StringComparer.OrdinalIgnoreCase);
- var line = existingDetails.Count + 1;
- var now2 = DateTime.Now;
- // 按子物料 ItemNum 去重(同一物料累加需求数量)
- var groupedItems = grp
- .GroupBy(x => x.ItemNum.Trim())
- .Select(g => new
- {
- ItemNum = g.Key,
- Descr = g.First().Descr,
- Descr1 = g.First().Descr1,
- UM = g.First().UM,
- QtyRequired = g.Sum(x => x.QtyRequired),
- Op = g.First().Op
- })
- .ToList();
- foreach (var item in groupedItems)
- {
- var itemNum = item.ItemNum.Trim();
- if (existingMap.TryGetValue(itemNum, out var ex))
- {
- // 已有 → UPDATE QtyRequired
- await _db.Ado.ExecuteCommandAsync(
- """
- UPDATE WorkOrdDetail
- SET QtyRequired = @QtyRequired, UpdateUser = @User, UpdateTime = @Now
- WHERE RecID = @RecID AND tenant_id = @TenantId
- """,
- new SugarParameter("@QtyRequired", item.QtyRequired),
- new SugarParameter("@User", account),
- new SugarParameter("@Now", now2),
- new SugarParameter("@RecID", ex.RecID),
- new SugarParameter("@TenantId", tid));
- updated++;
- }
- else
- {
- // 新增 → INSERT
- await _db.Ado.ExecuteCommandAsync(
- """
- INSERT INTO WorkOrdDetail (
- WorkOrdMasterRecID, `Domain`, WorkOrd, LineNum, ItemNum, Op,
- Location, QtyRequired, QtyPosted, UM, IsActive, IsConfirm,
- CreateUser, CreateTime, UpdateUser, UpdateTime, tenant_id
- ) VALUES (
- @MasterRecId, @Domain, @WorkOrd, @LineNum, @ItemNum, @Op,
- @Location, @QtyRequired, 0, @UM, 1, 0,
- @User, @Now, @User, @Now, @TenantId
- )
- """,
- new SugarParameter("@MasterRecId", woRow.RecId),
- new SugarParameter("@Domain", domain),
- new SugarParameter("@WorkOrd", woRow.WorkOrd),
- new SugarParameter("@LineNum", line),
- new SugarParameter("@ItemNum", itemNum),
- new SugarParameter("@Op", item.Op),
- new SugarParameter("@Location", DBNull.Value),
- new SugarParameter("@QtyRequired", item.QtyRequired),
- new SugarParameter("@UM", item.UM ?? (object)DBNull.Value),
- new SugarParameter("@User", account),
- new SugarParameter("@Now", now2),
- new SugarParameter("@TenantId", tid));
- inserted++;
- line++;
- }
- }
- matchedWorkOrders++;
- _logger.LogInformation(
- "[BomUpload] workOrd={WorkOrd} salesOrderNo={SalesOrderNo} parentItem={ParentItem} items={Items}",
- woRow.WorkOrd, grp.Key.SalesOrderNo, grp.Key.ParentItemNum, groupedItems.Count);
- }
- await _db.Ado.CommitTranAsync();
- _logger.LogInformation(
- "[BomUpload] tenantId={TenantId} matched={Matched} inserted={Inserted} updated={Updated} newItems={NewItems} unmatched={Unmatched}",
- tid, matchedWorkOrders, inserted, updated, newItemsCount, unmatched.Count);
- return new BomUploadResult
- {
- Inserted = inserted,
- Updated = updated,
- NewItems = newItemsCount,
- Total = input.Items.Count,
- MatchedWorkOrders = matchedWorkOrders,
- Unmatched = unmatched
- };
- }
- catch
- {
- await _db.Ado.RollbackTranAsync();
- throw;
- }
- }
- /// <summary>获取租户的 factory_ref_id(取 ItemMaster 中已有的最小 factory_ref_id,缺省 1)。</summary>
- private async Task<long> ResolveFactoryRefIdAsync(long tenantId)
- {
- var val = await _db.Ado.GetIntAsync(
- """
- SELECT IFNULL(MIN(factory_ref_id), 1) FROM ItemMaster
- WHERE tenant_id = @TenantId AND factory_ref_id > 0
- LIMIT 1
- """,
- new SugarParameter("@TenantId", tenantId));
- return val <= 0 ? 1 : val;
- }
- private sealed class WoInfo
- {
- public long RecId { get; set; }
- public string WorkOrd { get; set; } = string.Empty;
- public string? Domain { get; set; }
- public string? Status { get; set; }
- }
- private sealed class ExistingDetailRow
- {
- public long RecID { get; set; }
- public string ItemNum { get; set; } = string.Empty;
- public decimal QtyRequired { get; set; }
- }
- }
|