359 lines
13 KiB
C#
359 lines
13 KiB
C#
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<BomImportReport> 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<ProductionOrder> 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<ProductionNode> 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<ProductionNode>(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<MaterialQuota> 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<int, int> LevelCounts,
|
|
IReadOnlyList<string> Warnings,
|
|
bool SkippedUnchanged);
|