| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369 |
- using Yitter.IdGenerator;
- namespace Admin.NET.Plugin.AiDOP.Order;
- /// <summary>
- /// 订单评审资源检查:多层 BOM 展开与库存/采购在途缺口计算。
- /// </summary>
- public class MaterialRequirementCalculator : ITransient
- {
- private const int MaxBomDepth = 6;
- private readonly ISqlSugarClient _db;
- public MaterialRequirementCalculator(ISqlSugarClient db)
- {
- _db = db;
- }
- public async Task<List<ResourceCheckBomLine>> BuildLinesAsync(
- OrderWorkOrderGenerationService.OrderHeader order,
- OrderWorkOrderGenerationService.OrderEntryLine entry,
- List<string> warnings)
- {
- var itemNum = entry.ItemNumber!.Trim();
- var orderQty = entry.Qty ?? 0;
- var needTime = entry.SysCapacityDate ?? entry.PlanDate ?? DateTime.Today;
- var lines = new List<ResourceCheckBomLine>();
- var rootFid = YitIdHelper.NextId();
- lines.Add(new ResourceCheckBomLine
- {
- Fid = rootFid,
- Num = "1",
- Level = 1,
- ItemNumber = itemNum,
- ItemName = entry.ItemName,
- Model = entry.Specification,
- Unit = entry.Unit,
- BomNumber = entry.BomNumber,
- ErpCls = 1,
- ErpClsName = "自制",
- NeedCount = orderQty,
- NeedCountNoLoss = orderQty,
- KittingTime = needTime,
- SatisfyTime = needTime,
- IsUse = 1
- });
- var bomId = await ResolveBomIdAsync(entry, itemNum);
- if (bomId is null)
- {
- warnings.Add($"订单行 {entry.EntrySeq}({itemNum})未匹配到 BOM,仅写入成品行资源检查结果");
- await ApplySupplyAsync(lines, entry.TenantId);
- return lines;
- }
- var children = await LoadBomChildrenAsync(bomId.Value, entry.TenantId);
- if (children.Count == 0)
- {
- warnings.Add($"订单行 {entry.EntrySeq} BOM {entry.BomNumber ?? bomId.ToString()} 无子件明细");
- await ApplySupplyAsync(lines, entry.TenantId);
- return lines;
- }
- var seq = 1;
- foreach (var child in children)
- {
- seq = await AppendBomChildAsync(
- lines, rootFid, "1", 1, orderQty, child, entry, needTime, warnings, seq, depth: 1);
- }
- await ApplySupplyAsync(lines, entry.TenantId);
- return lines;
- }
- private async Task<int> AppendBomChildAsync(
- List<ResourceCheckBomLine> lines,
- long parentFid,
- string parentNum,
- int parentLevel,
- decimal parentNeedQty,
- BomChildRow child,
- OrderWorkOrderGenerationService.OrderEntryLine entry,
- DateTime needTime,
- List<string> warnings,
- int seq,
- int depth)
- {
- var unitQty = child.Qty <= 0 ? 1m : child.Qty;
- var wastageFactor = 1m + (child.Wastage / 100m);
- var scrapFactor = 1m + (child.Scrap / 100m);
- var needNoLoss = parentNeedQty * unitQty;
- var needCount = RoundQty(needNoLoss * wastageFactor * scrapFactor);
- var isVirtual = child.ErpCls == 4;
- var num = $"{parentNum}.{seq}";
- var fid = YitIdHelper.NextId();
- var nextSeq = seq + 1;
- if (!isVirtual)
- {
- lines.Add(new ResourceCheckBomLine
- {
- Fid = fid,
- ParentFid = parentFid,
- BomChildId = child.Id,
- Num = num,
- Level = parentLevel + 1,
- Type = child.Type,
- ItemNumber = child.ItemNumber ?? string.Empty,
- ItemName = child.ItemName,
- Model = child.Model,
- Unit = child.Unit,
- BomNumber = child.BomNumber,
- ErpCls = child.ErpCls,
- ErpClsName = MapErpClsName(child.ErpCls),
- Backflush = child.Backflush,
- Qty = unitQty,
- Wastage = child.Wastage,
- Scrap = child.Scrap,
- NeedCount = needCount,
- NeedCountNoLoss = RoundQty(needNoLoss),
- KittingTime = needTime,
- SatisfyTime = needTime,
- IsBom = child.IsBom,
- HaveIcSubs = child.HaveIcSubs,
- SubstituteCode = child.SubstituteCode,
- IsUse = 1
- });
- }
- seq = nextSeq;
- var expandParentFid = isVirtual ? parentFid : fid;
- var expandParentNum = num;
- var expandNeed = needCount;
- if (child.IsBom == 1 && depth < MaxBomDepth && !string.IsNullOrWhiteSpace(child.ItemNumber))
- {
- var subBomId = await ResolveBomIdByItemAsync(child.ItemNumber!, entry.TenantId);
- if (subBomId is null)
- {
- if (!isVirtual)
- warnings.Add($"物料 {child.ItemNumber} 标记为 BOM 但未找到子阶 BOM,按单层处理");
- return seq;
- }
- var grandchildren = await LoadBomChildrenAsync(subBomId.Value, entry.TenantId);
- if (grandchildren.Count == 0)
- {
- warnings.Add($"物料 {child.ItemNumber} 子阶 BOM 无明细");
- return seq;
- }
- var childSeq = 1;
- foreach (var gc in grandchildren)
- {
- childSeq = await AppendBomChildAsync(
- lines,
- expandParentFid,
- expandParentNum,
- parentLevel + 1,
- expandNeed,
- gc,
- entry,
- needTime,
- warnings,
- childSeq,
- depth + 1);
- }
- }
- else if (child.IsBom == 1 && depth >= MaxBomDepth)
- {
- warnings.Add($"物料 {child.ItemNumber} 超过最大 BOM 展开层级 {MaxBomDepth},停止下钻");
- }
- return seq;
- }
- private async Task<long?> ResolveBomIdAsync(
- OrderWorkOrderGenerationService.OrderEntryLine entry,
- string itemNum)
- {
- var bomNumber = entry.BomNumber?.Trim();
- var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
- """
- SELECT Id, bom_number AS BomNumber, item_number AS ItemNumber
- FROM ic_bom
- WHERE tenant_id = @TenantId AND IsDeleted = 0
- AND (
- (@BomNumber <> '' AND bom_number = @BomNumber)
- OR (@BomNumber = '' AND item_number = @ItemNum)
- OR item_number = @ItemNum
- )
- ORDER BY
- CASE WHEN @BomNumber <> '' AND bom_number = @BomNumber THEN 0 ELSE 1 END,
- Id DESC
- LIMIT 1
- """,
- new SugarParameter("@TenantId", entry.TenantId),
- new SugarParameter("@BomNumber", bomNumber ?? string.Empty),
- new SugarParameter("@ItemNum", itemNum));
- return rows.FirstOrDefault()?.Id;
- }
- private async Task<long?> ResolveBomIdByItemAsync(string itemNum, long tenantId)
- {
- var rows = await _db.Ado.SqlQueryAsync<BomHeaderRow>(
- """
- SELECT Id
- FROM ic_bom
- WHERE tenant_id = @TenantId AND IsDeleted = 0 AND item_number = @ItemNum
- ORDER BY Id DESC
- LIMIT 1
- """,
- new SugarParameter("@TenantId", tenantId),
- new SugarParameter("@ItemNum", itemNum.Trim()));
- return rows.FirstOrDefault()?.Id;
- }
- private async Task<List<BomChildRow>> LoadBomChildrenAsync(long bomId, long tenantId)
- {
- return await _db.Ado.SqlQueryAsync<BomChildRow>(
- """
- SELECT
- c.Id,
- c.bom_number AS BomNumber,
- c.item_number AS ItemNumber,
- c.item_name AS ItemName,
- IFNULL(im.Descr1, '') AS Model,
- c.unit AS Unit,
- IFNULL(c.qty, 1) AS Qty,
- IFNULL(c.wastage, 0) AS Wastage,
- IFNULL(c.scrap, 0) AS Scrap,
- IFNULL(c.backflush, 0) AS Backflush,
- IFNULL(c.type, 0) AS Type,
- IFNULL(c.erp_cls, 3) AS ErpCls,
- IFNULL(c.is_bom, 0) AS IsBom,
- IFNULL(c.haveicsubs, 0) AS HaveIcSubs,
- c.substitute_code AS SubstituteCode
- FROM ic_bom_child c
- LEFT JOIN ItemMaster im ON c.item_number = im.ItemNum
- WHERE c.bom_id = @BomId
- AND c.tenant_id = @TenantId
- AND c.IsDeleted = 0
- ORDER BY IFNULL(c.child_num, 0), IFNULL(c.entryid, 0), c.Id
- """,
- new SugarParameter("@BomId", bomId),
- new SugarParameter("@TenantId", tenantId));
- }
- private async Task ApplySupplyAsync(List<ResourceCheckBomLine> lines, long tenantId)
- {
- var stockMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
- var transitMap = new Dictionary<string, decimal>(StringComparer.OrdinalIgnoreCase);
- foreach (var line in lines)
- {
- if (string.IsNullOrWhiteSpace(line.ItemNumber))
- continue;
- if (!stockMap.TryGetValue(line.ItemNumber, out var stock))
- {
- stock = await QueryStockQtyAsync(line.ItemNumber, tenantId);
- stockMap[line.ItemNumber] = stock;
- }
- if (!transitMap.TryGetValue(line.ItemNumber, out var inTransit))
- {
- inTransit = await QueryOpenPurchaseQtyAsync(line.ItemNumber, tenantId);
- transitMap[line.ItemNumber] = inTransit;
- }
- line.StockQty = stock;
- line.PurchaseOccupyQty = inTransit;
- var available = RoundQty(stock + inTransit);
- line.Sqty = RoundQty(Math.Min(line.NeedCount, available));
- line.LackQty = RoundQty(Math.Max(0m, line.NeedCount - line.Sqty));
- line.SelfLackQty = line.LackQty;
- line.UseQty = line.Sqty;
- line.MoQty = 0;
- line.MakeQty = line.ErpCls == 1 ? line.LackQty : 0;
- line.PurchaseQty = line.ErpCls == 3 ? line.LackQty : 0;
- var consumedFromStock = RoundQty(Math.Min(stock, line.Sqty));
- stockMap[line.ItemNumber] = RoundQty(Math.Max(0m, stock - consumedFromStock));
- var remainingSqty = RoundQty(Math.Max(0m, line.Sqty - consumedFromStock));
- transitMap[line.ItemNumber] = RoundQty(Math.Max(0m, inTransit - remainingSqty));
- }
- }
- private async Task<decimal> QueryStockQtyAsync(string itemNum, long tenantId)
- {
- var qty = await _db.Ado.GetDecimalAsync(
- """
- SELECT COALESCE(SUM(
- CASE
- WHEN AvailStatusQty IS NOT NULL THEN AvailStatusQty
- WHEN QtyOnHand IS NOT NULL THEN QtyOnHand
- ELSE 0
- END
- ), 0)
- FROM InvMaster
- WHERE ItemNum = @ItemNum
- AND (tenant_id = @TenantId OR @TenantId = 0)
- """,
- new List<SugarParameter>
- {
- new("@ItemNum", itemNum),
- new("@TenantId", tenantId)
- });
- return RoundQty(qty);
- }
- private async Task<decimal> QueryOpenPurchaseQtyAsync(string itemNum, long tenantId)
- {
- var qty = await _db.Ado.GetDecimalAsync(
- """
- SELECT COALESCE(SUM(
- GREATEST(IFNULL(d.QtyOrded, 0) - IFNULL(d.QtyReceived, 0), 0)
- ), 0)
- FROM PurOrdDetail d
- INNER JOIN PurOrdMaster m ON m.RecID = d.PurOrdRecID
- WHERE d.ItemNum = @ItemNum
- AND m.tenant_id = @TenantId
- AND IFNULL(d.IsActive, 1) = 1
- AND IFNULL(m.IsActive, 1) = 1
- """,
- new SugarParameter("@ItemNum", itemNum),
- new SugarParameter("@TenantId", tenantId));
- return RoundQty(qty);
- }
- private static decimal RoundQty(decimal value) =>
- Math.Round(value, 4, MidpointRounding.AwayFromZero);
- private static string MapErpClsName(int erpCls) => erpCls switch
- {
- 0 => "配置类",
- 1 => "自制",
- 2 => "委外加工",
- 3 => "外购",
- 4 => "虚拟件",
- _ => string.Empty
- };
- private sealed class BomHeaderRow
- {
- public long Id { get; set; }
- public string? BomNumber { get; set; }
- public string? ItemNumber { get; set; }
- }
- private sealed class BomChildRow
- {
- public long Id { get; set; }
- public string? BomNumber { get; set; }
- public string? ItemNumber { get; set; }
- public string? ItemName { get; set; }
- public string? Model { get; set; }
- public string? Unit { get; set; }
- public decimal Qty { get; set; }
- public decimal Wastage { get; set; }
- public decimal Scrap { get; set; }
- public int Backflush { get; set; }
- public int Type { get; set; }
- public int ErpCls { get; set; }
- public int IsBom { get; set; }
- public int HaveIcSubs { get; set; }
- public string? SubstituteCode { get; set; }
- }
- }
|