Files
BI/server/DongfangHydro.Dashboard.Api/Importing/BomWorkbookParser.cs
T

535 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}