feat: initialize manufacturing progress dashboard
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user