using Admin.NET.Plugin.AiDOP.Infrastructure;
using Microsoft.Extensions.Logging;
namespace Admin.NET.Plugin.AiDOP.WorkOrder;
///
/// 工单 BOM 上传服务 — 从 Excel 解析结果按(订单号+父物料号)匹配未下达工单,
/// 批量写入 ItemMaster + WorkOrdDetail。
/// 路由前缀:/api/WorkOrder/dispatch/bom/...
///
/// 【租户安全边界】需认证访问;租户一律经
/// AidopTenantScope.ResolveOrThrow 从认证后 JWT 解析。
///
[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 _logger;
public WorkOrderBomUploadService(
ISqlSugarClient db,
UserManager userManager,
ILogger logger)
{
_db = db;
_userManager = userManager;
_logger = logger;
}
private long ResolveTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
// ══════════════════════════════════════════════════════════════
// POST /api/WorkOrder/dispatch/bom/check-materials
// ══════════════════════════════════════════════════════════════
/// 校验 BOM 子物料是否在 ItemMaster 中存在。
[DisplayName("BOM物料存在性校验")]
[ApiDescriptionSettings(Name = "BomCheckMaterials"), HttpPost("dispatch/bom/check-materials")]
public async Task 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(StringComparer.OrdinalIgnoreCase);
var pars = new List { 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(
$"""
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
// ══════════════════════════════════════════════════════════════
///
/// 写入 BOM:按(订单号+父物料号)匹配未下达工单,可选新建 ItemMaster 缺失物料,
/// 然后 Upsert WorkOrdDetail。
///
[DisplayName("BOM写入工单物料明细")]
[ApiDescriptionSettings(Name = "BomUpload"), HttpPost("dispatch/bom/upload")]
public async Task 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(StringComparer.OrdinalIgnoreCase);
if (itemNums.Count > 0)
{
var pars = new List { 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(
$"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();
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(
"""
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(
"""
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;
}
}
/// 获取租户的 factory_ref_id(取 ItemMaster 中已有的最小 factory_ref_id,缺省 1)。
private async Task 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; }
}
}