using ClosedXML.Excel; using DongfangHydro.Dashboard.Api.Importing; using Xunit; namespace DongfangHydro.Dashboard.Tests; public sealed class BomWorkbookParserTests { [Fact] public void Parse_builds_hierarchy_and_preserves_repeated_material_occurrences() { using var stream = CreateWorkbook(); var document = BomWorkbookParser.Parse(stream, "test-bom.xlsx"); Assert.Equal("SG2026-06-033", document.Metadata.SourceDocumentCode); Assert.Equal("2437-QM0201", document.Metadata.WorkOrderCode); Assert.Equal("青峪口水库工程", document.Metadata.ProjectName); Assert.Equal("青峪口尾水单向门机 2×160/10", document.Metadata.ContractProductName); Assert.Equal("电站尾水2×160kN/100kN单向门式启闭机", document.Metadata.DrawingProductName); Assert.Equal(1, document.Metadata.BatchQuantity); Assert.Equal(3, document.BlockCount); Assert.Single(document.Warnings); Assert.Contains("第 11 行", document.Warnings[0]); Assert.Equal(5, document.BomNodes.Count); Assert.Equal(2, document.BomNodes.Count(node => node.Level == 1)); Assert.Single(document.BomNodes, node => node.Level == 2); Assert.Equal(2, document.BomNodes.Count(node => node.Level == 3)); var component = Assert.Single(document.BomNodes, node => node.DrawingNumber == "C-01"); Assert.Equal(2, component.ParentSourceRow); Assert.Equal("component", component.NodeType); Assert.Equal("self_made", component.SupplyType); Assert.Equal("焊接件", component.Specification); Assert.Equal("装配件", component.Material); Assert.Equal(4m, component.UnitWeight); Assert.Equal(8m, component.TotalWeight); Assert.Equal("备注", component.Remark); var bolts = document.BomNodes.Where(node => node.Name == "螺栓").OrderBy(node => node.SourceRow).ToList(); Assert.Equal(2, bolts.Count); Assert.All(bolts, node => Assert.Equal(6, node.ParentSourceRow)); Assert.Null(bolts[0].Quantity); Assert.Equal("/", bolts[0].QuantityText); Assert.Equal(-8m, bolts[1].Quantity); Assert.Null(bolts[1].UnitWeight); Assert.Null(bolts[1].TotalWeight); Assert.Equal("95L", bolts[1].UnitWeightText); Assert.Equal("170L", bolts[1].TotalWeightText); Assert.All(bolts, node => Assert.Equal("material", node.NodeType)); Assert.All(bolts, node => Assert.Equal("purchased", node.SupplyType)); Assert.NotEqual(bolts[0].SourceRow, bolts[1].SourceRow); } [Fact] public void Parse_reads_material_quota_categories_and_nullable_quantity() { using var stream = CreateWorkbook(); var document = BomWorkbookParser.Parse(stream, "test-bom.xlsx"); Assert.Equal(2, document.MaterialQuotas.Count); var steel = document.MaterialQuotas[0]; Assert.Equal("钢板", steel.Category); Assert.Equal("1010100182", steel.MaterialCode); Assert.Equal("钢板/Q235B", steel.MaterialName); Assert.Equal("δ2-1500", steel.Specification); Assert.Equal("kg", steel.Unit); Assert.Null(steel.Quantity); Assert.Null(steel.NetWeight); Assert.Equal("/", steel.NetWeightText); Assert.Equal(21m, steel.ConsumptionQuota); Assert.Equal("21件", steel.ConsumptionQuotaText); Assert.Equal("无", steel.Brand); Assert.Equal("外协外购", document.MaterialQuotas[1].Category); } [Fact] public void Parse_rejects_a_block_whose_parent_was_not_declared() { using var stream = CreateWorkbook(orphanParent: true); var exception = Assert.Throws(() => BomWorkbookParser.Parse(stream, "test-bom.xlsx")); Assert.Contains("父节点", exception.Message); Assert.Contains("P-X", exception.Message); } [Fact] public void Parse_imports_a_material_quota_row_without_a_material_code() { using var stream = CreateWorkbook(malformedQuota: true); var document = BomWorkbookParser.Parse(stream, "test-bom.xlsx"); var uncoded = Assert.Single(document.MaterialQuotas, item => item.SourceRow == 13); Assert.Equal(string.Empty, uncoded.MaterialCode); Assert.Equal("缺少编码的物料", uncoded.MaterialName); Assert.Equal("件", uncoded.Unit); } private static MemoryStream CreateWorkbook(bool orphanParent = false, bool malformedQuota = false) { using var workbook = new XLWorkbook(); var detail = workbook.Worksheets.Add("明细"); detail.Cell("A1").Value = "序号"; detail.Cell("B1").Value = "图号"; detail.Cell("C1").Value = "名称"; detail.Cell("D1").Value = "型号规格"; detail.Cell("E1").Value = "数量"; detail.Cell("F1").Value = "材料"; detail.Cell("G1").Value = "单重"; detail.Cell("H1").Value = "总重"; detail.Cell("I1").Value = "备注"; SetRow(detail, 2, 1, "P-01", "主起升机构", null, 1, "部件", 10, 10, null); SetRow(detail, 3, 2, "P-02", "门架", null, 1, "装焊件", 20, 20, null); SetRow(detail, 5, 1, orphanParent ? "P-X" : "P-01", "主起升机构", null, 1, "部件", 10, 10, null); SetRow(detail, 6, 1.1, "C-01", "机架", "焊接件", 2, "装配件", 4, 8, "备注"); SetRow(detail, 8, 1.1, "C-01", "机架", "焊接件", 2, "装配件", 4, 8, "备注"); SetRow(detail, 9, 1.11, "GB/T5783", "螺栓", "M12", 4, "标准件", 0.1, 0.4, null); detail.Cell(9, 5).Value = "/"; SetRow(detail, 10, 1.12, "GB/T5783", "螺栓", "M16", -8, "标准件", 0.2, 1.6, "厂家提供"); detail.Cell(10, 7).Value = "95L"; detail.Cell(10, 8).Value = "170L"; detail.Cell(11, 1).Value = "1.1.3"; var quota = workbook.Worksheets.Add("材料定额"); quota.Cell("A1").Value = "产品材料定额汇总表"; quota.Cell("A2").Value = "编号:SG2026-06-033"; quota.Cell("A3").Value = "合同(项目)名称:青峪口水库工程"; quota.Cell("H3").Value = "工作令号"; quota.Cell("I3").Value = "2437-QM0201"; quota.Cell("A4").Value = "产品合同名称:青峪口尾水单向门机 2×160/10"; quota.Cell("A5").Value = "产品图纸名称:电站尾水2×160kN/100kN单向门式启闭机"; quota.Cell("A6").Value = "说明:本表为1套产品定额"; var headers = new[] { "序号", "物料编码", "物料名称", "型号规格", "单位", "数量", "净重", "消耗定额", "品牌", "备注" }; for (var column = 1; column <= headers.Length; column++) { quota.Cell(7, column).Value = headers[column - 1]; } quota.Cell("A8").Value = "一"; quota.Cell("B8").Value = "钢板"; SetQuotaRow(quota, 9, 1, "1010100182", "钢板/Q235B", "δ2-1500", "kg", null, 33.61, 35.6266, "无", null); quota.Cell(9, 7).Value = "/"; quota.Cell(9, 8).Value = "21件"; quota.Cell("A11").Value = "二"; quota.Cell("B11").Value = "外协外购"; SetQuotaRow(quota, 12, 1, "2050000001", "减速器", "ZQ-500", "台", 1, 100, 100, "", "外购"); if (malformedQuota) { quota.Cell(13, 1).Value = 2; quota.Cell(13, 3).Value = "缺少编码的物料"; quota.Cell(13, 5).Value = "件"; } quota.Cell(14, 1).Value = "编码: 编制: 校核: 审核: 日期:2026.6.16"; var stream = new MemoryStream(); workbook.SaveAs(stream); stream.Position = 0; return stream; } private static void SetRow( IXLWorksheet worksheet, int row, double sequence, string drawingNumber, string name, string? specification, int quantity, string material, double unitWeight, double totalWeight, string? remark) { worksheet.Cell(row, 1).Value = sequence; worksheet.Cell(row, 2).Value = drawingNumber; worksheet.Cell(row, 3).Value = name; worksheet.Cell(row, 4).Value = specification; worksheet.Cell(row, 5).Value = quantity; worksheet.Cell(row, 6).Value = material; worksheet.Cell(row, 7).Value = unitWeight; worksheet.Cell(row, 8).Value = totalWeight; worksheet.Cell(row, 9).Value = remark; } private static void SetQuotaRow( IXLWorksheet worksheet, int row, int sequence, string materialCode, string materialName, string specification, string unit, double? quantity, double netWeight, double consumptionQuota, string brand, string? remark) { worksheet.Cell(row, 1).Value = sequence; worksheet.Cell(row, 2).Value = materialCode; worksheet.Cell(row, 3).Value = materialName; worksheet.Cell(row, 4).Value = specification; worksheet.Cell(row, 5).Value = unit; if (quantity.HasValue) { worksheet.Cell(row, 6).Value = quantity.Value; } worksheet.Cell(row, 7).Value = netWeight; worksheet.Cell(row, 8).Value = consumptionQuota; worksheet.Cell(row, 9).Value = brand; worksheet.Cell(row, 10).Value = remark; } }