feat: initialize manufacturing progress dashboard

This commit is contained in:
czc
2026-07-13 18:07:59 +08:00
commit 658a23b630
87 changed files with 19195 additions and 0 deletions
@@ -0,0 +1,221 @@
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<BomImportException>(() =>
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;
}
}