535 lines
18 KiB
C#
535 lines
18 KiB
C#
using System.Globalization;
|
||
using System.Security.Cryptography;
|
||
using System.Text;
|
||
using System.Text.RegularExpressions;
|
||
using ClosedXML.Excel;
|
||
|
||
namespace DongfangHydro.Dashboard.Api.Importing;
|
||
|
||
public static partial class BomWorkbookParser
|
||
{
|
||
private const string DetailSheetName = "明细";
|
||
private const string QuotaSheetName = "材料定额";
|
||
|
||
public static BomImportDocument Parse(Stream stream, string sourceFileName)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(stream);
|
||
|
||
byte[] workbookBytes;
|
||
using (var buffer = new MemoryStream())
|
||
{
|
||
stream.CopyTo(buffer);
|
||
workbookBytes = buffer.ToArray();
|
||
}
|
||
|
||
try
|
||
{
|
||
using var workbookStream = new MemoryStream(workbookBytes, writable: false);
|
||
using var workbook = new XLWorkbook(workbookStream);
|
||
if (!workbook.TryGetWorksheet(DetailSheetName, out var detailSheet))
|
||
{
|
||
throw new BomImportException($"缺少工作表:{DetailSheetName}。");
|
||
}
|
||
|
||
if (!workbook.TryGetWorksheet(QuotaSheetName, out var quotaSheet))
|
||
{
|
||
throw new BomImportException($"缺少工作表:{QuotaSheetName}。");
|
||
}
|
||
|
||
var metadata = ParseMetadata(
|
||
quotaSheet,
|
||
sourceFileName,
|
||
Convert.ToHexString(SHA256.HashData(workbookBytes)).ToLowerInvariant());
|
||
var blockResult = ReadBomBlocks(detailSheet);
|
||
var nodes = BuildBomNodes(blockResult.Blocks);
|
||
var quotas = ReadMaterialQuotas(quotaSheet);
|
||
|
||
return new BomImportDocument(
|
||
metadata,
|
||
nodes,
|
||
quotas,
|
||
blockResult.Blocks.Count,
|
||
blockResult.Warnings);
|
||
}
|
||
catch (BomImportException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
throw new BomImportException("Excel BOM 文件无法解析。", exception);
|
||
}
|
||
}
|
||
|
||
private static BomImportMetadata ParseMetadata(
|
||
IXLWorksheet sheet,
|
||
string sourceFileName,
|
||
string sourceFileHash)
|
||
{
|
||
var sourceDocumentCode = ExtractValue(sheet.Cell(2, 1).GetFormattedString(), "编号");
|
||
var projectName = ExtractValue(sheet.Cell(3, 1).GetFormattedString(), "合同(项目)名称");
|
||
var contractProductName = ExtractValue(sheet.Cell(4, 1).GetFormattedString(), "产品合同名称");
|
||
var drawingProductName = ExtractValue(sheet.Cell(5, 1).GetFormattedString(), "产品图纸名称");
|
||
var description = sheet.Cell(6, 1).GetFormattedString();
|
||
|
||
var workOrderCode = string.Empty;
|
||
for (var column = 1; column <= 10; column++)
|
||
{
|
||
if (!Normalize(sheet.Cell(3, column).GetFormattedString()).Contains("工作令号", StringComparison.Ordinal))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
workOrderCode = sheet.Cell(3, column + 1).GetFormattedString().Trim();
|
||
break;
|
||
}
|
||
|
||
var quantityMatch = BatchQuantityRegex().Match(description);
|
||
var batchQuantity = quantityMatch.Success
|
||
? int.Parse(quantityMatch.Groups[1].Value, CultureInfo.InvariantCulture)
|
||
: throw new BomImportException("材料定额说明中未找到产品套数。");
|
||
|
||
var required = new Dictionary<string, string>
|
||
{
|
||
["定额编号"] = sourceDocumentCode,
|
||
["工作令号"] = workOrderCode,
|
||
["项目名称"] = projectName,
|
||
["产品合同名称"] = contractProductName,
|
||
["产品图纸名称"] = drawingProductName,
|
||
};
|
||
var missing = required.Where(item => string.IsNullOrWhiteSpace(item.Value)).Select(item => item.Key).ToList();
|
||
if (missing.Count > 0)
|
||
{
|
||
throw new BomImportException($"材料定额缺少元数据:{string.Join("、", missing)}。");
|
||
}
|
||
|
||
return new BomImportMetadata(
|
||
sourceDocumentCode,
|
||
workOrderCode,
|
||
projectName,
|
||
contractProductName,
|
||
drawingProductName,
|
||
batchQuantity,
|
||
sourceFileName,
|
||
sourceFileHash);
|
||
}
|
||
|
||
private static BomBlockReadResult ReadBomBlocks(IXLWorksheet sheet)
|
||
{
|
||
var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 1;
|
||
var blocks = new List<List<RawBomRow>>();
|
||
var warnings = new List<string>();
|
||
var current = new List<RawBomRow>();
|
||
|
||
for (var rowNumber = 2; rowNumber <= lastRow; rowNumber++)
|
||
{
|
||
var row = ReadBomRow(sheet, rowNumber);
|
||
if (row.IsIgnored)
|
||
{
|
||
warnings.Add($"明细第 {rowNumber} 行只有序号,已作为空占位行跳过。");
|
||
continue;
|
||
}
|
||
|
||
if (row.IsBlank)
|
||
{
|
||
if (current.Count > 0)
|
||
{
|
||
blocks.Add(current);
|
||
current = [];
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
current.Add(row);
|
||
}
|
||
|
||
if (current.Count > 0)
|
||
{
|
||
blocks.Add(current);
|
||
}
|
||
|
||
if (blocks.Count == 0)
|
||
{
|
||
throw new BomImportException("明细工作表没有可导入的 BOM 数据。");
|
||
}
|
||
|
||
return new BomBlockReadResult(blocks, warnings);
|
||
}
|
||
|
||
private static IReadOnlyList<BomImportNode> BuildBomNodes(IReadOnlyList<List<RawBomRow>> blocks)
|
||
{
|
||
var builders = new List<NodeBuilder>();
|
||
foreach (var row in blocks[0])
|
||
{
|
||
builders.Add(CreateNode(row, parentSourceRow: null, level: 1));
|
||
}
|
||
|
||
foreach (var block in blocks.Skip(1))
|
||
{
|
||
var parentMarker = block[0];
|
||
var parentKey = BuildBusinessKey(parentMarker.DrawingNumber, parentMarker.Name);
|
||
var candidates = builders.Where(node => node.BusinessKey == parentKey).ToList();
|
||
if (candidates.Count != 1)
|
||
{
|
||
var reason = candidates.Count == 0 ? "未找到" : $"匹配到 {candidates.Count} 个";
|
||
throw new BomImportException(
|
||
$"明细第 {parentMarker.SourceRow} 行父节点 {parentMarker.DrawingNumber} {parentMarker.Name} {reason},无法确定层级。");
|
||
}
|
||
|
||
var parent = candidates[0];
|
||
foreach (var row in block.Skip(1))
|
||
{
|
||
builders.Add(CreateNode(row, parent.SourceRow, parent.Level + 1));
|
||
}
|
||
}
|
||
|
||
var parentRows = builders
|
||
.Where(node => node.ParentSourceRow.HasValue)
|
||
.Select(node => node.ParentSourceRow!.Value)
|
||
.ToHashSet();
|
||
|
||
return builders.Select(node => new BomImportNode(
|
||
node.SourceRow,
|
||
node.ParentSourceRow,
|
||
node.SourceSequence,
|
||
node.DrawingNumber,
|
||
node.Name,
|
||
node.Specification,
|
||
node.Quantity,
|
||
node.Material,
|
||
node.UnitWeight,
|
||
node.TotalWeight,
|
||
node.Remark,
|
||
node.Level,
|
||
node.Level == 1 ? "part" : parentRows.Contains(node.SourceRow) ? "component" : "material",
|
||
MapSupplyType(node.Material, parentRows.Contains(node.SourceRow)),
|
||
node.UnitWeightText,
|
||
node.TotalWeightText,
|
||
node.QuantityText)).ToList();
|
||
}
|
||
|
||
private static IReadOnlyList<MaterialQuotaImportRow> ReadMaterialQuotas(IXLWorksheet sheet)
|
||
{
|
||
var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 7;
|
||
var category = string.Empty;
|
||
var rows = new List<MaterialQuotaImportRow>();
|
||
|
||
for (var rowNumber = 8; rowNumber <= lastRow; rowNumber++)
|
||
{
|
||
var sourceSequence = sheet.Cell(rowNumber, 1).GetFormattedString().Trim();
|
||
var materialCode = sheet.Cell(rowNumber, 2).GetFormattedString().Trim();
|
||
var materialName = sheet.Cell(rowNumber, 3).GetFormattedString().Trim();
|
||
var trailingValues = Enumerable.Range(4, 7)
|
||
.Select(column => sheet.Cell(rowNumber, column).GetFormattedString().Trim())
|
||
.ToArray();
|
||
if (!string.IsNullOrWhiteSpace(materialCode) && string.IsNullOrWhiteSpace(materialName))
|
||
{
|
||
if (string.IsNullOrWhiteSpace(sourceSequence) || trailingValues.Any(value => !string.IsNullOrWhiteSpace(value)))
|
||
{
|
||
throw new BomImportException($"材料定额第 {rowNumber} 行缺少物料名称。");
|
||
}
|
||
|
||
category = materialCode;
|
||
continue;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(materialCode) && string.IsNullOrWhiteSpace(materialName))
|
||
{
|
||
if (IsMaterialQuotaFooter(sourceSequence, trailingValues))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!string.IsNullOrWhiteSpace(sourceSequence)
|
||
|| trailingValues.Any(value => !string.IsNullOrWhiteSpace(value)))
|
||
{
|
||
throw new BomImportException($"材料定额第 {rowNumber} 行缺少物料编码和物料名称。");
|
||
}
|
||
|
||
continue;
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(materialName))
|
||
{
|
||
throw new BomImportException($"材料定额第 {rowNumber} 行缺少物料名称。");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(category))
|
||
{
|
||
throw new BomImportException($"材料定额第 {rowNumber} 行缺少所属分类。");
|
||
}
|
||
|
||
rows.Add(new MaterialQuotaImportRow(
|
||
rowNumber,
|
||
category,
|
||
sourceSequence,
|
||
materialCode,
|
||
materialName,
|
||
sheet.Cell(rowNumber, 4).GetFormattedString().Trim(),
|
||
sheet.Cell(rowNumber, 5).GetFormattedString().Trim(),
|
||
ReadQuotaDecimal(sheet.Cell(rowNumber, 6)),
|
||
ReadQuotaDecimal(sheet.Cell(rowNumber, 7)),
|
||
ReadQuotaDecimal(sheet.Cell(rowNumber, 8)),
|
||
sheet.Cell(rowNumber, 9).GetFormattedString().Trim(),
|
||
sheet.Cell(rowNumber, 10).GetFormattedString().Trim(),
|
||
sheet.Cell(rowNumber, 6).GetFormattedString().Trim(),
|
||
sheet.Cell(rowNumber, 7).GetFormattedString().Trim(),
|
||
sheet.Cell(rowNumber, 8).GetFormattedString().Trim()));
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
private static bool IsMaterialQuotaFooter(string sourceSequence, IReadOnlyCollection<string> trailingValues)
|
||
{
|
||
if (trailingValues.Any(value => !string.IsNullOrWhiteSpace(value)))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var normalized = Normalize(sourceSequence);
|
||
return normalized.Contains("编码:", StringComparison.Ordinal)
|
||
&& normalized.Contains("编制:", StringComparison.Ordinal)
|
||
&& normalized.Contains("审核:", StringComparison.Ordinal)
|
||
&& normalized.Contains("日期:", StringComparison.Ordinal);
|
||
}
|
||
|
||
private static RawBomRow ReadBomRow(IXLWorksheet sheet, int rowNumber)
|
||
{
|
||
var values = Enumerable.Range(1, 9)
|
||
.Select(column => sheet.Cell(rowNumber, column).GetFormattedString().Trim())
|
||
.ToArray();
|
||
if (values.All(string.IsNullOrWhiteSpace))
|
||
{
|
||
return RawBomRow.Blank(rowNumber);
|
||
}
|
||
|
||
var name = values[2];
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(values[0]) && values.Skip(1).All(string.IsNullOrWhiteSpace))
|
||
{
|
||
return RawBomRow.Ignored(rowNumber, values[0]);
|
||
}
|
||
|
||
throw new BomImportException($"明细第 {rowNumber} 行缺少名称。");
|
||
}
|
||
|
||
var quantityValue = ReadBomQuantity(sheet.Cell(rowNumber, 5));
|
||
return new RawBomRow(
|
||
rowNumber,
|
||
values[0],
|
||
values[1],
|
||
name,
|
||
values[3],
|
||
quantityValue,
|
||
values[4],
|
||
values[5],
|
||
ReadBomWeight(sheet.Cell(rowNumber, 7)),
|
||
ReadBomWeight(sheet.Cell(rowNumber, 8)),
|
||
values[6],
|
||
values[7],
|
||
values[8],
|
||
false,
|
||
false);
|
||
}
|
||
|
||
private static NodeBuilder CreateNode(RawBomRow row, int? parentSourceRow, int level)
|
||
{
|
||
return new NodeBuilder(
|
||
row.SourceRow,
|
||
parentSourceRow,
|
||
row.SourceSequence,
|
||
row.DrawingNumber,
|
||
row.Name,
|
||
row.Specification,
|
||
row.Quantity,
|
||
row.QuantityText,
|
||
row.Material,
|
||
row.UnitWeight,
|
||
row.TotalWeight,
|
||
row.UnitWeightText,
|
||
row.TotalWeightText,
|
||
row.Remark,
|
||
level,
|
||
BuildBusinessKey(row.DrawingNumber, row.Name));
|
||
}
|
||
|
||
private static string MapSupplyType(string material, bool hasChildren)
|
||
{
|
||
var normalized = Normalize(material);
|
||
if (normalized.Contains("外协", StringComparison.Ordinal))
|
||
{
|
||
return "outsourced";
|
||
}
|
||
|
||
if (normalized.Contains("部件", StringComparison.Ordinal)
|
||
|| normalized.Contains("装配", StringComparison.Ordinal)
|
||
|| normalized.Contains("装焊", StringComparison.Ordinal)
|
||
|| normalized.Contains("焊接", StringComparison.Ordinal)
|
||
|| normalized.Contains("加工", StringComparison.Ordinal))
|
||
{
|
||
return "self_made";
|
||
}
|
||
|
||
if (normalized.Contains("成品", StringComparison.Ordinal)
|
||
|| normalized.Contains("外购", StringComparison.Ordinal)
|
||
|| normalized.Contains("标准件", StringComparison.Ordinal))
|
||
{
|
||
return "purchased";
|
||
}
|
||
|
||
return hasChildren ? "self_made" : "purchased";
|
||
}
|
||
|
||
private static decimal? ReadBomQuantity(IXLCell cell)
|
||
{
|
||
if (cell.IsEmpty())
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (cell.TryGetValue<decimal>(out var value))
|
||
{
|
||
return value;
|
||
}
|
||
|
||
var text = cell.GetFormattedString().Trim();
|
||
return decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out value)
|
||
|| decimal.TryParse(text, NumberStyles.Number, CultureInfo.GetCultureInfo("zh-CN"), out value)
|
||
? value
|
||
: null;
|
||
}
|
||
|
||
private static decimal? ReadBomWeight(IXLCell cell)
|
||
{
|
||
if (cell.IsEmpty())
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (cell.TryGetValue<decimal>(out var value))
|
||
{
|
||
return value;
|
||
}
|
||
|
||
var text = cell.GetFormattedString().Trim();
|
||
return decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out value)
|
||
|| decimal.TryParse(text, NumberStyles.Number, CultureInfo.GetCultureInfo("zh-CN"), out value)
|
||
? value
|
||
: null;
|
||
}
|
||
|
||
private static decimal? ReadQuotaDecimal(IXLCell cell)
|
||
{
|
||
if (cell.IsEmpty())
|
||
{
|
||
return null;
|
||
}
|
||
|
||
if (cell.TryGetValue<decimal>(out var value))
|
||
{
|
||
return value;
|
||
}
|
||
|
||
var text = cell.GetFormattedString().Trim();
|
||
if (decimal.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out value)
|
||
|| decimal.TryParse(text, NumberStyles.Number, CultureInfo.GetCultureInfo("zh-CN"), out value))
|
||
{
|
||
return value;
|
||
}
|
||
|
||
var match = NumberWithUnitRegex().Match(text);
|
||
return match.Success
|
||
&& decimal.TryParse(match.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out value)
|
||
? value
|
||
: null;
|
||
}
|
||
|
||
private static string ExtractValue(string text, string label)
|
||
{
|
||
var normalized = text.Trim();
|
||
var separatorIndex = normalized.IndexOf(':');
|
||
if (separatorIndex < 0)
|
||
{
|
||
separatorIndex = normalized.IndexOf(':');
|
||
}
|
||
|
||
if (separatorIndex < 0 || !normalized[..separatorIndex].Contains(label, StringComparison.Ordinal))
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
return normalized[(separatorIndex + 1)..].Trim();
|
||
}
|
||
|
||
private static string BuildBusinessKey(string drawingNumber, string name)
|
||
{
|
||
return $"{Normalize(drawingNumber)}|{Normalize(name)}";
|
||
}
|
||
|
||
private static string Normalize(string value)
|
||
{
|
||
return string.Concat(value.Where(character => !char.IsWhiteSpace(character))).ToUpperInvariant();
|
||
}
|
||
|
||
[GeneratedRegex(@"(\d+)\s*套", RegexOptions.CultureInvariant)]
|
||
private static partial Regex BatchQuantityRegex();
|
||
|
||
[GeneratedRegex(@"^\s*(-?\d+(?:\.\d+)?)\s*[^\d\s].*$", RegexOptions.CultureInvariant)]
|
||
private static partial Regex NumberWithUnitRegex();
|
||
|
||
private sealed record RawBomRow(
|
||
int SourceRow,
|
||
string SourceSequence,
|
||
string DrawingNumber,
|
||
string Name,
|
||
string Specification,
|
||
decimal? Quantity,
|
||
string QuantityText,
|
||
string Material,
|
||
decimal? UnitWeight,
|
||
decimal? TotalWeight,
|
||
string UnitWeightText,
|
||
string TotalWeightText,
|
||
string Remark,
|
||
bool IsBlank,
|
||
bool IsIgnored)
|
||
{
|
||
public static RawBomRow Blank(int sourceRow)
|
||
{
|
||
return new RawBomRow(sourceRow, string.Empty, string.Empty, string.Empty, string.Empty, null,
|
||
string.Empty, string.Empty, null, null, string.Empty, string.Empty, string.Empty, true, false);
|
||
}
|
||
|
||
public static RawBomRow Ignored(int sourceRow, string sourceSequence)
|
||
{
|
||
return new RawBomRow(sourceRow, sourceSequence, string.Empty, string.Empty, string.Empty, null,
|
||
string.Empty, string.Empty, null, null, string.Empty, string.Empty, string.Empty, false, true);
|
||
}
|
||
}
|
||
|
||
private sealed record BomBlockReadResult(
|
||
List<List<RawBomRow>> Blocks,
|
||
IReadOnlyList<string> Warnings);
|
||
|
||
private sealed record NodeBuilder(
|
||
int SourceRow,
|
||
int? ParentSourceRow,
|
||
string SourceSequence,
|
||
string DrawingNumber,
|
||
string Name,
|
||
string Specification,
|
||
decimal? Quantity,
|
||
string QuantityText,
|
||
string Material,
|
||
decimal? UnitWeight,
|
||
decimal? TotalWeight,
|
||
string UnitWeightText,
|
||
string TotalWeightText,
|
||
string Remark,
|
||
int Level,
|
||
string BusinessKey);
|
||
}
|