using System.Security.Cryptography; using System.Text; using DongfangHydro.Dashboard.Api.Data; using DongfangHydro.Dashboard.Api.Domain; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; namespace DongfangHydro.Dashboard.Api.Importing; public sealed class BomImportService(DashboardDbContext dbContext) { public async Task ImportAsync( BomImportDocument document, bool purgeDemo, CancellationToken cancellationToken, bool forceReplace = false) { IDbContextTransaction? transaction = null; if (dbContext.Database.IsRelational()) { transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); } try { var ordersToRemove = await dbContext.ProductionOrders .Where(order => order.SourceDocumentCode == document.Metadata.SourceDocumentCode || (purgeDemo && order.DataSource == "demo")) .ToListAsync(cancellationToken); var unchangedOrder = forceReplace ? null : ordersToRemove.SingleOrDefault(order => order.SourceDocumentCode == document.Metadata.SourceDocumentCode && order.SourceFileHash == document.Metadata.SourceFileHash); var removedDemoOrders = ordersToRemove.Count(order => order.DataSource == "demo"); var replacedOrders = unchangedOrder is null ? ordersToRemove.Count(order => order.SourceDocumentCode == document.Metadata.SourceDocumentCode && order.DataSource != "demo") : 0; var removableOrders = unchangedOrder is null ? ordersToRemove : ordersToRemove.Where(order => order.DataSource == "demo").ToList(); if (removableOrders.Count > 0) { await RemoveOrdersAsync(removableOrders, cancellationToken); } if (unchangedOrder is not null) { var totalOrders = await dbContext.ProductionOrders.CountAsync(cancellationToken); var productionNodes = await dbContext.ProductionNodes .CountAsync(node => node.OrderId == unchangedOrder.Id, cancellationToken); var materialQuotas = await dbContext.MaterialQuotas .CountAsync(item => item.OrderId == unchangedOrder.Id, cancellationToken); if (transaction is not null) { await transaction.CommitAsync(cancellationToken); } return BuildReport( unchangedOrder, document, removedDemoOrders, replacedOrders, totalOrders, productionNodes, materialQuotas, skippedUnchanged: true); } var importedAt = DateTimeOffset.UtcNow; var order = BuildOrder(document.Metadata, importedAt); var nodes = BuildNodes(order, document, importedAt); var quotas = BuildMaterialQuotas(order.Id, document); dbContext.ProductionOrders.Add(order); dbContext.ProductionNodes.AddRange(nodes); dbContext.MaterialQuotas.AddRange(quotas); await dbContext.SaveChangesAsync(cancellationToken); var importedTotalOrders = await dbContext.ProductionOrders.CountAsync(cancellationToken); if (transaction is not null) { await transaction.CommitAsync(cancellationToken); } return BuildReport( order, document, removedDemoOrders, replacedOrders, importedTotalOrders, nodes.Count, quotas.Count, skippedUnchanged: false); } catch { if (transaction is not null) { try { await transaction.RollbackAsync(CancellationToken.None); } catch { // Preserve the original import failure. } } throw; } finally { if (transaction is not null) { await transaction.DisposeAsync(); } } } private static BomImportReport BuildReport( ProductionOrder order, BomImportDocument document, int removedDemoOrders, int replacedOrders, int totalOrders, int productionNodes, int materialQuotas, bool skippedUnchanged) { return new BomImportReport( order.Id, order.Code, document.Metadata.SourceDocumentCode, document.Metadata.SourceFileHash, removedDemoOrders, replacedOrders, totalOrders, document.BomNodes.Count, productionNodes, materialQuotas, document.BlockCount, document.BomNodes .GroupBy(node => node.Level) .OrderBy(group => group.Key) .ToDictionary(group => group.Key, group => group.Count()), document.Warnings, skippedUnchanged); } private async Task RemoveOrdersAsync( IReadOnlyCollection orders, CancellationToken cancellationToken) { var orderIds = orders.Select(order => order.Id).ToArray(); var riskEvents = await dbContext.RiskEvents .Where(item => orderIds.Contains(item.OrderId)) .ToListAsync(cancellationToken); var trendPoints = await dbContext.ProductionTrendPoints .Where(item => orderIds.Contains(item.OrderId)) .ToListAsync(cancellationToken); var quotas = await dbContext.MaterialQuotas .Where(item => orderIds.Contains(item.OrderId)) .ToListAsync(cancellationToken); var nodes = await dbContext.ProductionNodes .Where(item => orderIds.Contains(item.OrderId)) .OrderByDescending(item => item.Level) .ToListAsync(cancellationToken); dbContext.RiskEvents.RemoveRange(riskEvents); dbContext.ProductionTrendPoints.RemoveRange(trendPoints); dbContext.MaterialQuotas.RemoveRange(quotas); dbContext.ProductionNodes.RemoveRange(nodes); await dbContext.SaveChangesAsync(cancellationToken); dbContext.ProductionOrders.RemoveRange(orders); await dbContext.SaveChangesAsync(cancellationToken); } private static ProductionOrder BuildOrder(BomImportMetadata metadata, DateTimeOffset importedAt) { return new ProductionOrder { Id = StableGuid($"{metadata.SourceDocumentCode}|order"), Code = metadata.WorkOrderCode, ProductName = metadata.ContractProductName, DataSource = "excel", SourceDocumentCode = metadata.SourceDocumentCode, ProjectName = metadata.ProjectName, DrawingProductName = metadata.DrawingProductName, SourceFileName = metadata.SourceFileName, SourceFileHash = metadata.SourceFileHash, ImportedAt = importedAt, BatchQty = metadata.BatchQuantity, RequiredQty = metadata.BatchQuantity, CompletedQty = 0, Progress = 0, Status = "waiting", RiskLevel = "normal", DelayDays = 0, LineName = string.Empty, Owner = string.Empty, PlanAchievement = 0, DailyDelta = 0, ThumbnailKey = "generic", CreatedAt = importedAt, UpdatedAt = importedAt, }; } private static List BuildNodes( ProductionOrder order, BomImportDocument document, DateTimeOffset importedAt) { var rootId = StableGuid($"{document.Metadata.SourceDocumentCode}|node|root"); var nodeIdsBySourceRow = document.BomNodes.ToDictionary( node => node.SourceRow, node => StableGuid($"{document.Metadata.SourceDocumentCode}|node|明细|{node.SourceRow}")); var nodes = new List(document.BomNodes.Count + 1) { new() { Id = rootId, OrderId = order.Id, Code = order.Code, MaterialCode = document.Metadata.SourceDocumentCode, Name = order.ProductName, Level = 0, NodeType = "order", SupplyType = "self_made", RequiredQty = order.BatchQty, CompletedQty = 0, Progress = 0, Status = "waiting", RiskLevel = "normal", SourceSheet = "明细", SourceRow = 0, VisualKey = "generic", SortOrder = 0, UpdatedAt = importedAt, }, }; foreach (var source in document.BomNodes) { var parentId = source.ParentSourceRow.HasValue ? nodeIdsBySourceRow[source.ParentSourceRow.Value] : rootId; nodes.Add(new ProductionNode { Id = nodeIdsBySourceRow[source.SourceRow], OrderId = order.Id, ParentNodeId = parentId, Code = string.IsNullOrWhiteSpace(source.DrawingNumber) ? $"BOM-{source.SourceRow:0000}" : source.DrawingNumber, OperationCode = string.Empty, MaterialCode = source.DrawingNumber, Name = source.Name, SourceSequence = source.SourceSequence, Specification = source.Specification, Material = source.Material, UnitWeight = source.UnitWeight, TotalWeight = source.TotalWeight, UnitWeightText = source.UnitWeightText, TotalWeightText = source.TotalWeightText, BomQuantity = source.Quantity, BomQuantityText = source.QuantityText, Remark = source.Remark, SourceSheet = "明细", SourceRow = source.SourceRow, Level = source.Level, NodeType = source.NodeType, SupplyType = source.SupplyType, RequiredQty = ToOperationalQuantity(source), CompletedQty = 0, DefectQty = 0, Progress = 0, Status = "waiting", RiskLevel = "normal", DelayDays = 0, DelayReason = string.Empty, Owner = string.Empty, Vendor = string.Empty, StationName = string.Empty, VisualKey = "generic", SortOrder = source.SourceRow, UpdatedAt = importedAt, }); } return nodes; } private static List BuildMaterialQuotas(Guid orderId, BomImportDocument document) { return document.MaterialQuotas.Select(source => new MaterialQuota { Id = StableGuid($"{document.Metadata.SourceDocumentCode}|quota|材料定额|{source.SourceRow}"), OrderId = orderId, Category = source.Category, SourceSequence = source.SourceSequence, MaterialCode = source.MaterialCode, MaterialName = source.MaterialName, Specification = source.Specification, Unit = source.Unit, Quantity = source.Quantity, NetWeight = source.NetWeight, ConsumptionQuota = source.ConsumptionQuota, QuantityText = source.QuantityText, NetWeightText = source.NetWeightText, ConsumptionQuotaText = source.ConsumptionQuotaText, Brand = source.Brand, Remark = source.Remark, SourceSheet = "材料定额", SourceRow = source.SourceRow, }).ToList(); } private static int ToOperationalQuantity(BomImportNode source) { if (!source.Quantity.HasValue || source.Quantity.Value <= 0) { return 0; } if (source.Quantity.Value > int.MaxValue) { throw new BomImportException($"明细第 {source.SourceRow} 行数量超出进度字段可表示范围。"); } return decimal.ToInt32(decimal.Ceiling(source.Quantity.Value)); } private static Guid StableGuid(string value) { var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); var bytes = hash[..16]; bytes[6] = (byte)((bytes[6] & 0x0F) | 0x50); bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80); return new Guid(bytes); } } public sealed record BomImportReport( Guid OrderId, string OrderCode, string SourceDocumentCode, string SourceFileHash, int RemovedDemoOrders, int ReplacedOrders, int TotalOrders, int ImportedBomNodes, int TotalProductionNodes, int MaterialQuotas, int Blocks, IReadOnlyDictionary LevelCounts, IReadOnlyList Warnings, bool SkippedUnchanged);