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,147 @@
namespace DongfangHydro.Dashboard.Api.Contracts;
public sealed record ProductionNodeDto(
Guid Id,
Guid OrderId,
Guid? ParentId,
string Code,
string OperationCode,
string MaterialCode,
string Name,
string SourceSequence,
string Specification,
string Material,
decimal? UnitWeight,
decimal? TotalWeight,
string UnitWeightText,
string TotalWeightText,
decimal? BomQuantity,
string BomQuantityText,
string Remark,
string SourceSheet,
int SourceRow,
int Level,
string NodeType,
string SupplyType,
int RequiredQty,
int CompletedQty,
int DefectQty,
int Progress,
string Status,
string RiskLevel,
int DelayDays,
string DelayReason,
string PlannedStart,
string PlannedEnd,
string ActualStart,
string ActualEnd,
string Owner,
string Vendor,
string StationName,
string VisualKey,
string Version,
IReadOnlyList<ProductionNodeDto> Children);
public sealed record TrendPointDto(
string Time,
int Completion,
int Risk,
int PlannedQty,
int ActualQty,
double Achievement);
public sealed record RiskEventDto(
Guid Id,
Guid OrderId,
Guid NodeId,
string OrderCode,
string NodeName,
string RiskLevel,
string Message,
string Time,
string HandlingStatus);
public sealed record OrderSummaryDto(
Guid Id,
string Code,
string ProductName,
int BatchQty,
int RequiredQty,
int CompletedQty,
int Progress,
string Status,
string RiskLevel,
int DelayDays,
string PlannedStart,
string PlannedEnd,
string LineName,
string Owner,
double PlanAchievement,
double DailyDelta,
string ThumbnailKey,
ProductionNodeDto Root,
IReadOnlyList<TrendPointDto> Trend,
IReadOnlyList<RiskEventDto> Events);
public sealed record MaterialQuotaDto(
Guid Id,
Guid OrderId,
string Category,
string SourceSequence,
string MaterialCode,
string MaterialName,
string Specification,
string Unit,
decimal? Quantity,
decimal? NetWeight,
decimal? ConsumptionQuota,
string QuantityText,
string NetWeightText,
string ConsumptionQuotaText,
string Brand,
string Remark,
string SourceSheet,
int SourceRow);
public sealed record PagedResult<T>(
IReadOnlyList<T> Items,
int Total,
int Page,
int PageSize);
public sealed record DashboardOverviewDto(
int TotalOrders,
int TotalRequiredQty,
int TotalCompletedQty,
double OverallProgress,
int DelayedOrders,
int CriticalOrders,
int WarningOrders,
double PlanAchievement,
DateTimeOffset RefreshedAt);
public sealed record DelayTopItemDto(
int Rank,
Guid OrderId,
string OrderCode,
string ProductName,
int DelayDays,
string DelayReason,
string RiskLevel);
public sealed record UpdateNodeProgressRequest(
int? CompletedQty,
string? ExpectedVersion,
string? Status,
string? RiskLevel,
int? DelayDays,
string? DelayReason,
int? DefectQty,
DateTime? ActualStart,
DateTime? ActualEnd);
public sealed record UpdateNodeProgressResultDto(
ProductionNodeDto Node,
OrderSummaryDto Order,
RiskEventDto? RiskEvent,
TrendPointDto TrendPoint);
@@ -0,0 +1,172 @@
using DongfangHydro.Dashboard.Api.Domain;
using Microsoft.EntityFrameworkCore;
namespace DongfangHydro.Dashboard.Api.Data;
public sealed class DashboardDbContext(DbContextOptions<DashboardDbContext> options)
: DbContext(options)
{
public DbSet<ProductionOrder> ProductionOrders => Set<ProductionOrder>();
public DbSet<ProductionNode> ProductionNodes => Set<ProductionNode>();
public DbSet<ProductionTrendPoint> ProductionTrendPoints => Set<ProductionTrendPoint>();
public DbSet<RiskEvent> RiskEvents => Set<RiskEvent>();
public DbSet<ProductionLine> ProductionLines => Set<ProductionLine>();
public DbSet<Partner> Partners => Set<Partner>();
public DbSet<MaterialQuota> MaterialQuotas => Set<MaterialQuota>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<ProductionOrder>(entity =>
{
entity.ToTable("ProductionOrders");
entity.HasKey(order => order.Id);
entity.HasIndex(order => order.Code);
entity.HasIndex(order => new { order.Status, order.RiskLevel, order.PlannedEnd });
entity.Property(order => order.Code).HasMaxLength(40);
entity.Property(order => order.ProductName).HasMaxLength(200);
entity.Property(order => order.DataSource).HasMaxLength(32);
entity.Property(order => order.SourceDocumentCode).HasMaxLength(80);
entity.Property(order => order.ProjectName).HasMaxLength(200);
entity.Property(order => order.DrawingProductName).HasMaxLength(240);
entity.Property(order => order.SourceFileName).HasMaxLength(260);
entity.Property(order => order.SourceFileHash).HasMaxLength(64);
entity.HasIndex(order => order.SourceDocumentCode)
.IsUnique()
.HasFilter("[SourceDocumentCode] <> ''");
entity.Property(order => order.Status).HasMaxLength(32);
entity.Property(order => order.RiskLevel).HasMaxLength(32);
entity.Property(order => order.LineName).HasMaxLength(80);
entity.Property(order => order.Owner).HasMaxLength(80);
entity.Property(order => order.ThumbnailKey).HasMaxLength(40);
entity.Property(order => order.RowVersion).IsRowVersion();
entity.HasOne<ProductionLine>()
.WithMany()
.HasForeignKey(order => order.LineId)
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<ProductionNode>(entity =>
{
entity.ToTable("ProductionNodes");
entity.HasKey(node => node.Id);
entity.HasIndex(node => new { node.OrderId, node.ParentNodeId, node.SortOrder });
entity.HasIndex(node => new { node.OrderId, node.NodeType, node.RiskLevel, node.Status });
entity.Property(node => node.Code).HasMaxLength(60);
entity.Property(node => node.OperationCode).HasMaxLength(60);
entity.Property(node => node.MaterialCode).HasMaxLength(80);
entity.Property(node => node.Name).HasMaxLength(240);
entity.Property(node => node.SourceSequence).HasMaxLength(40);
entity.Property(node => node.Specification).HasMaxLength(240);
entity.Property(node => node.Material).HasMaxLength(120);
entity.Property(node => node.UnitWeight).HasPrecision(18, 4);
entity.Property(node => node.TotalWeight).HasPrecision(18, 4);
entity.Property(node => node.UnitWeightText).HasMaxLength(80);
entity.Property(node => node.TotalWeightText).HasMaxLength(80);
entity.Property(node => node.BomQuantity).HasPrecision(18, 4);
entity.Property(node => node.BomQuantityText).HasMaxLength(80);
entity.Property(node => node.Remark).HasMaxLength(500);
entity.Property(node => node.SourceSheet).HasMaxLength(40);
entity.Property(node => node.NodeType).HasMaxLength(32);
entity.Property(node => node.SupplyType).HasMaxLength(32);
entity.Property(node => node.Status).HasMaxLength(32);
entity.Property(node => node.RiskLevel).HasMaxLength(32);
entity.Property(node => node.DelayReason).HasMaxLength(500);
entity.Property(node => node.Owner).HasMaxLength(100);
entity.Property(node => node.Vendor).HasMaxLength(120);
entity.Property(node => node.StationName).HasMaxLength(120);
entity.Property(node => node.VisualKey).HasMaxLength(40);
entity.Property(node => node.RowVersion).IsRowVersion();
entity.HasAlternateKey(node => new { node.OrderId, node.Id });
entity.HasOne<ProductionOrder>()
.WithMany()
.HasForeignKey(node => node.OrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ProductionNode>()
.WithMany()
.HasForeignKey(node => new { node.OrderId, node.ParentNodeId })
.HasPrincipalKey(node => new { node.OrderId, node.Id })
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<ProductionTrendPoint>(entity =>
{
entity.ToTable("ProductionTrendPoints");
entity.HasKey(point => point.Id);
entity.HasIndex(point => new { point.OrderId, point.SampleTime }).IsDescending(false, true);
entity.HasOne<ProductionOrder>()
.WithMany()
.HasForeignKey(point => point.OrderId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<RiskEvent>(entity =>
{
entity.ToTable("RiskEvents");
entity.HasKey(riskEvent => riskEvent.Id);
entity.HasIndex(riskEvent => new { riskEvent.OrderId, riskEvent.OccurredAt }).IsDescending(false, true);
entity.Property(riskEvent => riskEvent.OrderCode).HasMaxLength(40);
entity.Property(riskEvent => riskEvent.NodeName).HasMaxLength(240);
entity.Property(riskEvent => riskEvent.RiskLevel).HasMaxLength(32);
entity.Property(riskEvent => riskEvent.Message).HasMaxLength(500);
entity.Property(riskEvent => riskEvent.HandlingStatus).HasMaxLength(32);
entity.HasOne<ProductionOrder>()
.WithMany()
.HasForeignKey(riskEvent => riskEvent.OrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne<ProductionNode>()
.WithMany()
.HasForeignKey(riskEvent => new { riskEvent.OrderId, riskEvent.NodeId })
.HasPrincipalKey(node => new { node.OrderId, node.Id })
.OnDelete(DeleteBehavior.NoAction);
});
modelBuilder.Entity<MaterialQuota>(entity =>
{
entity.ToTable("MaterialQuotas");
entity.HasKey(item => item.Id);
entity.HasIndex(item => new { item.OrderId, item.SourceSheet, item.SourceRow }).IsUnique();
entity.HasIndex(item => new { item.OrderId, item.Category, item.MaterialCode });
entity.Property(item => item.Category).HasMaxLength(80);
entity.Property(item => item.SourceSequence).HasMaxLength(40);
entity.Property(item => item.MaterialCode).HasMaxLength(80);
entity.Property(item => item.MaterialName).HasMaxLength(240);
entity.Property(item => item.Specification).HasMaxLength(240);
entity.Property(item => item.Unit).HasMaxLength(24);
entity.Property(item => item.Quantity).HasPrecision(18, 4);
entity.Property(item => item.NetWeight).HasPrecision(18, 4);
entity.Property(item => item.ConsumptionQuota).HasPrecision(18, 4);
entity.Property(item => item.QuantityText).HasMaxLength(80);
entity.Property(item => item.NetWeightText).HasMaxLength(80);
entity.Property(item => item.ConsumptionQuotaText).HasMaxLength(80);
entity.Property(item => item.Brand).HasMaxLength(120);
entity.Property(item => item.Remark).HasMaxLength(500);
entity.Property(item => item.SourceSheet).HasMaxLength(40);
entity.HasOne<ProductionOrder>()
.WithMany()
.HasForeignKey(item => item.OrderId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ProductionLine>(entity =>
{
entity.ToTable("ProductionLines");
entity.HasKey(line => line.Id);
entity.HasIndex(line => line.Code).IsUnique();
entity.Property(line => line.Code).HasMaxLength(40);
entity.Property(line => line.Name).HasMaxLength(80);
entity.Property(line => line.Owner).HasMaxLength(80);
});
modelBuilder.Entity<Partner>(entity =>
{
entity.ToTable("Partners");
entity.HasKey(partner => partner.Id);
entity.HasIndex(partner => partner.Code).IsUnique();
entity.Property(partner => partner.Code).HasMaxLength(40);
entity.Property(partner => partner.Name).HasMaxLength(120);
entity.Property(partner => partner.PartnerType).HasMaxLength(32);
entity.Property(partner => partner.ContactName).HasMaxLength(80);
entity.Property(partner => partner.ContactPhone).HasMaxLength(32);
});
}
}
@@ -0,0 +1,368 @@
using DongfangHydro.Dashboard.Api.Domain;
using DongfangHydro.Dashboard.Api.Services;
using Microsoft.EntityFrameworkCore;
namespace DongfangHydro.Dashboard.Api.Data;
public sealed class DashboardSeeder(
DashboardDbContext dbContext,
ProgressAggregationService aggregationService)
{
private static readonly string[] ProcessNames = ["备料齐套", "生产装配", "检验入库"];
private static readonly OrderBlueprint[] Orders =
[
new("MO-260708-001", "青峪口尾水单向门机 2×160/10", 1200, "总装一线", "周敏", 68, 4.3, "industrial-pc"),
new("MO-260708-002", "电站尾水2×160kN/100kN单向门式启闭机", 860, "总装二线", "李澈", 82, 2.8, "workstation"),
new("MO-260708-003", "主起升机构", 420, "高配小批线", "陈嘉", 54, -1.2, "workstation"),
new("MO-260708-004", "100kN回转吊", 1500, "总装三线", "罗岚", 43, -3.1, "aio"),
new("MO-260708-005", "门架", 2100, "柔性装配线", "宋一", 76, 1.6, "industrial-pc"),
new("MO-260708-006", "大车行走机构", 640, "精密装配线", "韩策", 61, -0.8, "edge-box"),
];
private static readonly PartBlueprint[] Parts =
[
new("主起升机构", "self_made", "重装车间", "自产工段", "fixture",
[
new("卷扬机构", "self_made", "起升装配组", "自产工段", "chassis"),
new("滑轮组", "outsourced", "机加协作组", "东方重工协作厂", "thermal"),
new("制动装置", "purchased", "采购一组", "华东液压", "power"),
]),
new("100kN回转吊", "outsourced", "回转装配组", "西南重装", "chassis",
[
new("回转支承", "purchased", "采购二组", "洛轴供应链", "chassis"),
new("起升卷筒", "self_made", "卷筒制造组", "自产工段", "fixture"),
new("吊钩组", "outsourced", "外协质检组", "川重锻造", "generic"),
]),
new("门架", "self_made", "结构车间", "自产工段", "mainboard",
[
new("主梁结构", "self_made", "铆焊一组", "自产工段", "mainboard"),
new("支腿结构", "self_made", "铆焊二组", "自产工段", "mainboard"),
new("连接平台", "outsourced", "结构外协组", "青峪钢构", "generic"),
]),
new("大车行走机构", "self_made", "行走机构组", "自产工段", "chassis",
[
new("行走台车", "self_made", "台车装配组", "自产工段", "chassis"),
new("轨道夹持器", "purchased", "采购三组", "水工制动", "fixture"),
new("驱动减速机", "purchased", "传动采购组", "国机传动", "power"),
]),
new("门机电气控制系统", "purchased", "电气车间", "东方电控", "power",
[
new("PLC控制柜", "self_made", "电控装配组", "自产工段", "power"),
new("电缆卷筒", "outsourced", "电缆协作组", "长江电缆", "cable"),
new("限位与监测装置", "purchased", "仪控采购组", "水工智控", "chip"),
]),
];
public async Task SeedAsync(CancellationToken cancellationToken)
{
if (await dbContext.ProductionOrders.AnyAsync(cancellationToken))
{
return;
}
var lines = Orders
.Select((order, index) => new ProductionLine
{
Id = Guid.NewGuid(),
Code = $"LINE-{index + 1:00}",
Name = order.LineName,
Owner = order.Owner,
})
.ToList();
dbContext.ProductionLines.AddRange(lines);
dbContext.Partners.AddRange(
new Partner { Id = Guid.NewGuid(), Code = "SUP-001", Name = "东方重工协作厂", PartnerType = "outsourcer" },
new Partner { Id = Guid.NewGuid(), Code = "SUP-002", Name = "华东液压", PartnerType = "supplier" },
new Partner { Id = Guid.NewGuid(), Code = "SUP-003", Name = "水工智控", PartnerType = "supplier" });
var now = DateTimeOffset.UtcNow;
for (var orderIndex = 0; orderIndex < Orders.Length; orderIndex++)
{
var blueprint = Orders[orderIndex];
var order = new ProductionOrder
{
Id = Guid.NewGuid(),
Code = blueprint.Code,
ProductName = blueprint.ProductName,
DataSource = "demo",
BatchQty = blueprint.BatchQty,
RequiredQty = blueprint.BatchQty,
PlannedStart = new DateTime(2026, 7, 1).AddDays(orderIndex % 4),
PlannedEnd = new DateTime(2026, 7, 14).AddDays(orderIndex),
LineId = lines[orderIndex].Id,
LineName = blueprint.LineName,
Owner = blueprint.Owner,
DailyDelta = blueprint.DailyDelta,
ThumbnailKey = blueprint.ThumbnailKey,
CreatedAt = now.AddDays(-10 - orderIndex),
UpdatedAt = now,
RowVersion = Guid.NewGuid().ToByteArray(),
};
var nodes = BuildNodes(order, blueprint, orderIndex, now);
aggregationService.Recalculate(order, nodes);
order.PlanAchievement = Math.Clamp(order.Progress + 8 - order.DelayDays * 1.5, 0, 120);
dbContext.ProductionOrders.Add(order);
dbContext.ProductionNodes.AddRange(nodes);
dbContext.ProductionTrendPoints.AddRange(BuildTrend(order, orderIndex, now));
dbContext.RiskEvents.AddRange(BuildRiskEvents(order, nodes, now));
}
await dbContext.SaveChangesAsync(cancellationToken);
}
private static List<ProductionNode> BuildNodes(
ProductionOrder order,
OrderBlueprint blueprint,
int orderIndex,
DateTimeOffset now)
{
var nodes = new List<ProductionNode>();
var root = CreateNode(
order,
null,
"ROOT",
order.ProductName,
0,
"order",
"self_made",
order.BatchQty,
0,
order.Owner,
"东方水利",
blueprint.ThumbnailKey,
0,
order.PlannedStart!.Value,
order.PlannedEnd!.Value,
now);
nodes.Add(root);
for (var partIndex = 0; partIndex < Parts.Length; partIndex++)
{
var part = Parts[partIndex];
var partNode = CreateNode(
order,
root.Id,
$"P-{partIndex + 1:00}",
part.Name,
1,
"part",
part.SupplyType,
order.BatchQty,
partIndex,
part.Owner,
part.Vendor,
part.VisualKey,
partIndex,
order.PlannedStart!.Value.AddDays(partIndex),
order.PlannedEnd!.Value.AddDays(partIndex - Parts.Length),
now);
nodes.Add(partNode);
for (var componentIndex = 0; componentIndex < part.Components.Length; componentIndex++)
{
var component = part.Components[componentIndex];
var componentNode = CreateNode(
order,
partNode.Id,
$"C-{partIndex + 1:00}-{componentIndex + 1:00}",
component.Name,
2,
"component",
component.SupplyType,
order.BatchQty,
componentIndex,
component.Owner,
component.Vendor,
component.VisualKey,
componentIndex,
partNode.PlannedStart!.Value.AddDays(componentIndex),
partNode.PlannedEnd!.Value.AddDays(componentIndex - 2),
now);
nodes.Add(componentNode);
for (var processIndex = 0; processIndex < ProcessNames.Length; processIndex++)
{
var signature = orderIndex * 17 + partIndex * 11 + componentIndex * 7 + processIndex * 5;
var progress = Math.Clamp(blueprint.BaseProgress + signature % 29 - 14 - processIndex * 4, 3, 100);
var critical = signature % 13 == 0;
var warning = !critical && (signature % 5 == 0 || progress < 45);
var delayDays = critical ? 3 + signature % 4 : warning ? 1 : 0;
var status = progress >= 100
? "done"
: critical && processIndex == 2
? "blocked"
: delayDays > 0
? "delayed"
: "in_progress";
var risk = critical ? "critical" : warning ? "warning" : "normal";
var requiredQty = order.BatchQty;
var completedQty = (int)Math.Round(requiredQty * progress / 100d, MidpointRounding.AwayFromZero);
var processNode = CreateNode(
order,
componentNode.Id,
$"OP-{(processIndex + 1) * 10:00}",
$"{component.Name} - {ProcessNames[processIndex]}",
3,
"process",
component.SupplyType,
requiredQty,
processIndex,
component.Owner,
component.Vendor,
processIndex == 2 ? "package" : component.VisualKey,
processIndex,
componentNode.PlannedStart!.Value.AddDays(processIndex),
componentNode.PlannedEnd!.Value.AddDays(processIndex - 2),
now);
processNode.CompletedQty = completedQty;
processNode.DefectQty = Math.Min(completedQty, risk == "critical" ? Math.Max(1, completedQty / 50) : completedQty / 250);
processNode.Progress = progress;
processNode.Status = status;
processNode.RiskLevel = risk;
processNode.DelayDays = delayDays;
processNode.DelayReason = DelayReason(risk, component.SupplyType, ProcessNames[processIndex]);
processNode.ActualStart = progress > 0 ? processNode.PlannedStart : null;
processNode.ActualEnd = progress >= 100 ? processNode.PlannedEnd : null;
nodes.Add(processNode);
}
}
}
return nodes;
}
private static ProductionNode CreateNode(
ProductionOrder order,
Guid? parentId,
string code,
string name,
int level,
string nodeType,
string supplyType,
int requiredQty,
int codeIndex,
string owner,
string vendor,
string visualKey,
int sortOrder,
DateTime plannedStart,
DateTime plannedEnd,
DateTimeOffset now)
{
return new ProductionNode
{
Id = Guid.NewGuid(),
OrderId = order.Id,
ParentNodeId = parentId,
Code = code,
OperationCode = code,
MaterialCode = $"MAT-{order.Code[^3..]}-{level}{codeIndex + 1:00}",
Name = name,
Level = level,
NodeType = nodeType,
SupplyType = supplyType,
RequiredQty = requiredQty,
Owner = owner,
Vendor = vendor,
StationName = $"{owner}-{sortOrder + 1}",
VisualKey = visualKey,
SortOrder = sortOrder,
PlannedStart = plannedStart,
PlannedEnd = plannedEnd,
UpdatedAt = now,
RowVersion = Guid.NewGuid().ToByteArray(),
};
}
private static IEnumerable<ProductionTrendPoint> BuildTrend(
ProductionOrder order,
int orderIndex,
DateTimeOffset now)
{
for (var index = 0; index < 12; index++)
{
var completion = Math.Clamp(order.Progress - (11 - index) * 2 + (index + orderIndex) % 3, 0, 100);
var actual = (int)Math.Round(order.RequiredQty * completion / 100d);
var planned = Math.Max(actual, (int)Math.Round(order.RequiredQty * Math.Min(100, completion + 8) / 100d));
yield return new ProductionTrendPoint
{
Id = Guid.NewGuid(),
OrderId = order.Id,
SampleTime = now.AddMinutes((index - 11) * 8),
Completion = completion,
Risk = Math.Max(1, order.DelayDays + 11 - index),
PlannedQty = planned,
ActualQty = actual,
Achievement = planned == 0 ? 0 : Math.Min(120, actual * 100d / planned),
};
}
}
private static IEnumerable<RiskEvent> BuildRiskEvents(
ProductionOrder order,
IReadOnlyCollection<ProductionNode> nodes,
DateTimeOffset now)
{
return nodes
.Where(node => node.RiskLevel != "normal" || node.DelayDays > 0)
.OrderByDescending(node => node.RiskLevel == "critical")
.ThenByDescending(node => node.DelayDays)
.Take(8)
.Select((node, index) => new RiskEvent
{
Id = Guid.NewGuid(),
OrderId = order.Id,
NodeId = node.Id,
OrderCode = order.Code,
NodeName = node.Name,
RiskLevel = node.RiskLevel,
Message = string.IsNullOrWhiteSpace(node.DelayReason)
? $"计划偏差 {Math.Max(1, node.DelayDays)} 天,需要复核节拍"
: node.DelayReason,
HandlingStatus = "processing",
OccurredAt = now.AddMinutes(-index),
});
}
private static string DelayReason(string riskLevel, string supplyType, string processName)
{
if (riskLevel == "normal")
{
return string.Empty;
}
return supplyType switch
{
"outsourced" => $"{processName}外协回货节拍低于计划",
"purchased" => $"{processName}供应到料存在批次差异",
_ => $"{processName}工序良率波动,需要复检",
};
}
private sealed record OrderBlueprint(
string Code,
string ProductName,
int BatchQty,
string LineName,
string Owner,
int BaseProgress,
double DailyDelta,
string ThumbnailKey);
private sealed record PartBlueprint(
string Name,
string SupplyType,
string Owner,
string Vendor,
string VisualKey,
ComponentBlueprint[] Components);
private sealed record ComponentBlueprint(
string Name,
string SupplyType,
string Owner,
string Vendor,
string VisualKey);
}
@@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore;
namespace DongfangHydro.Dashboard.Api.Data;
public static class DatabaseInitializer
{
public static async Task InitializeAsync(WebApplication app)
{
var autoMigrate = app.Configuration.GetValue("Database:AutoMigrate", false);
var seed = app.Configuration.GetValue("Database:Seed", false);
if (!autoMigrate && !seed)
{
return;
}
await using var scope = app.Services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<DashboardDbContext>();
if (autoMigrate)
{
if (dbContext.Database.IsRelational())
{
await dbContext.Database.MigrateAsync();
}
else
{
await dbContext.Database.EnsureCreatedAsync();
}
}
if (seed)
{
await scope.ServiceProvider.GetRequiredService<DashboardSeeder>().SeedAsync(CancellationToken.None);
}
}
}
@@ -0,0 +1,457 @@
// <auto-generated />
using System;
using DongfangHydro.Dashboard.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DongfangHydro.Dashboard.Api.Data.Migrations
{
[DbContext(typeof(DashboardDbContext))]
[Migration("20260710025438_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.20")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.Partner", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("ContactName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("ContactPhone")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("PartnerType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Partners", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ProductionLines", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActualEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("ActualStart")
.HasColumnType("datetime2");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<int>("CompletedQty")
.HasColumnType("int");
b.Property<int>("DefectQty")
.HasColumnType("int");
b.Property<int>("DelayDays")
.HasColumnType("int");
b.Property<string>("DelayReason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Level")
.HasColumnType("int");
b.Property<string>("MaterialCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("NodeType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("OperationCode")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ParentNodeId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("PlannedEnd")
.HasColumnType("datetime2");
b.Property<DateTime>("PlannedStart")
.HasColumnType("datetime2");
b.Property<int>("Progress")
.HasColumnType("int");
b.Property<int>("RequiredQty")
.HasColumnType("int");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<string>("StationName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("SupplyType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Vendor")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("VisualKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.HasKey("Id");
b.HasIndex("OrderId", "ParentNodeId", "SortOrder");
b.HasIndex("OrderId", "NodeType", "RiskLevel", "Status");
b.ToTable("ProductionNodes", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("BatchQty")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<int>("CompletedQty")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<double>("DailyDelta")
.HasColumnType("float");
b.Property<int>("DelayDays")
.HasColumnType("int");
b.Property<Guid?>("LineId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LineName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<double>("PlanAchievement")
.HasColumnType("float");
b.Property<DateTime>("PlannedEnd")
.HasColumnType("datetime2");
b.Property<DateTime>("PlannedStart")
.HasColumnType("datetime2");
b.Property<string>("ProductName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Progress")
.HasColumnType("int");
b.Property<int>("RequiredQty")
.HasColumnType("int");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("ThumbnailKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("LineId");
b.HasIndex("Status", "RiskLevel", "PlannedEnd");
b.ToTable("ProductionOrders", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionTrendPoint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<double>("Achievement")
.HasColumnType("float");
b.Property<int>("ActualQty")
.HasColumnType("int");
b.Property<int>("Completion")
.HasColumnType("int");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<int>("PlannedQty")
.HasColumnType("int");
b.Property<int>("Risk")
.HasColumnType("int");
b.Property<DateTimeOffset>("SampleTime")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("OrderId", "SampleTime")
.IsDescending(false, true);
b.ToTable("ProductionTrendPoints", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.RiskEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("HandlingStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid>("NodeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("NodeName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("OrderCode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("OrderId", "NodeId");
b.HasIndex("OrderId", "OccurredAt")
.IsDescending(false, true);
b.ToTable("RiskEvents", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionNode", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionNode", null)
.WithMany()
.HasForeignKey("OrderId", "ParentNodeId")
.HasPrincipalKey("OrderId", "Id")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionLine", null)
.WithMany()
.HasForeignKey("LineId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionTrendPoint", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.RiskEvent", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionNode", null)
.WithMany()
.HasForeignKey("OrderId", "NodeId")
.HasPrincipalKey("OrderId", "Id")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,266 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DongfangHydro.Dashboard.Api.Data.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Partners",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
Name = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
PartnerType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
ContactName = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
ContactPhone = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Partners", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ProductionLines",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
Name = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Owner = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductionLines", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ProductionOrders",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Code = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
ProductName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
BatchQty = table.Column<int>(type: "int", nullable: false),
RequiredQty = table.Column<int>(type: "int", nullable: false),
CompletedQty = table.Column<int>(type: "int", nullable: false),
Progress = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
RiskLevel = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
DelayDays = table.Column<int>(type: "int", nullable: false),
PlannedStart = table.Column<DateTime>(type: "datetime2", nullable: false),
PlannedEnd = table.Column<DateTime>(type: "datetime2", nullable: false),
LineId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
LineName = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Owner = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
PlanAchievement = table.Column<double>(type: "float", nullable: false),
DailyDelta = table.Column<double>(type: "float", nullable: false),
ThumbnailKey = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductionOrders", x => x.Id);
table.ForeignKey(
name: "FK_ProductionOrders_ProductionLines_LineId",
column: x => x.LineId,
principalTable: "ProductionLines",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "ProductionNodes",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OrderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
ParentNodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: true),
Code = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
OperationCode = table.Column<string>(type: "nvarchar(60)", maxLength: 60, nullable: false),
MaterialCode = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Name = table.Column<string>(type: "nvarchar(240)", maxLength: 240, nullable: false),
Level = table.Column<int>(type: "int", nullable: false),
NodeType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
SupplyType = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
RequiredQty = table.Column<int>(type: "int", nullable: false),
CompletedQty = table.Column<int>(type: "int", nullable: false),
DefectQty = table.Column<int>(type: "int", nullable: false),
Progress = table.Column<int>(type: "int", nullable: false),
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
RiskLevel = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
DelayDays = table.Column<int>(type: "int", nullable: false),
DelayReason = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
PlannedStart = table.Column<DateTime>(type: "datetime2", nullable: false),
PlannedEnd = table.Column<DateTime>(type: "datetime2", nullable: false),
ActualStart = table.Column<DateTime>(type: "datetime2", nullable: true),
ActualEnd = table.Column<DateTime>(type: "datetime2", nullable: true),
Owner = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Vendor = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
StationName = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
VisualKey = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
RowVersion = table.Column<byte[]>(type: "rowversion", rowVersion: true, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductionNodes", x => x.Id);
table.UniqueConstraint("AK_ProductionNodes_OrderId_Id", x => new { x.OrderId, x.Id });
table.ForeignKey(
name: "FK_ProductionNodes_ProductionNodes_OrderId_ParentNodeId",
columns: x => new { x.OrderId, x.ParentNodeId },
principalTable: "ProductionNodes",
principalColumns: new[] { "OrderId", "Id" },
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_ProductionNodes_ProductionOrders_OrderId",
column: x => x.OrderId,
principalTable: "ProductionOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ProductionTrendPoints",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OrderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SampleTime = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
Completion = table.Column<int>(type: "int", nullable: false),
Risk = table.Column<int>(type: "int", nullable: false),
PlannedQty = table.Column<int>(type: "int", nullable: false),
ActualQty = table.Column<int>(type: "int", nullable: false),
Achievement = table.Column<double>(type: "float", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ProductionTrendPoints", x => x.Id);
table.ForeignKey(
name: "FK_ProductionTrendPoints_ProductionOrders_OrderId",
column: x => x.OrderId,
principalTable: "ProductionOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "RiskEvents",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OrderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
NodeId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OrderCode = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
NodeName = table.Column<string>(type: "nvarchar(240)", maxLength: 240, nullable: false),
RiskLevel = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
Message = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
HandlingStatus = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
OccurredAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RiskEvents", x => x.Id);
table.ForeignKey(
name: "FK_RiskEvents_ProductionNodes_OrderId_NodeId",
columns: x => new { x.OrderId, x.NodeId },
principalTable: "ProductionNodes",
principalColumns: new[] { "OrderId", "Id" });
table.ForeignKey(
name: "FK_RiskEvents_ProductionOrders_OrderId",
column: x => x.OrderId,
principalTable: "ProductionOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Partners_Code",
table: "Partners",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ProductionLines_Code",
table: "ProductionLines",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ProductionNodes_OrderId_NodeType_RiskLevel_Status",
table: "ProductionNodes",
columns: new[] { "OrderId", "NodeType", "RiskLevel", "Status" });
migrationBuilder.CreateIndex(
name: "IX_ProductionNodes_OrderId_ParentNodeId_SortOrder",
table: "ProductionNodes",
columns: new[] { "OrderId", "ParentNodeId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_ProductionOrders_Code",
table: "ProductionOrders",
column: "Code",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ProductionOrders_LineId",
table: "ProductionOrders",
column: "LineId");
migrationBuilder.CreateIndex(
name: "IX_ProductionOrders_Status_RiskLevel_PlannedEnd",
table: "ProductionOrders",
columns: new[] { "Status", "RiskLevel", "PlannedEnd" });
migrationBuilder.CreateIndex(
name: "IX_ProductionTrendPoints_OrderId_SampleTime",
table: "ProductionTrendPoints",
columns: new[] { "OrderId", "SampleTime" },
descending: new[] { false, true });
migrationBuilder.CreateIndex(
name: "IX_RiskEvents_OrderId_NodeId",
table: "RiskEvents",
columns: new[] { "OrderId", "NodeId" });
migrationBuilder.CreateIndex(
name: "IX_RiskEvents_OrderId_OccurredAt",
table: "RiskEvents",
columns: new[] { "OrderId", "OccurredAt" },
descending: new[] { false, true });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Partners");
migrationBuilder.DropTable(
name: "ProductionTrendPoints");
migrationBuilder.DropTable(
name: "RiskEvents");
migrationBuilder.DropTable(
name: "ProductionNodes");
migrationBuilder.DropTable(
name: "ProductionOrders");
migrationBuilder.DropTable(
name: "ProductionLines");
}
}
}
@@ -0,0 +1,652 @@
// <auto-generated />
using System;
using DongfangHydro.Dashboard.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DongfangHydro.Dashboard.Api.Data.Migrations
{
[DbContext(typeof(DashboardDbContext))]
[Migration("20260710095939_ImportRealBom")]
partial class ImportRealBom
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.20")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.MaterialQuota", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Brand")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<decimal?>("ConsumptionQuota")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("ConsumptionQuotaText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("MaterialCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("MaterialName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<decimal?>("NetWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("NetWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("QuantityText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("SourceRow")
.HasColumnType("int");
b.Property<string>("SourceSequence")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("SourceSheet")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("Specification")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("nvarchar(24)");
b.HasKey("Id");
b.HasIndex("OrderId", "Category", "MaterialCode");
b.HasIndex("OrderId", "SourceSheet", "SourceRow")
.IsUnique();
b.ToTable("MaterialQuotas", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.Partner", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("ContactName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("ContactPhone")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("PartnerType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Partners", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ProductionLines", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActualEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("ActualStart")
.HasColumnType("datetime2");
b.Property<decimal?>("BomQuantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("BomQuantityText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<int>("CompletedQty")
.HasColumnType("int");
b.Property<int>("DefectQty")
.HasColumnType("int");
b.Property<int>("DelayDays")
.HasColumnType("int");
b.Property<string>("DelayReason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Level")
.HasColumnType("int");
b.Property<string>("Material")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("MaterialCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("NodeType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("OperationCode")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ParentNodeId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("PlannedEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("PlannedStart")
.HasColumnType("datetime2");
b.Property<int>("Progress")
.HasColumnType("int");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("RequiredQty")
.HasColumnType("int");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int>("SourceRow")
.HasColumnType("int");
b.Property<string>("SourceSequence")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("SourceSheet")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("Specification")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("StationName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("SupplyType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<decimal?>("TotalWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("TotalWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<decimal?>("UnitWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("UnitWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Vendor")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("VisualKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.HasKey("Id");
b.HasIndex("OrderId", "ParentNodeId", "SortOrder");
b.HasIndex("OrderId", "NodeType", "RiskLevel", "Status");
b.ToTable("ProductionNodes", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("BatchQty")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<int>("CompletedQty")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<double>("DailyDelta")
.HasColumnType("float");
b.Property<string>("DataSource")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<int>("DelayDays")
.HasColumnType("int");
b.Property<string>("DrawingProductName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<DateTimeOffset?>("ImportedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("LineId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LineName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<double>("PlanAchievement")
.HasColumnType("float");
b.Property<DateTime?>("PlannedEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("PlannedStart")
.HasColumnType("datetime2");
b.Property<string>("ProductName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Progress")
.HasColumnType("int");
b.Property<string>("ProjectName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("RequiredQty")
.HasColumnType("int");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<string>("SourceDocumentCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("SourceFileHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("ThumbnailKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.HasIndex("LineId");
b.HasIndex("SourceDocumentCode")
.IsUnique()
.HasFilter("[SourceDocumentCode] <> ''");
b.HasIndex("Status", "RiskLevel", "PlannedEnd");
b.ToTable("ProductionOrders", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionTrendPoint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<double>("Achievement")
.HasColumnType("float");
b.Property<int>("ActualQty")
.HasColumnType("int");
b.Property<int>("Completion")
.HasColumnType("int");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<int>("PlannedQty")
.HasColumnType("int");
b.Property<int>("Risk")
.HasColumnType("int");
b.Property<DateTimeOffset>("SampleTime")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("OrderId", "SampleTime")
.IsDescending(false, true);
b.ToTable("ProductionTrendPoints", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.RiskEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("HandlingStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid>("NodeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("NodeName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("OrderCode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("OrderId", "NodeId");
b.HasIndex("OrderId", "OccurredAt")
.IsDescending(false, true);
b.ToTable("RiskEvents", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.MaterialQuota", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionNode", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionNode", null)
.WithMany()
.HasForeignKey("OrderId", "ParentNodeId")
.HasPrincipalKey("OrderId", "Id")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionLine", null)
.WithMany()
.HasForeignKey("LineId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionTrendPoint", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.RiskEvent", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionNode", null)
.WithMany()
.HasForeignKey("OrderId", "NodeId")
.HasPrincipalKey("OrderId", "Id")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,378 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DongfangHydro.Dashboard.Api.Data.Migrations
{
/// <inheritdoc />
public partial class ImportRealBom : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<DateTime>(
name: "PlannedStart",
table: "ProductionOrders",
type: "datetime2",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "datetime2");
migrationBuilder.AlterColumn<DateTime>(
name: "PlannedEnd",
table: "ProductionOrders",
type: "datetime2",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "datetime2");
migrationBuilder.AddColumn<string>(
name: "DataSource",
table: "ProductionOrders",
type: "nvarchar(32)",
maxLength: 32,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "DrawingProductName",
table: "ProductionOrders",
type: "nvarchar(240)",
maxLength: 240,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<DateTimeOffset>(
name: "ImportedAt",
table: "ProductionOrders",
type: "datetimeoffset",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ProjectName",
table: "ProductionOrders",
type: "nvarchar(200)",
maxLength: 200,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "SourceDocumentCode",
table: "ProductionOrders",
type: "nvarchar(80)",
maxLength: 80,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "SourceFileHash",
table: "ProductionOrders",
type: "nvarchar(64)",
maxLength: 64,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "SourceFileName",
table: "ProductionOrders",
type: "nvarchar(260)",
maxLength: 260,
nullable: false,
defaultValue: "");
migrationBuilder.Sql(
"UPDATE [ProductionOrders] SET [DataSource] = 'demo' WHERE [Code] LIKE 'MO-260708-%';");
migrationBuilder.AlterColumn<DateTime>(
name: "PlannedStart",
table: "ProductionNodes",
type: "datetime2",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "datetime2");
migrationBuilder.AlterColumn<DateTime>(
name: "PlannedEnd",
table: "ProductionNodes",
type: "datetime2",
nullable: true,
oldClrType: typeof(DateTime),
oldType: "datetime2");
migrationBuilder.AddColumn<decimal>(
name: "BomQuantity",
table: "ProductionNodes",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "BomQuantityText",
table: "ProductionNodes",
type: "nvarchar(80)",
maxLength: 80,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Material",
table: "ProductionNodes",
type: "nvarchar(120)",
maxLength: 120,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Remark",
table: "ProductionNodes",
type: "nvarchar(500)",
maxLength: 500,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<int>(
name: "SourceRow",
table: "ProductionNodes",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<string>(
name: "SourceSequence",
table: "ProductionNodes",
type: "nvarchar(40)",
maxLength: 40,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "SourceSheet",
table: "ProductionNodes",
type: "nvarchar(40)",
maxLength: 40,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "Specification",
table: "ProductionNodes",
type: "nvarchar(240)",
maxLength: 240,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<decimal>(
name: "TotalWeight",
table: "ProductionNodes",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "TotalWeightText",
table: "ProductionNodes",
type: "nvarchar(80)",
maxLength: 80,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<decimal>(
name: "UnitWeight",
table: "ProductionNodes",
type: "decimal(18,4)",
precision: 18,
scale: 4,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "UnitWeightText",
table: "ProductionNodes",
type: "nvarchar(80)",
maxLength: 80,
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "MaterialQuotas",
columns: table => new
{
Id = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
OrderId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
Category = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
SourceSequence = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
MaterialCode = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
MaterialName = table.Column<string>(type: "nvarchar(240)", maxLength: 240, nullable: false),
Specification = table.Column<string>(type: "nvarchar(240)", maxLength: 240, nullable: false),
Unit = table.Column<string>(type: "nvarchar(24)", maxLength: 24, nullable: false),
Quantity = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
NetWeight = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
ConsumptionQuota = table.Column<decimal>(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true),
QuantityText = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
NetWeightText = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
ConsumptionQuotaText = table.Column<string>(type: "nvarchar(80)", maxLength: 80, nullable: false),
Brand = table.Column<string>(type: "nvarchar(120)", maxLength: 120, nullable: false),
Remark = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
SourceSheet = table.Column<string>(type: "nvarchar(40)", maxLength: 40, nullable: false),
SourceRow = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_MaterialQuotas", x => x.Id);
table.ForeignKey(
name: "FK_MaterialQuotas_ProductionOrders_OrderId",
column: x => x.OrderId,
principalTable: "ProductionOrders",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ProductionOrders_SourceDocumentCode",
table: "ProductionOrders",
column: "SourceDocumentCode",
unique: true,
filter: "[SourceDocumentCode] <> ''");
migrationBuilder.CreateIndex(
name: "IX_MaterialQuotas_OrderId_Category_MaterialCode",
table: "MaterialQuotas",
columns: new[] { "OrderId", "Category", "MaterialCode" });
migrationBuilder.CreateIndex(
name: "IX_MaterialQuotas_OrderId_SourceSheet_SourceRow",
table: "MaterialQuotas",
columns: new[] { "OrderId", "SourceSheet", "SourceRow" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MaterialQuotas");
migrationBuilder.DropIndex(
name: "IX_ProductionOrders_SourceDocumentCode",
table: "ProductionOrders");
migrationBuilder.DropColumn(
name: "DataSource",
table: "ProductionOrders");
migrationBuilder.DropColumn(
name: "DrawingProductName",
table: "ProductionOrders");
migrationBuilder.DropColumn(
name: "ImportedAt",
table: "ProductionOrders");
migrationBuilder.DropColumn(
name: "ProjectName",
table: "ProductionOrders");
migrationBuilder.DropColumn(
name: "SourceDocumentCode",
table: "ProductionOrders");
migrationBuilder.DropColumn(
name: "SourceFileHash",
table: "ProductionOrders");
migrationBuilder.DropColumn(
name: "SourceFileName",
table: "ProductionOrders");
migrationBuilder.DropColumn(
name: "BomQuantity",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "BomQuantityText",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "Material",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "Remark",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "SourceRow",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "SourceSequence",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "SourceSheet",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "Specification",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "TotalWeight",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "TotalWeightText",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "UnitWeight",
table: "ProductionNodes");
migrationBuilder.DropColumn(
name: "UnitWeightText",
table: "ProductionNodes");
migrationBuilder.AlterColumn<DateTime>(
name: "PlannedStart",
table: "ProductionOrders",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
oldClrType: typeof(DateTime),
oldType: "datetime2",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "PlannedEnd",
table: "ProductionOrders",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
oldClrType: typeof(DateTime),
oldType: "datetime2",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "PlannedStart",
table: "ProductionNodes",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
oldClrType: typeof(DateTime),
oldType: "datetime2",
oldNullable: true);
migrationBuilder.AlterColumn<DateTime>(
name: "PlannedEnd",
table: "ProductionNodes",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified),
oldClrType: typeof(DateTime),
oldType: "datetime2",
oldNullable: true);
}
}
}
@@ -0,0 +1,651 @@
// <auto-generated />
using System;
using DongfangHydro.Dashboard.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DongfangHydro.Dashboard.Api.Data.Migrations
{
[DbContext(typeof(DashboardDbContext))]
[Migration("20260710103033_AllowRepeatedWorkOrderCodes")]
partial class AllowRepeatedWorkOrderCodes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.20")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.MaterialQuota", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Brand")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<decimal?>("ConsumptionQuota")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("ConsumptionQuotaText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("MaterialCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("MaterialName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<decimal?>("NetWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("NetWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("QuantityText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("SourceRow")
.HasColumnType("int");
b.Property<string>("SourceSequence")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("SourceSheet")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("Specification")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("nvarchar(24)");
b.HasKey("Id");
b.HasIndex("OrderId", "Category", "MaterialCode");
b.HasIndex("OrderId", "SourceSheet", "SourceRow")
.IsUnique();
b.ToTable("MaterialQuotas", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.Partner", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("ContactName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("ContactPhone")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("PartnerType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Partners", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ProductionLines", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActualEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("ActualStart")
.HasColumnType("datetime2");
b.Property<decimal?>("BomQuantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("BomQuantityText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<int>("CompletedQty")
.HasColumnType("int");
b.Property<int>("DefectQty")
.HasColumnType("int");
b.Property<int>("DelayDays")
.HasColumnType("int");
b.Property<string>("DelayReason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Level")
.HasColumnType("int");
b.Property<string>("Material")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("MaterialCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("NodeType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("OperationCode")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ParentNodeId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("PlannedEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("PlannedStart")
.HasColumnType("datetime2");
b.Property<int>("Progress")
.HasColumnType("int");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("RequiredQty")
.HasColumnType("int");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int>("SourceRow")
.HasColumnType("int");
b.Property<string>("SourceSequence")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("SourceSheet")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("Specification")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("StationName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("SupplyType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<decimal?>("TotalWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("TotalWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<decimal?>("UnitWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("UnitWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Vendor")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("VisualKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.HasKey("Id");
b.HasIndex("OrderId", "ParentNodeId", "SortOrder");
b.HasIndex("OrderId", "NodeType", "RiskLevel", "Status");
b.ToTable("ProductionNodes", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("BatchQty")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<int>("CompletedQty")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<double>("DailyDelta")
.HasColumnType("float");
b.Property<string>("DataSource")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<int>("DelayDays")
.HasColumnType("int");
b.Property<string>("DrawingProductName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<DateTimeOffset?>("ImportedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("LineId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LineName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<double>("PlanAchievement")
.HasColumnType("float");
b.Property<DateTime?>("PlannedEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("PlannedStart")
.HasColumnType("datetime2");
b.Property<string>("ProductName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Progress")
.HasColumnType("int");
b.Property<string>("ProjectName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("RequiredQty")
.HasColumnType("int");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<string>("SourceDocumentCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("SourceFileHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("ThumbnailKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("LineId");
b.HasIndex("SourceDocumentCode")
.IsUnique()
.HasFilter("[SourceDocumentCode] <> ''");
b.HasIndex("Status", "RiskLevel", "PlannedEnd");
b.ToTable("ProductionOrders", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionTrendPoint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<double>("Achievement")
.HasColumnType("float");
b.Property<int>("ActualQty")
.HasColumnType("int");
b.Property<int>("Completion")
.HasColumnType("int");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<int>("PlannedQty")
.HasColumnType("int");
b.Property<int>("Risk")
.HasColumnType("int");
b.Property<DateTimeOffset>("SampleTime")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("OrderId", "SampleTime")
.IsDescending(false, true);
b.ToTable("ProductionTrendPoints", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.RiskEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("HandlingStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid>("NodeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("NodeName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("OrderCode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("OrderId", "NodeId");
b.HasIndex("OrderId", "OccurredAt")
.IsDescending(false, true);
b.ToTable("RiskEvents", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.MaterialQuota", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionNode", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionNode", null)
.WithMany()
.HasForeignKey("OrderId", "ParentNodeId")
.HasPrincipalKey("OrderId", "Id")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionLine", null)
.WithMany()
.HasForeignKey("LineId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionTrendPoint", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.RiskEvent", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionNode", null)
.WithMany()
.HasForeignKey("OrderId", "NodeId")
.HasPrincipalKey("OrderId", "Id")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DongfangHydro.Dashboard.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AllowRepeatedWorkOrderCodes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ProductionOrders_Code",
table: "ProductionOrders");
migrationBuilder.CreateIndex(
name: "IX_ProductionOrders_Code",
table: "ProductionOrders",
column: "Code");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_ProductionOrders_Code",
table: "ProductionOrders");
migrationBuilder.CreateIndex(
name: "IX_ProductionOrders_Code",
table: "ProductionOrders",
column: "Code",
unique: true);
}
}
}
@@ -0,0 +1,648 @@
// <auto-generated />
using System;
using DongfangHydro.Dashboard.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DongfangHydro.Dashboard.Api.Data.Migrations
{
[DbContext(typeof(DashboardDbContext))]
partial class DashboardDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.20")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.MaterialQuota", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Brand")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("Category")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<decimal?>("ConsumptionQuota")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("ConsumptionQuotaText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("MaterialCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("MaterialName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<decimal?>("NetWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("NetWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<decimal?>("Quantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("QuantityText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("SourceRow")
.HasColumnType("int");
b.Property<string>("SourceSequence")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("SourceSheet")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("Specification")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("Unit")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("nvarchar(24)");
b.HasKey("Id");
b.HasIndex("OrderId", "Category", "MaterialCode");
b.HasIndex("OrderId", "SourceSheet", "SourceRow")
.IsUnique();
b.ToTable("MaterialQuotas", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.Partner", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("ContactName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("ContactPhone")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("PartnerType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("Partners", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionLine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("ProductionLines", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("ActualEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("ActualStart")
.HasColumnType("datetime2");
b.Property<decimal?>("BomQuantity")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("BomQuantityText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<int>("CompletedQty")
.HasColumnType("int");
b.Property<int>("DefectQty")
.HasColumnType("int");
b.Property<int>("DelayDays")
.HasColumnType("int");
b.Property<string>("DelayReason")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("Level")
.HasColumnType("int");
b.Property<string>("Material")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("MaterialCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("NodeType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("OperationCode")
.IsRequired()
.HasMaxLength(60)
.HasColumnType("nvarchar(60)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<Guid?>("ParentNodeId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime?>("PlannedEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("PlannedStart")
.HasColumnType("datetime2");
b.Property<int>("Progress")
.HasColumnType("int");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<int>("RequiredQty")
.HasColumnType("int");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<int>("SortOrder")
.HasColumnType("int");
b.Property<int>("SourceRow")
.HasColumnType("int");
b.Property<string>("SourceSequence")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("SourceSheet")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<string>("Specification")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<string>("StationName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("SupplyType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<decimal?>("TotalWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("TotalWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<decimal?>("UnitWeight")
.HasPrecision(18, 4)
.HasColumnType("decimal(18,4)");
b.Property<string>("UnitWeightText")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("Vendor")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("nvarchar(120)");
b.Property<string>("VisualKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.HasKey("Id");
b.HasIndex("OrderId", "ParentNodeId", "SortOrder");
b.HasIndex("OrderId", "NodeType", "RiskLevel", "Status");
b.ToTable("ProductionNodes", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("BatchQty")
.HasColumnType("int");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<int>("CompletedQty")
.HasColumnType("int");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<double>("DailyDelta")
.HasColumnType("float");
b.Property<string>("DataSource")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<int>("DelayDays")
.HasColumnType("int");
b.Property<string>("DrawingProductName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<DateTimeOffset?>("ImportedAt")
.HasColumnType("datetimeoffset");
b.Property<Guid?>("LineId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LineName")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("Owner")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<double>("PlanAchievement")
.HasColumnType("float");
b.Property<DateTime?>("PlannedEnd")
.HasColumnType("datetime2");
b.Property<DateTime?>("PlannedStart")
.HasColumnType("datetime2");
b.Property<string>("ProductName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("Progress")
.HasColumnType("int");
b.Property<string>("ProjectName")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<int>("RequiredQty")
.HasColumnType("int");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<byte[]>("RowVersion")
.IsConcurrencyToken()
.IsRequired()
.ValueGeneratedOnAddOrUpdate()
.HasColumnType("rowversion");
b.Property<string>("SourceDocumentCode")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("nvarchar(80)");
b.Property<string>("SourceFileHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<string>("SourceFileName")
.IsRequired()
.HasMaxLength(260)
.HasColumnType("nvarchar(260)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("ThumbnailKey")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("Code");
b.HasIndex("LineId");
b.HasIndex("SourceDocumentCode")
.IsUnique()
.HasFilter("[SourceDocumentCode] <> ''");
b.HasIndex("Status", "RiskLevel", "PlannedEnd");
b.ToTable("ProductionOrders", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionTrendPoint", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<double>("Achievement")
.HasColumnType("float");
b.Property<int>("ActualQty")
.HasColumnType("int");
b.Property<int>("Completion")
.HasColumnType("int");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<int>("PlannedQty")
.HasColumnType("int");
b.Property<int>("Risk")
.HasColumnType("int");
b.Property<DateTimeOffset>("SampleTime")
.HasColumnType("datetimeoffset");
b.HasKey("Id");
b.HasIndex("OrderId", "SampleTime")
.IsDescending(false, true);
b.ToTable("ProductionTrendPoints", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.RiskEvent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("HandlingStatus")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.Property<string>("Message")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<Guid>("NodeId")
.HasColumnType("uniqueidentifier");
b.Property<string>("NodeName")
.IsRequired()
.HasMaxLength(240)
.HasColumnType("nvarchar(240)");
b.Property<DateTimeOffset>("OccurredAt")
.HasColumnType("datetimeoffset");
b.Property<string>("OrderCode")
.IsRequired()
.HasMaxLength(40)
.HasColumnType("nvarchar(40)");
b.Property<Guid>("OrderId")
.HasColumnType("uniqueidentifier");
b.Property<string>("RiskLevel")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("nvarchar(32)");
b.HasKey("Id");
b.HasIndex("OrderId", "NodeId");
b.HasIndex("OrderId", "OccurredAt")
.IsDescending(false, true);
b.ToTable("RiskEvents", (string)null);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.MaterialQuota", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionNode", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionNode", null)
.WithMany()
.HasForeignKey("OrderId", "ParentNodeId")
.HasPrincipalKey("OrderId", "Id")
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionLine", null)
.WithMany()
.HasForeignKey("LineId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.ProductionTrendPoint", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DongfangHydro.Dashboard.Api.Domain.RiskEvent", b =>
{
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionOrder", null)
.WithMany()
.HasForeignKey("OrderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DongfangHydro.Dashboard.Api.Domain.ProductionNode", null)
.WithMany()
.HasForeignKey("OrderId", "NodeId")
.HasPrincipalKey("OrderId", "Id")
.OnDelete(DeleteBehavior.NoAction)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,266 @@
IF OBJECT_ID(N'[__EFMigrationsHistory]') IS NULL
BEGIN
CREATE TABLE [__EFMigrationsHistory] (
[MigrationId] nvarchar(150) NOT NULL,
[ProductVersion] nvarchar(32) NOT NULL,
CONSTRAINT [PK___EFMigrationsHistory] PRIMARY KEY ([MigrationId])
);
END;
GO
BEGIN TRANSACTION;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE TABLE [Partners] (
[Id] uniqueidentifier NOT NULL,
[Code] nvarchar(40) NOT NULL,
[Name] nvarchar(120) NOT NULL,
[PartnerType] nvarchar(32) NOT NULL,
[ContactName] nvarchar(80) NOT NULL,
[ContactPhone] nvarchar(32) NOT NULL,
CONSTRAINT [PK_Partners] PRIMARY KEY ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE TABLE [ProductionLines] (
[Id] uniqueidentifier NOT NULL,
[Code] nvarchar(40) NOT NULL,
[Name] nvarchar(80) NOT NULL,
[Owner] nvarchar(80) NOT NULL,
[IsActive] bit NOT NULL,
CONSTRAINT [PK_ProductionLines] PRIMARY KEY ([Id])
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE TABLE [ProductionOrders] (
[Id] uniqueidentifier NOT NULL,
[Code] nvarchar(40) NOT NULL,
[ProductName] nvarchar(200) NOT NULL,
[BatchQty] int NOT NULL,
[RequiredQty] int NOT NULL,
[CompletedQty] int NOT NULL,
[Progress] int NOT NULL,
[Status] nvarchar(32) NOT NULL,
[RiskLevel] nvarchar(32) NOT NULL,
[DelayDays] int NOT NULL,
[PlannedStart] datetime2 NOT NULL,
[PlannedEnd] datetime2 NOT NULL,
[LineId] uniqueidentifier NULL,
[LineName] nvarchar(80) NOT NULL,
[Owner] nvarchar(80) NOT NULL,
[PlanAchievement] float NOT NULL,
[DailyDelta] float NOT NULL,
[ThumbnailKey] nvarchar(40) NOT NULL,
[CreatedAt] datetimeoffset NOT NULL,
[UpdatedAt] datetimeoffset NOT NULL,
[RowVersion] rowversion NOT NULL,
CONSTRAINT [PK_ProductionOrders] PRIMARY KEY ([Id]),
CONSTRAINT [FK_ProductionOrders_ProductionLines_LineId] FOREIGN KEY ([LineId]) REFERENCES [ProductionLines] ([Id]) ON DELETE SET NULL
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE TABLE [ProductionNodes] (
[Id] uniqueidentifier NOT NULL,
[OrderId] uniqueidentifier NOT NULL,
[ParentNodeId] uniqueidentifier NULL,
[Code] nvarchar(60) NOT NULL,
[OperationCode] nvarchar(60) NOT NULL,
[MaterialCode] nvarchar(80) NOT NULL,
[Name] nvarchar(240) NOT NULL,
[Level] int NOT NULL,
[NodeType] nvarchar(32) NOT NULL,
[SupplyType] nvarchar(32) NOT NULL,
[RequiredQty] int NOT NULL,
[CompletedQty] int NOT NULL,
[DefectQty] int NOT NULL,
[Progress] int NOT NULL,
[Status] nvarchar(32) NOT NULL,
[RiskLevel] nvarchar(32) NOT NULL,
[DelayDays] int NOT NULL,
[DelayReason] nvarchar(500) NOT NULL,
[PlannedStart] datetime2 NOT NULL,
[PlannedEnd] datetime2 NOT NULL,
[ActualStart] datetime2 NULL,
[ActualEnd] datetime2 NULL,
[Owner] nvarchar(100) NOT NULL,
[Vendor] nvarchar(120) NOT NULL,
[StationName] nvarchar(120) NOT NULL,
[VisualKey] nvarchar(40) NOT NULL,
[SortOrder] int NOT NULL,
[UpdatedAt] datetimeoffset NOT NULL,
[RowVersion] rowversion NOT NULL,
CONSTRAINT [PK_ProductionNodes] PRIMARY KEY ([Id]),
CONSTRAINT [AK_ProductionNodes_OrderId_Id] UNIQUE ([OrderId], [Id]),
CONSTRAINT [FK_ProductionNodes_ProductionNodes_OrderId_ParentNodeId] FOREIGN KEY ([OrderId], [ParentNodeId]) REFERENCES [ProductionNodes] ([OrderId], [Id]) ON DELETE NO ACTION,
CONSTRAINT [FK_ProductionNodes_ProductionOrders_OrderId] FOREIGN KEY ([OrderId]) REFERENCES [ProductionOrders] ([Id]) ON DELETE CASCADE
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE TABLE [ProductionTrendPoints] (
[Id] uniqueidentifier NOT NULL,
[OrderId] uniqueidentifier NOT NULL,
[SampleTime] datetimeoffset NOT NULL,
[Completion] int NOT NULL,
[Risk] int NOT NULL,
[PlannedQty] int NOT NULL,
[ActualQty] int NOT NULL,
[Achievement] float NOT NULL,
CONSTRAINT [PK_ProductionTrendPoints] PRIMARY KEY ([Id]),
CONSTRAINT [FK_ProductionTrendPoints_ProductionOrders_OrderId] FOREIGN KEY ([OrderId]) REFERENCES [ProductionOrders] ([Id]) ON DELETE CASCADE
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE TABLE [RiskEvents] (
[Id] uniqueidentifier NOT NULL,
[OrderId] uniqueidentifier NOT NULL,
[NodeId] uniqueidentifier NOT NULL,
[OrderCode] nvarchar(40) NOT NULL,
[NodeName] nvarchar(240) NOT NULL,
[RiskLevel] nvarchar(32) NOT NULL,
[Message] nvarchar(500) NOT NULL,
[HandlingStatus] nvarchar(32) NOT NULL,
[OccurredAt] datetimeoffset NOT NULL,
CONSTRAINT [PK_RiskEvents] PRIMARY KEY ([Id]),
CONSTRAINT [FK_RiskEvents_ProductionNodes_OrderId_NodeId] FOREIGN KEY ([OrderId], [NodeId]) REFERENCES [ProductionNodes] ([OrderId], [Id]),
CONSTRAINT [FK_RiskEvents_ProductionOrders_OrderId] FOREIGN KEY ([OrderId]) REFERENCES [ProductionOrders] ([Id]) ON DELETE CASCADE
);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE UNIQUE INDEX [IX_Partners_Code] ON [Partners] ([Code]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE UNIQUE INDEX [IX_ProductionLines_Code] ON [ProductionLines] ([Code]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE INDEX [IX_ProductionNodes_OrderId_NodeType_RiskLevel_Status] ON [ProductionNodes] ([OrderId], [NodeType], [RiskLevel], [Status]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE INDEX [IX_ProductionNodes_OrderId_ParentNodeId_SortOrder] ON [ProductionNodes] ([OrderId], [ParentNodeId], [SortOrder]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE UNIQUE INDEX [IX_ProductionOrders_Code] ON [ProductionOrders] ([Code]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE INDEX [IX_ProductionOrders_LineId] ON [ProductionOrders] ([LineId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE INDEX [IX_ProductionOrders_Status_RiskLevel_PlannedEnd] ON [ProductionOrders] ([Status], [RiskLevel], [PlannedEnd]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE INDEX [IX_ProductionTrendPoints_OrderId_SampleTime] ON [ProductionTrendPoints] ([OrderId], [SampleTime] DESC);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE INDEX [IX_RiskEvents_OrderId_NodeId] ON [RiskEvents] ([OrderId], [NodeId]);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
CREATE INDEX [IX_RiskEvents_OrderId_OccurredAt] ON [RiskEvents] ([OrderId], [OccurredAt] DESC);
END;
GO
IF NOT EXISTS (
SELECT * FROM [__EFMigrationsHistory]
WHERE [MigrationId] = N'20260710025438_InitialCreate'
)
BEGIN
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
VALUES (N'20260710025438_InitialCreate', N'8.0.20');
END;
GO
COMMIT;
GO
@@ -0,0 +1,23 @@
namespace DongfangHydro.Dashboard.Api.Domain;
public sealed class MaterialQuota
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public string Category { get; set; } = string.Empty;
public string SourceSequence { get; set; } = string.Empty;
public string MaterialCode { get; set; } = string.Empty;
public string MaterialName { get; set; } = string.Empty;
public string Specification { get; set; } = string.Empty;
public string Unit { get; set; } = string.Empty;
public decimal? Quantity { get; set; }
public decimal? NetWeight { get; set; }
public decimal? ConsumptionQuota { get; set; }
public string QuantityText { get; set; } = string.Empty;
public string NetWeightText { get; set; } = string.Empty;
public string ConsumptionQuotaText { get; set; } = string.Empty;
public string Brand { get; set; } = string.Empty;
public string Remark { get; set; } = string.Empty;
public string SourceSheet { get; set; } = "材料定额";
public int SourceRow { get; set; }
}
@@ -0,0 +1,11 @@
namespace DongfangHydro.Dashboard.Api.Domain;
public sealed class Partner
{
public Guid Id { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string PartnerType { get; set; } = "supplier";
public string ContactName { get; set; } = string.Empty;
public string ContactPhone { get; set; } = string.Empty;
}
@@ -0,0 +1,10 @@
namespace DongfangHydro.Dashboard.Api.Domain;
public sealed class ProductionLine
{
public Guid Id { get; set; }
public string Code { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Owner { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
}
@@ -0,0 +1,46 @@
namespace DongfangHydro.Dashboard.Api.Domain;
public sealed class ProductionNode
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public Guid? ParentNodeId { get; set; }
public string Code { get; set; } = string.Empty;
public string OperationCode { get; set; } = string.Empty;
public string MaterialCode { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string SourceSequence { get; set; } = string.Empty;
public string Specification { get; set; } = string.Empty;
public string Material { get; set; } = string.Empty;
public decimal? UnitWeight { get; set; }
public decimal? TotalWeight { get; set; }
public string UnitWeightText { get; set; } = string.Empty;
public string TotalWeightText { get; set; } = string.Empty;
public decimal? BomQuantity { get; set; }
public string BomQuantityText { get; set; } = string.Empty;
public string Remark { get; set; } = string.Empty;
public string SourceSheet { get; set; } = string.Empty;
public int SourceRow { get; set; }
public int Level { get; set; }
public string NodeType { get; set; } = "process";
public string SupplyType { get; set; } = "self_made";
public int RequiredQty { get; set; }
public int CompletedQty { get; set; }
public int DefectQty { get; set; }
public int Progress { get; set; }
public string Status { get; set; } = "waiting";
public string RiskLevel { get; set; } = "normal";
public int DelayDays { get; set; }
public string DelayReason { get; set; } = string.Empty;
public DateTime? PlannedStart { get; set; }
public DateTime? PlannedEnd { get; set; }
public DateTime? ActualStart { get; set; }
public DateTime? ActualEnd { get; set; }
public string Owner { get; set; } = string.Empty;
public string Vendor { get; set; } = string.Empty;
public string StationName { get; set; } = string.Empty;
public string VisualKey { get; set; } = "generic";
public int SortOrder { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public byte[] RowVersion { get; set; } = [];
}
@@ -0,0 +1,33 @@
namespace DongfangHydro.Dashboard.Api.Domain;
public sealed class ProductionOrder
{
public Guid Id { get; set; }
public string Code { get; set; } = string.Empty;
public string ProductName { get; set; } = string.Empty;
public string DataSource { get; set; } = "manual";
public string SourceDocumentCode { get; set; } = string.Empty;
public string ProjectName { get; set; } = string.Empty;
public string DrawingProductName { get; set; } = string.Empty;
public string SourceFileName { get; set; } = string.Empty;
public string SourceFileHash { get; set; } = string.Empty;
public DateTimeOffset? ImportedAt { get; set; }
public int BatchQty { get; set; }
public int RequiredQty { get; set; }
public int CompletedQty { get; set; }
public int Progress { get; set; }
public string Status { get; set; } = "waiting";
public string RiskLevel { get; set; } = "normal";
public int DelayDays { get; set; }
public DateTime? PlannedStart { get; set; }
public DateTime? PlannedEnd { get; set; }
public Guid? LineId { get; set; }
public string LineName { get; set; } = string.Empty;
public string Owner { get; set; } = string.Empty;
public double PlanAchievement { get; set; }
public double DailyDelta { get; set; }
public string ThumbnailKey { get; set; } = "generic";
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public byte[] RowVersion { get; set; } = [];
}
@@ -0,0 +1,13 @@
namespace DongfangHydro.Dashboard.Api.Domain;
public sealed class ProductionTrendPoint
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public DateTimeOffset SampleTime { get; set; }
public int Completion { get; set; }
public int Risk { get; set; }
public int PlannedQty { get; set; }
public int ActualQty { get; set; }
public double Achievement { get; set; }
}
@@ -0,0 +1,14 @@
namespace DongfangHydro.Dashboard.Api.Domain;
public sealed class RiskEvent
{
public Guid Id { get; set; }
public Guid OrderId { get; set; }
public Guid NodeId { get; set; }
public string OrderCode { get; set; } = string.Empty;
public string NodeName { get; set; } = string.Empty;
public string RiskLevel { get; set; } = "normal";
public string Message { get; set; } = string.Empty;
public string HandlingStatus { get; set; } = "processing";
public DateTimeOffset OccurredAt { get; set; }
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>DongfangHydro.Dashboard.Api</RootNamespace>
<UserSecretsId>DongfangHydro.Dashboard.Api</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.20">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.20" />
</ItemGroup>
</Project>
@@ -0,0 +1,117 @@
using DongfangHydro.Dashboard.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
namespace DongfangHydro.Dashboard.Api.Importing;
public sealed record BomImportCommand(string FilePath, bool PurgeDemo, bool ValidateOnly, bool Force)
{
public static BomImportCommand? Parse(string[] args)
{
if (args.Length == 0 || !string.Equals(args[0], "import-bom", StringComparison.OrdinalIgnoreCase))
{
return null;
}
string? filePath = null;
var purgeDemo = false;
var validateOnly = false;
var force = false;
for (var index = 1; index < args.Length; index++)
{
switch (args[index])
{
case "--file":
if (index + 1 >= args.Length || args[index + 1].StartsWith("--", StringComparison.Ordinal))
{
throw new BomImportException("import-bom 的 --file 参数必须提供 Excel 路径。");
}
filePath = args[++index];
break;
case "--purge-demo":
purgeDemo = true;
break;
case "--validate-only":
validateOnly = true;
break;
case "--force":
force = true;
break;
default:
throw new BomImportException($"不支持的 import-bom 参数:{args[index]}。");
}
}
if (string.IsNullOrWhiteSpace(filePath))
{
throw new BomImportException("import-bom 必须提供 --file <xlsx路径>。");
}
return new BomImportCommand(filePath, purgeDemo, validateOnly, force);
}
}
public static class BomImportCommandRunner
{
public static async Task<object> RunAsync(
IServiceProvider services,
BomImportCommand command,
CancellationToken cancellationToken)
{
var fullPath = Path.GetFullPath(command.FilePath);
if (!File.Exists(fullPath))
{
throw new BomImportException($"Excel 文件不存在:{fullPath}");
}
BomImportDocument document;
await using (var stream = File.OpenRead(fullPath))
{
document = BomWorkbookParser.Parse(stream, Path.GetFileName(fullPath));
}
var levelCounts = document.BomNodes
.GroupBy(node => node.Level)
.OrderBy(group => group.Key)
.ToDictionary(group => group.Key, group => group.Count());
if (command.ValidateOnly)
{
return new BomValidationReport(
fullPath,
document.Metadata.SourceDocumentCode,
document.Metadata.WorkOrderCode,
document.Metadata.SourceFileHash,
document.BlockCount,
document.BomNodes.Count,
document.MaterialQuotas.Count,
levelCounts,
document.Warnings);
}
await using var scope = services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<DashboardDbContext>();
if (dbContext.Database.IsRelational())
{
await dbContext.Database.MigrateAsync(cancellationToken);
}
else
{
await dbContext.Database.EnsureCreatedAsync(cancellationToken);
}
return await scope.ServiceProvider.GetRequiredService<BomImportService>()
.ImportAsync(document, command.PurgeDemo, cancellationToken, command.Force);
}
}
public sealed record BomValidationReport(
string FilePath,
string SourceDocumentCode,
string WorkOrderCode,
string SourceFileHash,
int Blocks,
int BomNodes,
int MaterialQuotas,
IReadOnlyDictionary<int, int> LevelCounts,
IReadOnlyList<string> Warnings);
@@ -0,0 +1,57 @@
namespace DongfangHydro.Dashboard.Api.Importing;
public sealed record BomImportMetadata(
string SourceDocumentCode,
string WorkOrderCode,
string ProjectName,
string ContractProductName,
string DrawingProductName,
int BatchQuantity,
string SourceFileName,
string SourceFileHash);
public sealed record BomImportNode(
int SourceRow,
int? ParentSourceRow,
string SourceSequence,
string DrawingNumber,
string Name,
string Specification,
decimal? Quantity,
string Material,
decimal? UnitWeight,
decimal? TotalWeight,
string Remark,
int Level,
string NodeType,
string SupplyType,
string UnitWeightText = "",
string TotalWeightText = "",
string QuantityText = "");
public sealed record MaterialQuotaImportRow(
int SourceRow,
string Category,
string SourceSequence,
string MaterialCode,
string MaterialName,
string Specification,
string Unit,
decimal? Quantity,
decimal? NetWeight,
decimal? ConsumptionQuota,
string Brand,
string Remark,
string QuantityText = "",
string NetWeightText = "",
string ConsumptionQuotaText = "");
public sealed record BomImportDocument(
BomImportMetadata Metadata,
IReadOnlyList<BomImportNode> BomNodes,
IReadOnlyList<MaterialQuotaImportRow> MaterialQuotas,
int BlockCount,
IReadOnlyList<string> Warnings);
public sealed class BomImportException(string message, Exception? innerException = null)
: Exception(message, innerException);
@@ -0,0 +1,358 @@
using System.Security.Cryptography;
using System.Text;
using DongfangHydro.Dashboard.Api.Data;
using DongfangHydro.Dashboard.Api.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
namespace DongfangHydro.Dashboard.Api.Importing;
public sealed class BomImportService(DashboardDbContext dbContext)
{
public async Task<BomImportReport> ImportAsync(
BomImportDocument document,
bool purgeDemo,
CancellationToken cancellationToken,
bool forceReplace = false)
{
IDbContextTransaction? transaction = null;
if (dbContext.Database.IsRelational())
{
transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
}
try
{
var ordersToRemove = await dbContext.ProductionOrders
.Where(order => order.SourceDocumentCode == document.Metadata.SourceDocumentCode
|| (purgeDemo && order.DataSource == "demo"))
.ToListAsync(cancellationToken);
var unchangedOrder = forceReplace ? null : ordersToRemove.SingleOrDefault(order =>
order.SourceDocumentCode == document.Metadata.SourceDocumentCode
&& order.SourceFileHash == document.Metadata.SourceFileHash);
var removedDemoOrders = ordersToRemove.Count(order => order.DataSource == "demo");
var replacedOrders = unchangedOrder is null ? ordersToRemove.Count(order =>
order.SourceDocumentCode == document.Metadata.SourceDocumentCode
&& order.DataSource != "demo") : 0;
var removableOrders = unchangedOrder is null
? ordersToRemove
: ordersToRemove.Where(order => order.DataSource == "demo").ToList();
if (removableOrders.Count > 0)
{
await RemoveOrdersAsync(removableOrders, cancellationToken);
}
if (unchangedOrder is not null)
{
var totalOrders = await dbContext.ProductionOrders.CountAsync(cancellationToken);
var productionNodes = await dbContext.ProductionNodes
.CountAsync(node => node.OrderId == unchangedOrder.Id, cancellationToken);
var materialQuotas = await dbContext.MaterialQuotas
.CountAsync(item => item.OrderId == unchangedOrder.Id, cancellationToken);
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return BuildReport(
unchangedOrder,
document,
removedDemoOrders,
replacedOrders,
totalOrders,
productionNodes,
materialQuotas,
skippedUnchanged: true);
}
var importedAt = DateTimeOffset.UtcNow;
var order = BuildOrder(document.Metadata, importedAt);
var nodes = BuildNodes(order, document, importedAt);
var quotas = BuildMaterialQuotas(order.Id, document);
dbContext.ProductionOrders.Add(order);
dbContext.ProductionNodes.AddRange(nodes);
dbContext.MaterialQuotas.AddRange(quotas);
await dbContext.SaveChangesAsync(cancellationToken);
var importedTotalOrders = await dbContext.ProductionOrders.CountAsync(cancellationToken);
if (transaction is not null)
{
await transaction.CommitAsync(cancellationToken);
}
return BuildReport(
order,
document,
removedDemoOrders,
replacedOrders,
importedTotalOrders,
nodes.Count,
quotas.Count,
skippedUnchanged: false);
}
catch
{
if (transaction is not null)
{
try
{
await transaction.RollbackAsync(CancellationToken.None);
}
catch
{
// Preserve the original import failure.
}
}
throw;
}
finally
{
if (transaction is not null)
{
await transaction.DisposeAsync();
}
}
}
private static BomImportReport BuildReport(
ProductionOrder order,
BomImportDocument document,
int removedDemoOrders,
int replacedOrders,
int totalOrders,
int productionNodes,
int materialQuotas,
bool skippedUnchanged)
{
return new BomImportReport(
order.Id,
order.Code,
document.Metadata.SourceDocumentCode,
document.Metadata.SourceFileHash,
removedDemoOrders,
replacedOrders,
totalOrders,
document.BomNodes.Count,
productionNodes,
materialQuotas,
document.BlockCount,
document.BomNodes
.GroupBy(node => node.Level)
.OrderBy(group => group.Key)
.ToDictionary(group => group.Key, group => group.Count()),
document.Warnings,
skippedUnchanged);
}
private async Task RemoveOrdersAsync(
IReadOnlyCollection<ProductionOrder> orders,
CancellationToken cancellationToken)
{
var orderIds = orders.Select(order => order.Id).ToArray();
var riskEvents = await dbContext.RiskEvents
.Where(item => orderIds.Contains(item.OrderId))
.ToListAsync(cancellationToken);
var trendPoints = await dbContext.ProductionTrendPoints
.Where(item => orderIds.Contains(item.OrderId))
.ToListAsync(cancellationToken);
var quotas = await dbContext.MaterialQuotas
.Where(item => orderIds.Contains(item.OrderId))
.ToListAsync(cancellationToken);
var nodes = await dbContext.ProductionNodes
.Where(item => orderIds.Contains(item.OrderId))
.OrderByDescending(item => item.Level)
.ToListAsync(cancellationToken);
dbContext.RiskEvents.RemoveRange(riskEvents);
dbContext.ProductionTrendPoints.RemoveRange(trendPoints);
dbContext.MaterialQuotas.RemoveRange(quotas);
dbContext.ProductionNodes.RemoveRange(nodes);
await dbContext.SaveChangesAsync(cancellationToken);
dbContext.ProductionOrders.RemoveRange(orders);
await dbContext.SaveChangesAsync(cancellationToken);
}
private static ProductionOrder BuildOrder(BomImportMetadata metadata, DateTimeOffset importedAt)
{
return new ProductionOrder
{
Id = StableGuid($"{metadata.SourceDocumentCode}|order"),
Code = metadata.WorkOrderCode,
ProductName = metadata.ContractProductName,
DataSource = "excel",
SourceDocumentCode = metadata.SourceDocumentCode,
ProjectName = metadata.ProjectName,
DrawingProductName = metadata.DrawingProductName,
SourceFileName = metadata.SourceFileName,
SourceFileHash = metadata.SourceFileHash,
ImportedAt = importedAt,
BatchQty = metadata.BatchQuantity,
RequiredQty = metadata.BatchQuantity,
CompletedQty = 0,
Progress = 0,
Status = "waiting",
RiskLevel = "normal",
DelayDays = 0,
LineName = string.Empty,
Owner = string.Empty,
PlanAchievement = 0,
DailyDelta = 0,
ThumbnailKey = "generic",
CreatedAt = importedAt,
UpdatedAt = importedAt,
};
}
private static List<ProductionNode> BuildNodes(
ProductionOrder order,
BomImportDocument document,
DateTimeOffset importedAt)
{
var rootId = StableGuid($"{document.Metadata.SourceDocumentCode}|node|root");
var nodeIdsBySourceRow = document.BomNodes.ToDictionary(
node => node.SourceRow,
node => StableGuid($"{document.Metadata.SourceDocumentCode}|node|明细|{node.SourceRow}"));
var nodes = new List<ProductionNode>(document.BomNodes.Count + 1)
{
new()
{
Id = rootId,
OrderId = order.Id,
Code = order.Code,
MaterialCode = document.Metadata.SourceDocumentCode,
Name = order.ProductName,
Level = 0,
NodeType = "order",
SupplyType = "self_made",
RequiredQty = order.BatchQty,
CompletedQty = 0,
Progress = 0,
Status = "waiting",
RiskLevel = "normal",
SourceSheet = "明细",
SourceRow = 0,
VisualKey = "generic",
SortOrder = 0,
UpdatedAt = importedAt,
},
};
foreach (var source in document.BomNodes)
{
var parentId = source.ParentSourceRow.HasValue
? nodeIdsBySourceRow[source.ParentSourceRow.Value]
: rootId;
nodes.Add(new ProductionNode
{
Id = nodeIdsBySourceRow[source.SourceRow],
OrderId = order.Id,
ParentNodeId = parentId,
Code = string.IsNullOrWhiteSpace(source.DrawingNumber)
? $"BOM-{source.SourceRow:0000}"
: source.DrawingNumber,
OperationCode = string.Empty,
MaterialCode = source.DrawingNumber,
Name = source.Name,
SourceSequence = source.SourceSequence,
Specification = source.Specification,
Material = source.Material,
UnitWeight = source.UnitWeight,
TotalWeight = source.TotalWeight,
UnitWeightText = source.UnitWeightText,
TotalWeightText = source.TotalWeightText,
BomQuantity = source.Quantity,
BomQuantityText = source.QuantityText,
Remark = source.Remark,
SourceSheet = "明细",
SourceRow = source.SourceRow,
Level = source.Level,
NodeType = source.NodeType,
SupplyType = source.SupplyType,
RequiredQty = ToOperationalQuantity(source),
CompletedQty = 0,
DefectQty = 0,
Progress = 0,
Status = "waiting",
RiskLevel = "normal",
DelayDays = 0,
DelayReason = string.Empty,
Owner = string.Empty,
Vendor = string.Empty,
StationName = string.Empty,
VisualKey = "generic",
SortOrder = source.SourceRow,
UpdatedAt = importedAt,
});
}
return nodes;
}
private static List<MaterialQuota> BuildMaterialQuotas(Guid orderId, BomImportDocument document)
{
return document.MaterialQuotas.Select(source => new MaterialQuota
{
Id = StableGuid($"{document.Metadata.SourceDocumentCode}|quota|材料定额|{source.SourceRow}"),
OrderId = orderId,
Category = source.Category,
SourceSequence = source.SourceSequence,
MaterialCode = source.MaterialCode,
MaterialName = source.MaterialName,
Specification = source.Specification,
Unit = source.Unit,
Quantity = source.Quantity,
NetWeight = source.NetWeight,
ConsumptionQuota = source.ConsumptionQuota,
QuantityText = source.QuantityText,
NetWeightText = source.NetWeightText,
ConsumptionQuotaText = source.ConsumptionQuotaText,
Brand = source.Brand,
Remark = source.Remark,
SourceSheet = "材料定额",
SourceRow = source.SourceRow,
}).ToList();
}
private static int ToOperationalQuantity(BomImportNode source)
{
if (!source.Quantity.HasValue || source.Quantity.Value <= 0)
{
return 0;
}
if (source.Quantity.Value > int.MaxValue)
{
throw new BomImportException($"明细第 {source.SourceRow} 行数量超出进度字段可表示范围。");
}
return decimal.ToInt32(decimal.Ceiling(source.Quantity.Value));
}
private static Guid StableGuid(string value)
{
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value));
var bytes = hash[..16];
bytes[6] = (byte)((bytes[6] & 0x0F) | 0x50);
bytes[8] = (byte)((bytes[8] & 0x3F) | 0x80);
return new Guid(bytes);
}
}
public sealed record BomImportReport(
Guid OrderId,
string OrderCode,
string SourceDocumentCode,
string SourceFileHash,
int RemovedDemoOrders,
int ReplacedOrders,
int TotalOrders,
int ImportedBomNodes,
int TotalProductionNodes,
int MaterialQuotas,
int Blocks,
IReadOnlyDictionary<int, int> LevelCounts,
IReadOnlyList<string> Warnings,
bool SkippedUnchanged);
@@ -0,0 +1,534 @@
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);
}
@@ -0,0 +1,165 @@
using DongfangHydro.Dashboard.Api.Contracts;
using DongfangHydro.Dashboard.Api.Data;
using DongfangHydro.Dashboard.Api.Importing;
using DongfangHydro.Dashboard.Api.Realtime;
using DongfangHydro.Dashboard.Api.Services;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
var importCommand = BomImportCommand.Parse(args);
var builder = WebApplication.CreateBuilder(importCommand is null ? args : []);
if (importCommand is not null)
{
builder.Configuration.AddUserSecrets<Program>(optional: true);
}
var connectionString = builder.Configuration.GetConnectionString("DashboardDb")
?? throw new InvalidOperationException("Connection string 'DashboardDb' is not configured.");
builder.Services.AddDbContext<DashboardDbContext>(options => options.UseSqlServer(connectionString));
builder.Services.AddScoped<ProgressAggregationService>();
builder.Services.AddScoped<DashboardSeeder>();
builder.Services.AddScoped<DashboardQueryService>();
builder.Services.AddScoped<ProgressUpdateService>();
builder.Services.AddScoped<BomImportService>();
builder.Services.AddSignalR();
builder.Services.AddSingleton<IDashboardNotifier, SignalRDashboardNotifier>();
builder.Services.AddSingleton<DashboardUpdateQueue>();
builder.Services.AddSingleton<IDashboardUpdateQueue>(services =>
services.GetRequiredService<DashboardUpdateQueue>());
builder.Services.AddHostedService(services => services.GetRequiredService<DashboardUpdateQueue>());
builder.Services.AddHealthChecks();
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [];
if (origins.Length == 0)
{
policy.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod();
return;
}
policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
});
});
var app = builder.Build();
if (importCommand is not null)
{
var report = await BomImportCommandRunner.RunAsync(app.Services, importCommand, CancellationToken.None);
Console.WriteLine(JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
return;
}
app.UseCors();
app.MapHealthChecks("/health");
app.MapGet("/api/dashboard/overview", async (
DashboardQueryService service,
CancellationToken cancellationToken) =>
Results.Ok(await service.GetOverviewAsync(cancellationToken)));
app.MapGet("/api/orders", async (
string? keyword,
string? status,
Guid? lineId,
int? page,
int? pageSize,
DashboardQueryService service,
CancellationToken cancellationToken) =>
Results.Ok(await service.GetOrdersAsync(
keyword,
status,
lineId,
page ?? 1,
pageSize ?? 20,
cancellationToken)));
app.MapGet("/api/orders/{orderId:guid}", async (
Guid orderId,
DashboardQueryService service,
CancellationToken cancellationToken) =>
{
var order = await service.GetOrderAsync(orderId, cancellationToken);
return order is null ? Results.NotFound() : Results.Ok(order);
});
app.MapGet("/api/orders/{orderId:guid}/tree", async (
Guid orderId,
DashboardQueryService service,
CancellationToken cancellationToken) =>
{
var tree = await service.GetTreeAsync(orderId, cancellationToken);
return tree is null ? Results.NotFound() : Results.Ok(tree);
});
app.MapGet("/api/orders/{orderId:guid}/trend", async (
Guid orderId,
int? limit,
DashboardQueryService service,
CancellationToken cancellationToken) =>
Results.Ok(await service.GetTrendAsync(orderId, limit ?? 12, cancellationToken)));
app.MapGet("/api/orders/{orderId:guid}/material-quotas", async (
Guid orderId,
string? category,
string? keyword,
int? page,
int? pageSize,
DashboardQueryService service,
CancellationToken cancellationToken) =>
Results.Ok(await service.GetMaterialQuotasAsync(
orderId,
category,
keyword,
page ?? 1,
pageSize ?? 50,
cancellationToken)));
app.MapGet("/api/risk-events", async (
Guid? orderId,
int? limit,
DashboardQueryService service,
CancellationToken cancellationToken) =>
Results.Ok(await service.GetRiskEventsAsync(orderId, limit ?? 20, cancellationToken)));
app.MapGet("/api/delay-top", async (
int? limit,
DashboardQueryService service,
CancellationToken cancellationToken) =>
Results.Ok(await service.GetDelayTopAsync(limit ?? 5, cancellationToken)));
app.MapPatch("/api/nodes/{nodeId:guid}/progress", async (
Guid nodeId,
UpdateNodeProgressRequest request,
ProgressUpdateService service,
CancellationToken cancellationToken) =>
{
try
{
var result = await service.UpdateAsync(nodeId, request, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result);
}
catch (ArgumentException exception)
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["progress"] = [exception.Message],
});
}
catch (DbUpdateConcurrencyException)
{
return Results.Conflict(new
{
code = "progress_conflict",
message = "The production node changed after it was loaded. Reload the node and retry.",
});
}
});
app.MapHub<DashboardHub>("/hubs/dashboard");
await DatabaseInitializer.InitializeAsync(app);
app.Run();
public partial class Program;
@@ -0,0 +1,5 @@
using Microsoft.AspNetCore.SignalR;
namespace DongfangHydro.Dashboard.Api.Realtime;
public sealed class DashboardHub : Hub;
@@ -0,0 +1,112 @@
using System.Collections.Concurrent;
using DongfangHydro.Dashboard.Api.Contracts;
using DongfangHydro.Dashboard.Api.Services;
namespace DongfangHydro.Dashboard.Api.Realtime;
public sealed class DashboardUpdateQueue(
IDashboardNotifier notifier,
IServiceScopeFactory scopeFactory,
ILogger<DashboardUpdateQueue> logger)
: BackgroundService, IDashboardUpdateQueue
{
private const int MaximumAttempts = 5;
private readonly ConcurrentDictionary<Guid, QueuedUpdate> pendingByOrder = new();
private readonly SemaphoreSlim signal = new(0, 1);
private int signalScheduled;
public ValueTask EnqueueAsync(
UpdateNodeProgressResultDto update,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
pendingByOrder.AddOrUpdate(
update.Order.Id,
_ => new QueuedUpdate(update),
(_, _) => new QueuedUpdate(update));
ScheduleWork();
return ValueTask.CompletedTask;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
await signal.WaitAsync(stoppingToken);
Interlocked.Exchange(ref signalScheduled, 0);
foreach (var orderId in pendingByOrder.Keys)
{
if (pendingByOrder.TryRemove(orderId, out var item))
{
await PublishLatestAsync(orderId, item, stoppingToken);
}
}
if (!pendingByOrder.IsEmpty)
{
ScheduleWork();
}
}
}
private async Task PublishLatestAsync(
Guid orderId,
QueuedUpdate initial,
CancellationToken stoppingToken)
{
var current = initial;
var attempt = 1;
while (!stoppingToken.IsCancellationRequested)
{
if (pendingByOrder.TryRemove(orderId, out var newer))
{
current = newer;
attempt = 1;
}
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var overview = await scope.ServiceProvider
.GetRequiredService<DashboardQueryService>()
.GetOverviewAsync(stoppingToken);
await notifier.PublishUpdateAsync(current.Update, overview, stoppingToken);
return;
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
logger.LogWarning(
exception,
"Dashboard update broadcast failed on attempt {Attempt} for order {OrderId}.",
attempt,
orderId);
if (attempt >= MaximumAttempts)
{
return;
}
if (!pendingByOrder.ContainsKey(orderId))
{
await Task.Delay(TimeSpan.FromSeconds(attempt * 2), stoppingToken);
attempt += 1;
}
}
}
}
private void ScheduleWork()
{
if (Interlocked.CompareExchange(ref signalScheduled, 1, 0) == 0)
{
signal.Release();
}
}
private sealed record QueuedUpdate(UpdateNodeProgressResultDto Update);
}
@@ -0,0 +1,11 @@
using DongfangHydro.Dashboard.Api.Contracts;
namespace DongfangHydro.Dashboard.Api.Realtime;
public interface IDashboardNotifier
{
Task PublishUpdateAsync(
UpdateNodeProgressResultDto update,
DashboardOverviewDto overview,
CancellationToken cancellationToken);
}
@@ -0,0 +1,10 @@
using DongfangHydro.Dashboard.Api.Contracts;
namespace DongfangHydro.Dashboard.Api.Realtime;
public interface IDashboardUpdateQueue
{
ValueTask EnqueueAsync(
UpdateNodeProgressResultDto update,
CancellationToken cancellationToken);
}
@@ -0,0 +1,24 @@
using DongfangHydro.Dashboard.Api.Contracts;
using Microsoft.AspNetCore.SignalR;
namespace DongfangHydro.Dashboard.Api.Realtime;
public sealed class SignalRDashboardNotifier(IHubContext<DashboardHub> hubContext)
: IDashboardNotifier
{
public async Task PublishUpdateAsync(
UpdateNodeProgressResultDto update,
DashboardOverviewDto overview,
CancellationToken cancellationToken)
{
var clients = hubContext.Clients.All;
await Task.WhenAll(
clients.SendAsync("nodeUpdated", update.Node, cancellationToken),
clients.SendAsync("orderUpdated", update.Order, cancellationToken),
clients.SendAsync("trendAppended", update.TrendPoint, cancellationToken),
clients.SendAsync("overviewUpdated", overview, cancellationToken),
update.RiskEvent is null
? Task.CompletedTask
: clients.SendAsync("riskEventCreated", update.RiskEvent, cancellationToken));
}
}
@@ -0,0 +1,151 @@
using DongfangHydro.Dashboard.Api.Contracts;
using DongfangHydro.Dashboard.Api.Domain;
namespace DongfangHydro.Dashboard.Api.Services;
public static class DashboardMapper
{
public static ProductionNodeDto? BuildTree(IReadOnlyCollection<ProductionNode> nodes)
{
var root = nodes
.Where(node => node.ParentNodeId is null)
.OrderBy(node => node.SortOrder)
.FirstOrDefault(node => node.NodeType == "order")
?? nodes.Where(node => node.ParentNodeId is null).OrderBy(node => node.SortOrder).FirstOrDefault();
if (root is null)
{
return null;
}
var childrenByParent = nodes
.Where(node => node.ParentNodeId.HasValue)
.GroupBy(node => node.ParentNodeId!.Value)
.ToDictionary(
group => group.Key,
group => group.OrderBy(node => node.SortOrder).ThenBy(node => node.Code).ToList());
return MapNode(root, childrenByParent, []);
}
public static ProductionNodeDto MapNode(ProductionNode node)
{
return MapNode(node, new Dictionary<Guid, List<ProductionNode>>(), []);
}
public static TrendPointDto MapTrend(ProductionTrendPoint point)
{
return new TrendPointDto(
point.SampleTime.ToLocalTime().ToString("HH:mm:ss"),
point.Completion,
point.Risk,
point.PlannedQty,
point.ActualQty,
Math.Round(point.Achievement, 1));
}
public static RiskEventDto MapRiskEvent(RiskEvent riskEvent)
{
return new RiskEventDto(
riskEvent.Id,
riskEvent.OrderId,
riskEvent.NodeId,
riskEvent.OrderCode,
riskEvent.NodeName,
riskEvent.RiskLevel,
riskEvent.Message,
riskEvent.OccurredAt.ToLocalTime().ToString("HH:mm:ss"),
riskEvent.HandlingStatus);
}
public static OrderSummaryDto MapOrder(
ProductionOrder order,
ProductionNodeDto root,
IReadOnlyList<TrendPointDto> trend,
IReadOnlyList<RiskEventDto> events)
{
return new OrderSummaryDto(
order.Id,
order.Code,
order.ProductName,
order.BatchQty,
order.RequiredQty,
order.CompletedQty,
order.Progress,
order.Status,
order.RiskLevel,
order.DelayDays,
FormatDate(order.PlannedStart),
FormatDate(order.PlannedEnd),
order.LineName,
order.Owner,
Math.Round(order.PlanAchievement, 1),
Math.Round(order.DailyDelta, 1),
order.ThumbnailKey,
root,
trend,
events);
}
private static ProductionNodeDto MapNode(
ProductionNode node,
IReadOnlyDictionary<Guid, List<ProductionNode>> childrenByParent,
HashSet<Guid> ancestors)
{
if (!ancestors.Add(node.Id))
{
throw new InvalidOperationException($"Production node cycle detected at {node.Id}.");
}
var children = childrenByParent.TryGetValue(node.Id, out var childNodes)
? childNodes.Select(child => MapNode(child, childrenByParent, new HashSet<Guid>(ancestors))).ToList()
: [];
return new ProductionNodeDto(
node.Id,
node.OrderId,
node.ParentNodeId,
node.Code,
node.OperationCode,
node.MaterialCode,
node.Name,
node.SourceSequence,
node.Specification,
node.Material,
node.UnitWeight,
node.TotalWeight,
node.UnitWeightText,
node.TotalWeightText,
node.BomQuantity,
node.BomQuantityText,
node.Remark,
node.SourceSheet,
node.SourceRow,
node.Level,
node.NodeType,
node.SupplyType,
node.RequiredQty,
node.CompletedQty,
node.DefectQty,
node.Progress,
node.Status,
node.RiskLevel,
node.DelayDays,
node.DelayReason,
FormatDate(node.PlannedStart),
FormatDate(node.PlannedEnd),
FormatDate(node.ActualStart),
FormatDate(node.ActualEnd),
node.Owner,
node.Vendor,
node.StationName,
node.VisualKey,
Convert.ToBase64String(node.RowVersion),
children);
}
private static string FormatDate(DateTime? value)
{
return value.HasValue ? value.Value.ToString("yyyy-MM-dd") : string.Empty;
}
}
@@ -0,0 +1,382 @@
using DongfangHydro.Dashboard.Api.Contracts;
using DongfangHydro.Dashboard.Api.Data;
using DongfangHydro.Dashboard.Api.Domain;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
namespace DongfangHydro.Dashboard.Api.Services;
public sealed class DashboardQueryService(DashboardDbContext dbContext)
{
public async Task<PagedResult<MaterialQuotaDto>> GetMaterialQuotasAsync(
Guid orderId,
string? category,
string? keyword,
int page,
int pageSize,
CancellationToken cancellationToken)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 200);
var query = dbContext.MaterialQuotas.AsNoTracking().Where(item => item.OrderId == orderId);
if (!string.IsNullOrWhiteSpace(category))
{
var normalizedCategory = category.Trim();
query = query.Where(item => item.Category == normalizedCategory);
}
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalizedKeyword = keyword.Trim();
query = query.Where(item =>
item.MaterialCode.Contains(normalizedKeyword)
|| item.MaterialName.Contains(normalizedKeyword)
|| item.Specification.Contains(normalizedKeyword));
}
var total = await query.CountAsync(cancellationToken);
var rows = await query
.OrderBy(item => item.Category)
.ThenBy(item => item.SourceRow)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
var items = rows.Select(item => new MaterialQuotaDto(
item.Id,
item.OrderId,
item.Category,
item.SourceSequence,
item.MaterialCode,
item.MaterialName,
item.Specification,
item.Unit,
item.Quantity,
item.NetWeight,
item.ConsumptionQuota,
item.QuantityText,
item.NetWeightText,
item.ConsumptionQuotaText,
item.Brand,
item.Remark,
item.SourceSheet,
item.SourceRow)).ToList();
return new PagedResult<MaterialQuotaDto>(items, total, page, pageSize);
}
public async Task<PagedResult<OrderSummaryDto>> GetOrdersAsync(
string? keyword,
string? status,
Guid? lineId,
int page,
int pageSize,
CancellationToken cancellationToken)
{
page = Math.Max(1, page);
pageSize = Math.Clamp(pageSize, 1, 100);
var query = dbContext.ProductionOrders.AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword))
{
var normalized = keyword.Trim();
query = query.Where(order =>
order.Code.Contains(normalized)
|| order.ProductName.Contains(normalized)
|| order.LineName.Contains(normalized)
|| order.Owner.Contains(normalized));
}
if (!string.IsNullOrWhiteSpace(status))
{
query = query.Where(order => order.Status == status);
}
if (lineId.HasValue)
{
query = query.Where(order => order.LineId == lineId);
}
var total = await query.CountAsync(cancellationToken);
var orders = await query
.OrderBy(order => order.Code)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
var items = await BuildOrderSummariesAsync(orders, cancellationToken);
return new PagedResult<OrderSummaryDto>(items, total, page, pageSize);
}
public async Task<OrderSummaryDto?> GetOrderAsync(Guid orderId, CancellationToken cancellationToken)
{
var order = await dbContext.ProductionOrders
.AsNoTracking()
.SingleOrDefaultAsync(item => item.Id == orderId, cancellationToken);
if (order is null)
{
return null;
}
return (await BuildOrderSummariesAsync([order], cancellationToken)).SingleOrDefault();
}
public async Task<ProductionNodeDto?> GetTreeAsync(Guid orderId, CancellationToken cancellationToken)
{
var nodes = await dbContext.ProductionNodes
.AsNoTracking()
.Where(node => node.OrderId == orderId)
.OrderBy(node => node.Level)
.ThenBy(node => node.SortOrder)
.ToListAsync(cancellationToken);
return DashboardMapper.BuildTree(nodes);
}
public async Task<IReadOnlyList<TrendPointDto>> GetTrendAsync(
Guid orderId,
int limit,
CancellationToken cancellationToken)
{
var points = await dbContext.ProductionTrendPoints
.AsNoTracking()
.Where(point => point.OrderId == orderId)
.OrderByDescending(point => point.SampleTime)
.Take(Math.Clamp(limit, 1, 100))
.ToListAsync(cancellationToken);
return points.OrderBy(point => point.SampleTime).Select(DashboardMapper.MapTrend).ToList();
}
public async Task<IReadOnlyList<RiskEventDto>> GetRiskEventsAsync(
Guid? orderId,
int limit,
CancellationToken cancellationToken)
{
var query = dbContext.RiskEvents.AsNoTracking();
if (orderId.HasValue)
{
query = query.Where(riskEvent => riskEvent.OrderId == orderId);
}
var events = await query
.OrderByDescending(riskEvent => riskEvent.OccurredAt)
.Take(Math.Clamp(limit, 1, 100))
.ToListAsync(cancellationToken);
return events.Select(DashboardMapper.MapRiskEvent).ToList();
}
public async Task<DashboardOverviewDto> GetOverviewAsync(CancellationToken cancellationToken)
{
var orders = await dbContext.ProductionOrders.AsNoTracking().ToListAsync(cancellationToken);
var totalRequired = orders.Sum(order => order.RequiredQty);
var totalCompleted = orders.Sum(order => order.CompletedQty);
return new DashboardOverviewDto(
orders.Count,
totalRequired,
totalCompleted,
totalRequired == 0 ? 0 : Math.Round(totalCompleted * 100d / totalRequired, 1),
orders.Count(order => order.DelayDays > 0 || order.Status is "delayed" or "blocked"),
orders.Count(order => order.RiskLevel == "critical"),
orders.Count(order => order.RiskLevel == "warning"),
orders.Count == 0 ? 0 : Math.Round(orders.Average(order => order.PlanAchievement), 1),
DateTimeOffset.UtcNow);
}
public async Task<IReadOnlyList<DelayTopItemDto>> GetDelayTopAsync(
int limit,
CancellationToken cancellationToken)
{
var orders = await dbContext.ProductionOrders
.AsNoTracking()
.Where(order => order.DelayDays > 0 || order.Status == "delayed" || order.Status == "blocked")
.OrderByDescending(order => order.DelayDays)
.ThenByDescending(order => order.RiskLevel == "critical")
.Take(Math.Clamp(limit, 1, 50))
.ToListAsync(cancellationToken);
var orderIds = orders.Select(order => order.Id).ToArray();
var recentEvents = await LoadRecentRiskEventsAsync(orderIds, 1, cancellationToken);
var result = new List<DelayTopItemDto>(orders.Count);
for (var index = 0; index < orders.Count; index++)
{
var order = orders[index];
var delayReason = recentEvents.FirstOrDefault(riskEvent => riskEvent.OrderId == order.Id)?.Message;
result.Add(new DelayTopItemDto(
index + 1,
order.Id,
order.Code,
order.ProductName,
order.DelayDays,
delayReason ?? "计划节拍低于目标",
order.RiskLevel));
}
return result;
}
private async Task<IReadOnlyList<OrderSummaryDto>> BuildOrderSummariesAsync(
IReadOnlyList<ProductionOrder> orders,
CancellationToken cancellationToken)
{
if (orders.Count == 0)
{
return [];
}
var orderIds = orders.Select(order => order.Id).ToList();
var nodes = await dbContext.ProductionNodes
.AsNoTracking()
.Where(node => orderIds.Contains(node.OrderId))
.OrderBy(node => node.Level)
.ThenBy(node => node.SortOrder)
.ToListAsync(cancellationToken);
var trend = await LoadRecentTrendAsync(orderIds, 12, cancellationToken);
var events = await LoadRecentRiskEventsAsync(orderIds, 8, cancellationToken);
var result = new List<OrderSummaryDto>(orders.Count);
foreach (var order in orders)
{
var root = DashboardMapper.BuildTree(nodes.Where(node => node.OrderId == order.Id).ToList());
if (root is null)
{
continue;
}
var orderTrend = trend.Where(point => point.OrderId == order.Id);
var orderEvents = events
.Where(riskEvent => riskEvent.OrderId == order.Id)
.OrderByDescending(riskEvent => riskEvent.OccurredAt);
result.Add(DashboardMapper.MapOrder(
order,
root,
orderTrend.OrderBy(point => point.SampleTime).Select(DashboardMapper.MapTrend).ToList(),
orderEvents.Select(DashboardMapper.MapRiskEvent).ToList()));
}
return result;
}
private async Task<List<ProductionTrendPoint>> LoadRecentTrendAsync(
IReadOnlyCollection<Guid> orderIds,
int perOrderLimit,
CancellationToken cancellationToken)
{
if (orderIds.Count == 0)
{
return [];
}
if (dbContext.Database.IsSqlServer())
{
return await BuildRecentTrendQuery(dbContext, orderIds, perOrderLimit)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
var points = await dbContext.ProductionTrendPoints
.AsNoTracking()
.Where(point => orderIds.Contains(point.OrderId))
.ToListAsync(cancellationToken);
return points
.GroupBy(point => point.OrderId)
.SelectMany(group => group.OrderByDescending(point => point.SampleTime).Take(perOrderLimit))
.ToList();
}
private async Task<List<RiskEvent>> LoadRecentRiskEventsAsync(
IReadOnlyCollection<Guid> orderIds,
int perOrderLimit,
CancellationToken cancellationToken)
{
if (orderIds.Count == 0)
{
return [];
}
if (dbContext.Database.IsSqlServer())
{
return await BuildRecentRiskEventQuery(dbContext, orderIds, perOrderLimit)
.AsNoTracking()
.ToListAsync(cancellationToken);
}
var events = await dbContext.RiskEvents
.AsNoTracking()
.Where(riskEvent => orderIds.Contains(riskEvent.OrderId))
.ToListAsync(cancellationToken);
return events
.GroupBy(riskEvent => riskEvent.OrderId)
.SelectMany(group => group.OrderByDescending(riskEvent => riskEvent.OccurredAt).Take(perOrderLimit))
.ToList();
}
public static IQueryable<ProductionTrendPoint> BuildRecentTrendQuery(
DashboardDbContext dbContext,
IReadOnlyCollection<Guid> orderIds,
int perOrderLimit)
{
if (orderIds.Count == 0)
{
return dbContext.ProductionTrendPoints.Where(_ => false);
}
var (placeholders, parameters) = BuildOrderIdParameters(orderIds);
parameters.Add(new SqlParameter("@perOrderLimit", Math.Max(1, perOrderLimit)));
var sql = $$"""
SELECT [ranked].[Id], [ranked].[OrderId], [ranked].[SampleTime], [ranked].[Completion],
[ranked].[Risk], [ranked].[PlannedQty], [ranked].[ActualQty], [ranked].[Achievement]
FROM (
SELECT [point].*, ROW_NUMBER() OVER (
PARTITION BY [point].[OrderId]
ORDER BY [point].[SampleTime] DESC, [point].[Id] DESC
) AS [row_number]
FROM [ProductionTrendPoints] AS [point]
WHERE [point].[OrderId] IN ({{placeholders}})
) AS [ranked]
WHERE [ranked].[row_number] <= @perOrderLimit
""";
return dbContext.ProductionTrendPoints.FromSqlRaw(sql, [.. parameters]);
}
private static IQueryable<RiskEvent> BuildRecentRiskEventQuery(
DashboardDbContext dbContext,
IReadOnlyCollection<Guid> orderIds,
int perOrderLimit)
{
if (orderIds.Count == 0)
{
return dbContext.RiskEvents.Where(_ => false);
}
var (placeholders, parameters) = BuildOrderIdParameters(orderIds);
parameters.Add(new SqlParameter("@perOrderLimit", Math.Max(1, perOrderLimit)));
var sql = $$"""
SELECT [ranked].[Id], [ranked].[OrderId], [ranked].[NodeId], [ranked].[OrderCode],
[ranked].[NodeName], [ranked].[RiskLevel], [ranked].[Message],
[ranked].[HandlingStatus], [ranked].[OccurredAt]
FROM (
SELECT [event].*, ROW_NUMBER() OVER (
PARTITION BY [event].[OrderId]
ORDER BY [event].[OccurredAt] DESC, [event].[Id] DESC
) AS [row_number]
FROM [RiskEvents] AS [event]
WHERE [event].[OrderId] IN ({{placeholders}})
) AS [ranked]
WHERE [ranked].[row_number] <= @perOrderLimit
""";
return dbContext.RiskEvents.FromSqlRaw(sql, [.. parameters]);
}
private static (string Placeholders, List<object> Parameters) BuildOrderIdParameters(
IReadOnlyCollection<Guid> orderIds)
{
var parameters = orderIds
.Select((orderId, index) => (object)new SqlParameter($"@orderId{index}", orderId))
.ToList();
var placeholders = string.Join(", ", Enumerable.Range(0, orderIds.Count).Select(index => $"@orderId{index}"));
return (placeholders, parameters);
}
}
@@ -0,0 +1,122 @@
using DongfangHydro.Dashboard.Api.Domain;
namespace DongfangHydro.Dashboard.Api.Services;
public sealed class ProgressAggregationService
{
public void Recalculate(ProductionOrder order, IReadOnlyCollection<ProductionNode> nodes)
{
var childrenByParent = nodes
.Where(node => node.ParentNodeId.HasValue)
.GroupBy(node => node.ParentNodeId!.Value)
.ToDictionary(group => group.Key, group => group.ToList());
var roots = nodes.Where(node => node.ParentNodeId is null).ToList();
foreach (var rootNode in roots)
{
RecalculateNode(rootNode, childrenByParent);
}
var root = roots.FirstOrDefault(node => node.NodeType == "order") ?? roots.FirstOrDefault();
if (root is null)
{
return;
}
order.RequiredQty = root.RequiredQty;
order.CompletedQty = root.CompletedQty;
order.Progress = root.Progress;
order.Status = root.Status;
order.RiskLevel = root.RiskLevel;
order.DelayDays = root.DelayDays;
}
private static void RecalculateNode(
ProductionNode node,
IReadOnlyDictionary<Guid, List<ProductionNode>> childrenByParent)
{
if (!childrenByParent.TryGetValue(node.Id, out var children) || children.Count == 0)
{
node.Progress = Percentage(node.CompletedQty, node.RequiredQty);
if (node.Progress >= 100)
{
node.Status = "done";
node.RiskLevel = "normal";
node.DelayDays = 0;
node.DelayReason = string.Empty;
}
return;
}
foreach (var child in children)
{
RecalculateNode(child, childrenByParent);
}
var childRequiredQty = children.Sum(child => child.RequiredQty);
var weightedProgress = childRequiredQty == 0
? 0
: children.Sum(child => child.Progress * child.RequiredQty) / (double)childRequiredQty;
node.RequiredQty = node.RequiredQty > 0 ? node.RequiredQty : childRequiredQty;
node.CompletedQty = (int)Math.Round(
node.RequiredQty * weightedProgress / 100d,
MidpointRounding.AwayFromZero);
node.DefectQty = children.Sum(child => child.DefectQty);
node.Progress = Math.Clamp(
(int)Math.Round(weightedProgress, MidpointRounding.AwayFromZero),
0,
100);
node.DelayDays = children.Max(child => child.DelayDays);
node.RiskLevel = AggregateRisk(children);
node.Status = AggregateStatus(children);
}
private static int Percentage(int completed, int required)
{
return required <= 0
? 0
: Math.Clamp((int)Math.Round(completed * 100d / required, MidpointRounding.AwayFromZero), 0, 100);
}
private static string AggregateRisk(IEnumerable<ProductionNode> children)
{
var childList = children.ToList();
if (childList.Any(child =>
child.RiskLevel == "critical"
|| child.Status == "blocked"
|| child.DelayDays >= 3))
{
return "critical";
}
return childList.Any(child =>
child.RiskLevel == "warning"
|| child.Status == "delayed"
|| child.DelayDays > 0)
? "warning"
: "normal";
}
private static string AggregateStatus(IReadOnlyCollection<ProductionNode> children)
{
if (children.Any(child => child.Status == "blocked"))
{
return "blocked";
}
if (children.Any(child => child.Status == "delayed" || child.DelayDays > 0))
{
return "delayed";
}
if (children.All(child => child.Status == "done"))
{
return "done";
}
return children.Any(child => child.Status == "in_progress" || child.CompletedQty > 0)
? "in_progress"
: "waiting";
}
}
@@ -0,0 +1,252 @@
using DongfangHydro.Dashboard.Api.Contracts;
using DongfangHydro.Dashboard.Api.Data;
using DongfangHydro.Dashboard.Api.Domain;
using DongfangHydro.Dashboard.Api.Realtime;
using Microsoft.EntityFrameworkCore;
namespace DongfangHydro.Dashboard.Api.Services;
public sealed class ProgressUpdateService(
DashboardDbContext dbContext,
ProgressAggregationService aggregationService,
IDashboardUpdateQueue updateQueue)
{
public async Task<UpdateNodeProgressResultDto?> UpdateAsync(
Guid nodeId,
UpdateNodeProgressRequest request,
CancellationToken cancellationToken)
{
var node = await dbContext.ProductionNodes
.SingleOrDefaultAsync(item => item.Id == nodeId, cancellationToken);
if (node is null)
{
return null;
}
if (!request.CompletedQty.HasValue)
{
throw new ArgumentException("Completed quantity is required.", nameof(request.CompletedQty));
}
var completedQty = request.CompletedQty.Value;
if (completedQty < 0 || completedQty > node.RequiredQty)
{
throw new ArgumentOutOfRangeException(
nameof(request.CompletedQty),
$"Completed quantity must be between 0 and {node.RequiredQty}.");
}
var order = await dbContext.ProductionOrders
.SingleAsync(item => item.Id == node.OrderId, cancellationToken);
var nodes = await dbContext.ProductionNodes
.Where(item => item.OrderId == node.OrderId)
.ToListAsync(cancellationToken);
var recentTrend = await dbContext.ProductionTrendPoints
.AsNoTracking()
.Where(point => point.OrderId == node.OrderId)
.OrderByDescending(point => point.SampleTime)
.Take(11)
.ToListAsync(cancellationToken);
var recentEvents = await dbContext.RiskEvents
.AsNoTracking()
.Where(riskEvent => riskEvent.OrderId == node.OrderId)
.OrderByDescending(riskEvent => riskEvent.OccurredAt)
.Take(7)
.ToListAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(request.ExpectedVersion))
{
throw new ArgumentException("Expected version is required.", nameof(request.ExpectedVersion));
}
try
{
dbContext.Entry(node).Property(item => item.RowVersion).OriginalValue =
Convert.FromBase64String(request.ExpectedVersion);
}
catch (FormatException exception)
{
throw new ArgumentException("Expected version is not valid Base64.", nameof(request.ExpectedVersion), exception);
}
if (node.NodeType is not ("process" or "material"))
{
throw new ArgumentException("Only process or material nodes can receive direct progress updates.", nameof(nodeId));
}
var effectiveStatus = NormalizeStatus(request.Status ?? node.Status);
if (completedQty > 0 && completedQty < node.RequiredQty && effectiveStatus == "waiting")
{
effectiveStatus = "in_progress";
}
var effectiveRisk = NormalizeRisk(request.RiskLevel ?? node.RiskLevel);
var effectiveDelayDays = Math.Max(0, request.DelayDays ?? node.DelayDays);
var effectiveDelayReason = request.DelayReason ?? node.DelayReason;
var effectiveActualStart = request.ActualStart
?? node.ActualStart
?? (completedQty > 0 ? DateTime.Today : null);
var effectiveActualEnd = completedQty >= node.RequiredQty
? request.ActualEnd ?? node.ActualEnd ?? DateTime.Today
: request.ActualEnd;
ValidateState(
completedQty,
node.RequiredQty,
effectiveStatus,
effectiveRisk,
effectiveDelayDays,
effectiveDelayReason,
effectiveActualStart,
effectiveActualEnd);
node.CompletedQty = completedQty;
node.DefectQty = Math.Clamp(request.DefectQty ?? node.DefectQty, 0, completedQty);
node.DelayDays = effectiveDelayDays;
node.DelayReason = effectiveDelayReason;
node.Status = effectiveStatus;
node.RiskLevel = effectiveRisk;
node.ActualStart = effectiveActualStart;
node.ActualEnd = effectiveActualEnd;
node.UpdatedAt = DateTimeOffset.UtcNow;
aggregationService.Recalculate(order, nodes);
order.UpdatedAt = DateTimeOffset.UtcNow;
var plannedQty = Math.Max(
order.CompletedQty,
(int)Math.Round(order.RequiredQty * Math.Min(100, order.Progress + 8) / 100d));
order.PlanAchievement = plannedQty == 0
? 0
: Math.Min(120, order.CompletedQty * 100d / plannedQty);
var trendPoint = new ProductionTrendPoint
{
Id = Guid.NewGuid(),
OrderId = order.Id,
SampleTime = DateTimeOffset.UtcNow,
Completion = order.Progress,
Risk = nodes.Count(item => item.RiskLevel != "normal"),
PlannedQty = plannedQty,
ActualQty = order.CompletedQty,
Achievement = order.PlanAchievement,
};
dbContext.ProductionTrendPoints.Add(trendPoint);
RiskEvent? riskEvent = null;
if (node.RiskLevel != "normal" || node.DelayDays > 0 || node.Status is "delayed" or "blocked")
{
riskEvent = new RiskEvent
{
Id = Guid.NewGuid(),
OrderId = order.Id,
NodeId = node.Id,
OrderCode = order.Code,
NodeName = node.Name,
RiskLevel = node.RiskLevel,
Message = string.IsNullOrWhiteSpace(node.DelayReason)
? $"计划偏差 {Math.Max(1, node.DelayDays)} 天,需要复核节拍"
: node.DelayReason,
HandlingStatus = "processing",
OccurredAt = DateTimeOffset.UtcNow,
};
dbContext.RiskEvents.Add(riskEvent);
}
await dbContext.SaveChangesAsync(cancellationToken);
var rootDto = DashboardMapper.BuildTree(nodes)
?? throw new InvalidOperationException($"Order {order.Id} has no production tree.");
var trendDtos = recentTrend
.Append(trendPoint)
.OrderBy(point => point.SampleTime)
.Select(DashboardMapper.MapTrend)
.ToList();
var eventDtos = (riskEvent is null ? recentEvents : recentEvents.Prepend(riskEvent))
.Take(8)
.Select(DashboardMapper.MapRiskEvent)
.ToList();
var orderDto = DashboardMapper.MapOrder(order, rootDto, trendDtos, eventDtos);
var nodeDto = FindNode(orderDto.Root, node.Id)
?? throw new InvalidOperationException($"Node {node.Id} disappeared after progress update.");
var result = new UpdateNodeProgressResultDto(
nodeDto,
orderDto,
riskEvent is null ? null : DashboardMapper.MapRiskEvent(riskEvent),
DashboardMapper.MapTrend(trendPoint));
await updateQueue.EnqueueAsync(result, CancellationToken.None);
return result;
}
private static ProductionNodeDto? FindNode(ProductionNodeDto node, Guid nodeId)
{
if (node.Id == nodeId)
{
return node;
}
foreach (var child in node.Children)
{
var found = FindNode(child, nodeId);
if (found is not null)
{
return found;
}
}
return null;
}
private static string NormalizeStatus(string value)
{
return value is "waiting" or "in_progress" or "done" or "delayed" or "blocked"
? value
: throw new ArgumentException($"Unsupported production status '{value}'.", nameof(value));
}
private static string NormalizeRisk(string value)
{
return value is "normal" or "warning" or "critical"
? value
: throw new ArgumentException($"Unsupported risk level '{value}'.", nameof(value));
}
private static void ValidateState(
int completedQty,
int requiredQty,
string status,
string riskLevel,
int delayDays,
string delayReason,
DateTime? actualStart,
DateTime? actualEnd)
{
if (delayReason.Length > 500)
{
throw new ArgumentException("Delay reason cannot exceed 500 characters.", nameof(delayReason));
}
if (completedQty < requiredQty && status == "done")
{
throw new ArgumentException("A process cannot be done before its planned quantity is complete.", nameof(status));
}
if (completedQty < requiredQty && actualEnd.HasValue)
{
throw new ArgumentException("An incomplete process cannot have an actual end date.", nameof(actualEnd));
}
if (actualStart.HasValue && actualEnd.HasValue && actualEnd.Value < actualStart.Value)
{
throw new ArgumentException("Actual end date cannot be earlier than actual start date.", nameof(actualEnd));
}
if (status == "blocked" && riskLevel != "critical")
{
throw new ArgumentException("A blocked process must have critical risk.", nameof(riskLevel));
}
if ((status == "delayed" || delayDays > 0) && riskLevel == "normal")
{
throw new ArgumentException("A delayed process cannot have normal risk.", nameof(riskLevel));
}
}
}
@@ -0,0 +1,6 @@
{
"Database": {
"AutoMigrate": true,
"Seed": true
}
}
@@ -0,0 +1,22 @@
{
"ConnectionStrings": {
"DashboardDb": ""
},
"Database": {
"AutoMigrate": false,
"Seed": false
},
"Cors": {
"AllowedOrigins": [
"http://localhost:5173",
"http://127.0.0.1:5173"
]
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
@@ -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);
}
}
+20
View File
@@ -0,0 +1,20 @@
services:
sqlserver:
image: mcr.microsoft.com/mssql/server:2022-latest
container_name: dongfang-hydro-sqlserver
environment:
ACCEPT_EULA: "Y"
MSSQL_PID: "Developer"
MSSQL_SA_PASSWORD: "${MSSQL_SA_PASSWORD:?Set MSSQL_SA_PASSWORD before starting SQL Server}"
ports:
- "127.0.0.1:1433:1433"
volumes:
- dongfang-hydro-sql-data:/var/opt/mssql
healthcheck:
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P \"$${MSSQL_SA_PASSWORD}\" -C -Q 'SELECT 1' || exit 1"]
interval: 10s
timeout: 5s
retries: 12
volumes:
dongfang-hydro-sql-data: