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,212 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using DongfangHydro.Dashboard.Api.Data;
using DongfangHydro.Dashboard.Api.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace DongfangHydro.Dashboard.Tests;
public sealed class BomDataApiTests(DashboardApiFactory factory)
: IClassFixture<DashboardApiFactory>
{
[Fact]
public async Task MaterialQuotas_supports_category_keyword_and_paging()
{
using var client = factory.CreateClient();
Guid orderId;
await using (var scope = factory.Services.CreateAsyncScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<DashboardDbContext>();
orderId = await dbContext.ProductionOrders.Select(order => order.Id).FirstAsync();
dbContext.MaterialQuotas.AddRange(
CreateQuota(orderId, "钢板", "1010100182", "钢板/Q235B", 9),
CreateQuota(orderId, "标准件", "1110100001", "螺栓/8.8级", 188));
await dbContext.SaveChangesAsync();
}
var response = await client.GetAsync(
$"/api/orders/{orderId}/material-quotas?category=钢板&keyword=Q235B&page=1&pageSize=10");
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(1, body.GetProperty("total").GetInt32());
var item = Assert.Single(body.GetProperty("items").EnumerateArray());
Assert.Equal("钢板", item.GetProperty("category").GetString());
Assert.Equal("1010100182", item.GetProperty("materialCode").GetString());
Assert.Equal(33.61m, item.GetProperty("netWeight").GetDecimal());
Assert.Equal(35.6266m, item.GetProperty("consumptionQuota").GetDecimal());
Assert.Equal("33.61", item.GetProperty("netWeightText").GetString());
Assert.Equal("35.6266", item.GetProperty("consumptionQuotaText").GetString());
Assert.Equal(9, item.GetProperty("sourceRow").GetInt32());
}
[Fact]
public async Task Tree_exposes_imported_bom_fields()
{
using var client = factory.CreateClient();
Guid orderId;
Guid nodeId;
await using (var scope = factory.Services.CreateAsyncScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<DashboardDbContext>();
var node = await dbContext.ProductionNodes.FirstAsync(item => item.NodeType == "process");
orderId = node.OrderId;
nodeId = node.Id;
node.Specification = "M16";
node.Material = "标准件";
node.UnitWeight = 0.2m;
node.TotalWeight = 1.6m;
node.UnitWeightText = "0.2";
node.TotalWeightText = "1.6";
node.BomQuantity = -8m;
node.BomQuantityText = "-8";
node.Remark = "镀锌";
node.SourceSequence = "1.1.2";
node.SourceSheet = "明细";
node.SourceRow = 10;
await dbContext.SaveChangesAsync();
}
var root = await client.GetFromJsonAsync<JsonElement>($"/api/orders/{orderId}/tree");
var nodeJson = FindNode(root, nodeId);
Assert.Equal("M16", nodeJson.GetProperty("specification").GetString());
Assert.Equal("标准件", nodeJson.GetProperty("material").GetString());
Assert.Equal(0.2m, nodeJson.GetProperty("unitWeight").GetDecimal());
Assert.Equal(1.6m, nodeJson.GetProperty("totalWeight").GetDecimal());
Assert.Equal("0.2", nodeJson.GetProperty("unitWeightText").GetString());
Assert.Equal("1.6", nodeJson.GetProperty("totalWeightText").GetString());
Assert.Equal(-8m, nodeJson.GetProperty("bomQuantity").GetDecimal());
Assert.Equal("-8", nodeJson.GetProperty("bomQuantityText").GetString());
Assert.Equal("镀锌", nodeJson.GetProperty("remark").GetString());
Assert.Equal("1.1.2", nodeJson.GetProperty("sourceSequence").GetString());
Assert.Equal("明细", nodeJson.GetProperty("sourceSheet").GetString());
Assert.Equal(10, nodeJson.GetProperty("sourceRow").GetInt32());
}
[Fact]
public async Task Material_leaf_accepts_direct_progress_updates()
{
using var client = factory.CreateClient();
var orderId = Guid.NewGuid();
var rootId = Guid.NewGuid();
var materialId = Guid.NewGuid();
await using (var scope = factory.Services.CreateAsyncScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<DashboardDbContext>();
dbContext.ProductionOrders.Add(new ProductionOrder
{
Id = orderId,
Code = $"ZZ-MAT-{orderId:N}"[..24],
ProductName = "物料报工测试",
BatchQty = 1,
RequiredQty = 10,
Status = "waiting",
RiskLevel = "normal",
DataSource = "test",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
RowVersion = [1],
});
dbContext.ProductionNodes.AddRange(
new ProductionNode
{
Id = rootId,
OrderId = orderId,
Code = "ROOT",
Name = "测试订单",
NodeType = "order",
SupplyType = "self_made",
RequiredQty = 10,
Status = "waiting",
RiskLevel = "normal",
UpdatedAt = DateTimeOffset.UtcNow,
RowVersion = [1],
},
new ProductionNode
{
Id = materialId,
OrderId = orderId,
ParentNodeId = rootId,
Code = "MAT-01",
Name = "钢板",
Level = 1,
NodeType = "material",
SupplyType = "purchased",
RequiredQty = 10,
Status = "waiting",
RiskLevel = "normal",
UpdatedAt = DateTimeOffset.UtcNow,
RowVersion = [1],
});
await dbContext.SaveChangesAsync();
}
var tree = await client.GetFromJsonAsync<JsonElement>($"/api/orders/{orderId}/tree");
var material = FindNode(tree, materialId);
var response = await client.PatchAsJsonAsync(
$"/api/nodes/{materialId}/progress",
new
{
completedQty = 10,
expectedVersion = material.GetProperty("version").GetString(),
status = "done",
riskLevel = "normal",
});
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal("material", body.GetProperty("node").GetProperty("nodeType").GetString());
Assert.Equal(100, body.GetProperty("node").GetProperty("progress").GetInt32());
}
private static MaterialQuota CreateQuota(
Guid orderId,
string category,
string materialCode,
string materialName,
int sourceRow)
{
return new MaterialQuota
{
Id = Guid.NewGuid(),
OrderId = orderId,
Category = category,
SourceSequence = "1",
MaterialCode = materialCode,
MaterialName = materialName,
Specification = "δ2-1500",
Unit = "kg",
NetWeight = 33.61m,
ConsumptionQuota = 35.6266m,
NetWeightText = "33.61",
ConsumptionQuotaText = "35.6266",
Brand = "无",
Remark = string.Empty,
SourceSheet = "材料定额",
SourceRow = sourceRow,
};
}
private static JsonElement FindNode(JsonElement node, Guid nodeId)
{
if (node.GetProperty("id").GetGuid() == nodeId)
{
return node;
}
foreach (var child in node.GetProperty("children").EnumerateArray())
{
var match = FindNode(child, nodeId);
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
return default;
}
}
@@ -0,0 +1,34 @@
using DongfangHydro.Dashboard.Api.Importing;
using Xunit;
namespace DongfangHydro.Dashboard.Tests;
public sealed class BomImportCommandTests
{
[Fact]
public void Parse_reads_file_and_switches()
{
var command = BomImportCommand.Parse(
["import-bom", "--file", "C:/data/bom.xlsx", "--purge-demo", "--validate-only", "--force"]);
Assert.NotNull(command);
Assert.Equal("C:/data/bom.xlsx", command.FilePath);
Assert.True(command.PurgeDemo);
Assert.True(command.ValidateOnly);
Assert.True(command.Force);
}
[Fact]
public void Parse_returns_null_for_normal_web_host_arguments()
{
Assert.Null(BomImportCommand.Parse(["--urls", "http://localhost:5080"]));
}
[Fact]
public void Parse_rejects_import_without_a_file()
{
var exception = Assert.Throws<BomImportException>(() => BomImportCommand.Parse(["import-bom"]));
Assert.Contains("--file", exception.Message);
}
}
@@ -0,0 +1,136 @@
using DongfangHydro.Dashboard.Api.Data;
using DongfangHydro.Dashboard.Api.Domain;
using DongfangHydro.Dashboard.Api.Importing;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace DongfangHydro.Dashboard.Tests;
public sealed class BomImportServiceTests
{
[Fact]
public void Order_code_index_is_not_unique_because_source_document_is_the_import_identity()
{
var options = new DbContextOptionsBuilder<DashboardDbContext>()
.UseInMemoryDatabase($"bom-model-{Guid.NewGuid():N}")
.Options;
using var dbContext = new DashboardDbContext(options);
var orderType = dbContext.Model.FindEntityType(typeof(ProductionOrder))!;
var codeIndex = Assert.Single(orderType.GetIndexes(), index =>
index.Properties.Count == 1 && index.Properties[0].Name == nameof(ProductionOrder.Code));
Assert.False(codeIndex.IsUnique);
}
[Fact]
public async Task Import_replaces_matching_source_purges_demo_and_is_idempotent()
{
var options = new DbContextOptionsBuilder<DashboardDbContext>()
.UseInMemoryDatabase($"bom-import-{Guid.NewGuid():N}")
.Options;
await using var dbContext = new DashboardDbContext(options);
await dbContext.Database.EnsureCreatedAsync();
dbContext.ProductionOrders.AddRange(
CreateOrder("MO-DEMO", "demo", string.Empty),
CreateOrder("2437-OLD", "excel", "SG2026-06-033"),
CreateOrder("KEEP-001", "manual", "OTHER-DOC"));
await dbContext.SaveChangesAsync();
var service = new BomImportService(dbContext);
var document = CreateDocument();
var first = await service.ImportAsync(document, purgeDemo: true, CancellationToken.None);
var progressedNode = await dbContext.ProductionNodes.SingleAsync(node => node.SourceRow == 6);
progressedNode.CompletedQty = 1;
progressedNode.Progress = 50;
progressedNode.Status = "in_progress";
await dbContext.SaveChangesAsync();
var second = await service.ImportAsync(document, purgeDemo: true, CancellationToken.None);
Assert.Equal(1, first.RemovedDemoOrders);
Assert.Equal(1, first.ReplacedOrders);
Assert.Equal(0, second.ReplacedOrders);
Assert.Equal(2, second.TotalOrders);
Assert.Equal(2, await dbContext.ProductionOrders.CountAsync());
Assert.DoesNotContain(await dbContext.ProductionOrders.ToListAsync(), order => order.DataSource == "demo");
Assert.Contains(await dbContext.ProductionOrders.ToListAsync(), order => order.Code == "KEEP-001");
var importedOrder = await dbContext.ProductionOrders.SingleAsync(
order => order.SourceDocumentCode == "SG2026-06-033");
Assert.Equal("2437-QM0201", importedOrder.Code);
Assert.Equal("excel", importedOrder.DataSource);
Assert.Null(importedOrder.PlannedStart);
Assert.Null(importedOrder.PlannedEnd);
var nodes = await dbContext.ProductionNodes
.Where(node => node.OrderId == importedOrder.Id)
.OrderBy(node => node.Level)
.ThenBy(node => node.SourceRow)
.ToListAsync();
Assert.Equal(3, nodes.Count);
Assert.Equal("order", nodes[0].NodeType);
Assert.Equal("part", nodes[1].NodeType);
Assert.Equal("material", nodes[2].NodeType);
Assert.Equal(nodes[0].Id, nodes[1].ParentNodeId);
Assert.Equal(nodes[1].Id, nodes[2].ParentNodeId);
Assert.Equal(1, nodes.Single(node => node.SourceRow == 6).CompletedQty);
Assert.Equal(50, nodes.Single(node => node.SourceRow == 6).Progress);
Assert.Equal("in_progress", nodes.Single(node => node.SourceRow == 6).Status);
var quota = await dbContext.MaterialQuotas.SingleAsync(item => item.OrderId == importedOrder.Id);
Assert.Equal("钢板", quota.Category);
Assert.Equal(33.61m, quota.NetWeight);
Assert.Equal(9, quota.SourceRow);
var forced = await service.ImportAsync(
document,
purgeDemo: true,
CancellationToken.None,
forceReplace: true);
Assert.Equal(1, forced.ReplacedOrders);
Assert.False(forced.SkippedUnchanged);
Assert.Equal(0, (await dbContext.ProductionNodes.SingleAsync(node => node.SourceRow == 6)).Progress);
}
private static ProductionOrder CreateOrder(string code, string dataSource, string sourceDocumentCode)
{
return new ProductionOrder
{
Id = Guid.NewGuid(),
Code = code,
ProductName = code,
DataSource = dataSource,
SourceDocumentCode = sourceDocumentCode,
BatchQty = 1,
RequiredQty = 1,
Status = "waiting",
RiskLevel = "normal",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
};
}
private static BomImportDocument CreateDocument()
{
return new BomImportDocument(
new BomImportMetadata(
"SG2026-06-033",
"2437-QM0201",
"青峪口水库工程",
"青峪口尾水单向门机 2×160/10",
"电站尾水2×160kN/100kN单向门式启闭机",
1,
"bom.xlsx",
new string('a', 64)),
[
new BomImportNode(2, null, "1", "P-01", "主起升机构", "", 1, "部件", 10, 10, "", 1, "part", "self_made"),
new BomImportNode(6, 2, "1.1", "GB/T700", "钢板", "δ10", 2, "Q235B", 5, 10, "", 2, "material", "purchased"),
],
[
new MaterialQuotaImportRow(9, "钢板", "1", "1010100182", "钢板/Q235B", "δ2-1500", "kg", null, 33.61m, 35.6266m, "无", ""),
],
2,
[]);
}
}
@@ -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;
}
}
@@ -0,0 +1,228 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using DongfangHydro.Dashboard.Api.Data;
using DongfangHydro.Dashboard.Api.Contracts;
using DongfangHydro.Dashboard.Api.Realtime;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Xunit;
namespace DongfangHydro.Dashboard.Tests;
public sealed class DashboardApiTests(DashboardApiFactory factory)
: IClassFixture<DashboardApiFactory>
{
[Fact]
public async Task Orders_SearchesSeededWaterConservancyProductNames()
{
using var client = factory.CreateClient();
var response = await client.GetAsync("/api/orders?keyword=门架&page=1&pageSize=20");
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.True(body.GetProperty("total").GetInt32() >= 1);
Assert.Contains(
body.GetProperty("items").EnumerateArray(),
item => item.GetProperty("productName").GetString()!.Contains("门架", StringComparison.Ordinal));
}
[Fact]
public async Task OrderTree_ReturnsTheCompleteSeededHierarchy()
{
using var client = factory.CreateClient();
var orders = await client.GetFromJsonAsync<JsonElement>("/api/orders?page=1&pageSize=20");
var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString();
var root = await client.GetFromJsonAsync<JsonElement>($"/api/orders/{orderId}/tree");
Assert.Equal("order", root.GetProperty("nodeType").GetString());
Assert.Equal(66, CountNodes(root));
Assert.Equal(1, CountNodes(root, "order"));
Assert.Equal(5, CountNodes(root, "part"));
Assert.Equal(15, CountNodes(root, "component"));
Assert.Equal(45, CountNodes(root, "process"));
Assert.False(string.IsNullOrWhiteSpace(FindFirstProcess(root).GetProperty("version").GetString()));
}
[Fact]
public async Task UpdateProgress_RecalculatesTheOrderAndReturnsTheUpdatedNode()
{
using var client = factory.CreateClient();
factory.UpdateQueue.Reset();
var orders = await client.GetFromJsonAsync<JsonElement>("/api/orders?page=1&pageSize=20");
var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString();
var root = await client.GetFromJsonAsync<JsonElement>($"/api/orders/{orderId}/tree");
var process = FindFirstProcess(root);
var response = await client.PatchAsJsonAsync(
$"/api/nodes/{process.GetProperty("id").GetString()}/progress",
new
{
completedQty = process.GetProperty("requiredQty").GetInt32(),
expectedVersion = process.GetProperty("version").GetString(),
status = "done",
riskLevel = "normal",
delayDays = 0,
delayReason = string.Empty,
});
var updated = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(100, updated.GetProperty("node").GetProperty("progress").GetInt32());
Assert.Equal("done", updated.GetProperty("node").GetProperty("status").GetString());
Assert.Equal(orderId, updated.GetProperty("order").GetProperty("id").GetString());
Assert.Equal(1, factory.UpdateQueue.EnqueueCount);
}
[Fact]
public async Task UpdateProgress_RejectsARequestWithoutCompletedQuantity()
{
using var client = factory.CreateClient();
var process = await GetFirstProcessAsync(client);
var response = await client.PatchAsJsonAsync(
$"/api/nodes/{process.GetProperty("id").GetString()}/progress",
new { status = "in_progress" });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task UpdateProgress_RejectsDoneStatusBeforeThePlannedQuantityIsComplete()
{
using var client = factory.CreateClient();
var process = await GetFirstProcessAsync(client);
var requiredQty = process.GetProperty("requiredQty").GetInt32();
var response = await client.PatchAsJsonAsync(
$"/api/nodes/{process.GetProperty("id").GetString()}/progress",
new
{
completedQty = requiredQty - 1,
expectedVersion = process.GetProperty("version").GetString(),
status = "done",
riskLevel = "normal",
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task UpdateProgress_RequiresTheClientsExpectedVersion()
{
using var client = factory.CreateClient();
var process = await GetFirstProcessAsync(client);
var response = await client.PatchAsJsonAsync(
$"/api/nodes/{process.GetProperty("id").GetString()}/progress",
new
{
completedQty = process.GetProperty("completedQty").GetInt32(),
status = process.GetProperty("status").GetString(),
riskLevel = process.GetProperty("riskLevel").GetString(),
});
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
private static int CountNodes(JsonElement node)
{
var count = 1;
if (node.TryGetProperty("children", out var children))
{
count += children.EnumerateArray().Sum(CountNodes);
}
return count;
}
private static int CountNodes(JsonElement node, string nodeType)
{
var count = node.GetProperty("nodeType").GetString() == nodeType ? 1 : 0;
if (node.TryGetProperty("children", out var children))
{
count += children.EnumerateArray().Sum(child => CountNodes(child, nodeType));
}
return count;
}
private static async Task<JsonElement> GetFirstProcessAsync(HttpClient client)
{
var orders = await client.GetFromJsonAsync<JsonElement>("/api/orders?page=1&pageSize=20");
var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString();
var root = await client.GetFromJsonAsync<JsonElement>($"/api/orders/{orderId}/tree");
return FindFirstProcess(root);
}
private static JsonElement FindFirstProcess(JsonElement node)
{
if (node.GetProperty("nodeType").GetString() == "process")
{
return node;
}
foreach (var child in node.GetProperty("children").EnumerateArray())
{
var match = FindFirstProcess(child);
if (match.ValueKind != JsonValueKind.Undefined)
{
return match;
}
}
return default;
}
}
public sealed class DashboardApiFactory : WebApplicationFactory<Program>
{
private readonly string databaseName = $"dashboard-tests-{Guid.NewGuid():N}";
public RecordingDashboardUpdateQueue UpdateQueue { get; } = new();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureAppConfiguration((_, configuration) =>
{
configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Database:AutoMigrate"] = "true",
["Database:Seed"] = "true",
});
});
builder.ConfigureServices(services =>
{
services.RemoveAll<DbContextOptions<DashboardDbContext>>();
services.AddDbContext<DashboardDbContext>(options =>
options.UseInMemoryDatabase(databaseName));
services.RemoveAll<IDashboardUpdateQueue>();
services.AddSingleton<IDashboardUpdateQueue>(UpdateQueue);
});
}
}
public sealed class RecordingDashboardUpdateQueue : IDashboardUpdateQueue
{
public int EnqueueCount { get; private set; }
public ValueTask EnqueueAsync(
UpdateNodeProgressResultDto update,
CancellationToken cancellationToken)
{
EnqueueCount++;
return ValueTask.CompletedTask;
}
public void Reset()
{
EnqueueCount = 0;
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.20" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DongfangHydro.Dashboard.Api\DongfangHydro.Dashboard.Api.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,132 @@
using DongfangHydro.Dashboard.Api.Domain;
using DongfangHydro.Dashboard.Api.Services;
using Xunit;
namespace DongfangHydro.Dashboard.Tests;
public sealed class ProgressAggregationServiceTests
{
[Fact]
public void Recalculate_WeightsProgressAndBubblesRiskAndDelayToTheOrder()
{
var order = new ProductionOrder
{
Id = Guid.NewGuid(),
Code = "MO-TEST-001",
ProductName = "青峪口尾水单向门机 2×160/10",
};
var root = Node(order.Id, "ROOT", null, "order", 30, 0);
var completed = Node(order.Id, "P-01", root.Id, "part", 10, 10);
var delayed = Node(order.Id, "P-02", root.Id, "part", 20, 10);
delayed.Status = "delayed";
delayed.RiskLevel = "critical";
delayed.DelayDays = 3;
new ProgressAggregationService().Recalculate(order, [root, completed, delayed]);
Assert.Equal(67, root.Progress);
Assert.Equal(20, root.CompletedQty);
Assert.Equal("delayed", root.Status);
Assert.Equal("critical", root.RiskLevel);
Assert.Equal(3, root.DelayDays);
Assert.Equal(67, order.Progress);
Assert.Equal(20, order.CompletedQty);
Assert.Equal("delayed", order.Status);
Assert.Equal("critical", order.RiskLevel);
Assert.Equal(3, order.DelayDays);
}
[Fact]
public void Recalculate_MarksParentDoneWhenAllChildrenAreDone()
{
var order = new ProductionOrder
{
Id = Guid.NewGuid(),
Code = "MO-TEST-002",
ProductName = "门架",
};
var root = Node(order.Id, "ROOT", null, "order", 10, 0);
var childA = Node(order.Id, "P-01", root.Id, "part", 4, 4);
var childB = Node(order.Id, "P-02", root.Id, "part", 6, 6);
new ProgressAggregationService().Recalculate(order, [root, childA, childB]);
Assert.Equal(100, root.Progress);
Assert.Equal("done", root.Status);
Assert.Equal("normal", root.RiskLevel);
Assert.Equal(100, order.Progress);
Assert.Equal("done", order.Status);
}
[Fact]
public void Recalculate_KeepsTheParentsPlannedQuantityWhileUsingChildrenAsWeights()
{
var order = new ProductionOrder
{
Id = Guid.NewGuid(),
Code = "MO-TEST-003",
ProductName = "大车行走机构",
};
var root = Node(order.Id, "ROOT", null, "order", 5, 0);
var childA = Node(order.Id, "P-01", root.Id, "part", 10, 10);
var childB = Node(order.Id, "P-02", root.Id, "part", 20, 10);
new ProgressAggregationService().Recalculate(order, [root, childA, childB]);
Assert.Equal(5, root.RequiredQty);
Assert.Equal(3, root.CompletedQty);
Assert.Equal(5, order.RequiredQty);
Assert.Equal(3, order.CompletedQty);
Assert.Equal(67, order.Progress);
}
[Theory]
[InlineData("blocked", 0, "critical")]
[InlineData("delayed", 1, "warning")]
[InlineData("in_progress", 3, "critical")]
public void Recalculate_BubblesOperationalRiskEvenWhenTheLeafRiskWasNotSet(
string childStatus,
int delayDays,
string expectedRisk)
{
var order = new ProductionOrder
{
Id = Guid.NewGuid(),
Code = "MO-TEST-004",
ProductName = "门机电气控制系统",
};
var root = Node(order.Id, "ROOT", null, "order", 10, 0);
var child = Node(order.Id, "OP-10", root.Id, "process", 10, 4);
child.Status = childStatus;
child.DelayDays = delayDays;
child.RiskLevel = "normal";
new ProgressAggregationService().Recalculate(order, [root, child]);
Assert.Equal(expectedRisk, root.RiskLevel);
Assert.Equal(expectedRisk, order.RiskLevel);
}
private static ProductionNode Node(
Guid orderId,
string code,
Guid? parentId,
string nodeType,
int requiredQty,
int completedQty)
{
return new ProductionNode
{
Id = Guid.NewGuid(),
OrderId = orderId,
ParentNodeId = parentId,
Code = code,
Name = code,
NodeType = nodeType,
RequiredQty = requiredQty,
CompletedQty = completedQty,
Status = completedQty >= requiredQty ? "done" : "in_progress",
RiskLevel = "normal",
};
}
}
@@ -0,0 +1,26 @@
using DongfangHydro.Dashboard.Api.Data;
using DongfangHydro.Dashboard.Api.Services;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace DongfangHydro.Dashboard.Tests;
public sealed class SqlServerQueryShapeTests
{
[Fact]
public void RecentTrendQuery_UsesAServerSidePerOrderWindow()
{
var options = new DbContextOptionsBuilder<DashboardDbContext>()
.UseSqlServer("Server=localhost;Database=query-shape;TrustServerCertificate=True")
.Options;
using var dbContext = new DashboardDbContext(options);
var orderIds = new[] { Guid.NewGuid(), Guid.NewGuid() };
var query = DashboardQueryService.BuildRecentTrendQuery(dbContext, orderIds, 12);
var sql = query.ToQueryString();
Assert.Contains("ROW_NUMBER", sql, StringComparison.OrdinalIgnoreCase);
Assert.Contains("PARTITION BY", sql, StringComparison.OrdinalIgnoreCase);
}
}