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
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.20",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
+1
View File
@@ -0,0 +1 @@
VITE_DASHBOARD_API_URL=http://localhost:5080
+29
View File
@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
**/bin/
**/obj/
.env
.env.*
!.env.example
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+103
View File
@@ -0,0 +1,103 @@
# 东方水利制造进度驾驶舱
工业制造订单进度大屏,前端采用 React + Vite + TypeScript,后端采用 ASP.NET Core 8 + EF Core + SQL Server + SignalR。
## 目录
- `src/`:驾驶舱前端。
- `server/DongfangHydro.Dashboard.Api/`REST API、SignalR Hub、EF Core 数据模型与迁移。
- `server/DongfangHydro.Dashboard.Tests/`:聚合规则与 API 集成测试。
- `server/docker-compose.yml`:本地 SQL Server 2022 开发实例。
## 数据库连接
正式连接串通过环境变量提供,不需要修改源码:
```powershell
$env:ConnectionStrings__DashboardDb='Server=YOUR_SERVER;Database=YOUR_DATABASE;User Id=YOUR_USER;Password=YOUR_PASSWORD;TrustServerCertificate=True'
$env:Database__AutoMigrate='true'
$env:Database__Seed='true'
```
首次连接正式库时,建议先保留 `Database__Seed=false`,确认迁移成功后再决定是否写入演示数据。已有业务数据库也可以只使用本项目表,不会修改其他表。
当前 `InitialCreate` 是在尚未接入任何正式 SQL Server 实例时生成的首次迁移。如果目标库已由其他版本创建过同名表,请先核对迁移历史,不要直接开启自动迁移。
本地也可以启动 Docker SQL Server
```powershell
docker compose -f .\server\docker-compose.yml up -d
```
启动前先设置强密码:`$env:MSSQL_SA_PASSWORD='YOUR_STRONG_LOCAL_PASSWORD'`。使用 Docker 数据库时,再把 `ConnectionStrings__DashboardDb` 设置为 `Server=localhost,1433;...` 的连接串;端口仅绑定本机回环地址。
## 启动后端
本机未全局安装 .NET 时,可使用本项目开发时安装的用户级 SDK:
```powershell
& "$env:USERPROFILE\.dotnet\dotnet.exe" tool restore
& "$env:USERPROFILE\.dotnet\dotnet.exe" run --project .\server\DongfangHydro.Dashboard.Api --urls http://localhost:5080
```
后端默认地址:`http://localhost:5080`,健康检查:`GET /health`SignalR`/hubs/dashboard`
## 导入 Excel BOM
先将 `ConnectionStrings:DashboardDb` 配置在环境变量或 .NET User Secrets 中。只读校验不会连接数据库:
```powershell
& "$env:USERPROFILE\.dotnet\dotnet.exe" run --project .\server\DongfangHydro.Dashboard.Api -- import-bom --file "C:\path\bom.xlsx" --validate-only
```
校验通过后执行正式导入:
```powershell
& "$env:USERPROFILE\.dotnet\dotnet.exe" run --project .\server\DongfangHydro.Dashboard.Api -- import-bom --file "C:\path\bom.xlsx" --purge-demo
```
导入以定额编号识别同一订单,在一个 SQL 事务中替换原订单及其 BOM、材料定额、趋势和风险数据。`--purge-demo` 只删除标记为 `demo` 的订单,不影响其他真实订单。重复导入同一文件不会累积重复节点。
同一定额编号且文件哈希未变化时,命令默认跳过替换,以免清空后续报工进度。只有确认需要重建时才追加 `--force`;强制导入会重置该订单的进度、趋势和风险数据。
解析器保留来源工作表、行号、原序号、规格、材质、数量、单重、总重和备注。负数量与 `/``95L``21件` 等源表文本通过“原文 + 可计算数值”双字段保存,不会被静默改写。
## 启动前端
```powershell
npm install
npm run dev
```
如后端不是 `http://localhost:5080`,在本地 `.env` 中设置:
```dotenv
VITE_DASHBOARD_API_URL=http://your-api-host:port
```
后端不可用时,前端保留最后一次订单快照;成功连接后会通过 REST 获取完整数据,并通过 SignalR 接收增量更新。
## 主要接口
- `GET /api/dashboard/overview`
- `GET /api/orders?keyword=&status=&lineId=&page=1&pageSize=20`
- `GET /api/orders/{orderId}`
- `GET /api/orders/{orderId}/tree`
- `GET /api/orders/{orderId}/trend`
- `GET /api/orders/{orderId}/material-quotas?category=&keyword=&page=1&pageSize=50`
- `GET /api/risk-events`
- `GET /api/delay-top`
- `PATCH /api/nodes/{nodeId}/progress`
更新进度时应携带节点返回的 `version` 作为 `expectedVersion`;并发修改冲突会返回 `409 Conflict`,调用方重新获取节点后再提交。
第一版按既定范围不包含登录和权限模块。正式部署前应放在受控内网或带鉴权的反向代理之后,写接口不要直接暴露到公网。
## 验证
```powershell
npm test
npm run lint
npm run build
& "$env:USERPROFILE\.dotnet\dotnet.exe" test .\server\DongfangHydro.Dashboard.Tests\DongfangHydro.Dashboard.Tests.csproj
```
+19
View File
@@ -0,0 +1,19 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>制造订单进度驾驶舱</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2957
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
{
"name": "bi",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@microsoft/signalr": "^10.0.0",
"@tailwindcss/vite": "^4.3.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
"lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"reactflow": "^11.11.4",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.3.2"
},
"devDependencies": {
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.3",
"oxlint": "^1.71.0",
"typescript": "~6.0.2",
"vite": "^8.1.1",
"vitest": "^4.1.10"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

@@ -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:
+7
View File
@@ -0,0 +1,7 @@
import { Dashboard } from '@/components/dashboard/Dashboard'
function App() {
return <Dashboard />
}
export default App
@@ -0,0 +1,430 @@
import { type ComponentType, useMemo } from 'react'
import * as EChartsCoreModule from 'echarts-for-react/lib/core.js'
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import {
GraphicComponent,
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components'
import * as echarts from 'echarts/core'
import type { EChartsCoreOption } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import {
Activity,
AlertTriangle,
BarChart3,
CheckCircle2,
Clock3,
DatabaseZap,
Radio,
TimerReset,
} from 'lucide-react'
import type { OrderSummary } from '@/types'
import { chartPalette } from '@/lib/palette'
import { riskMeta } from '@/lib/production'
import { formatPercent, formatQuantity } from '@/lib/utils'
echarts.use([
BarChart,
LineChart,
PieChart,
GridComponent,
GraphicComponent,
TooltipComponent,
LegendComponent,
CanvasRenderer,
])
const echartsCoreModule = EChartsCoreModule as unknown as { default?: unknown }
const echartsCoreCandidate = echartsCoreModule.default ?? EChartsCoreModule
const ReactEChartsCore = (
(echartsCoreCandidate as { default?: unknown }).default ?? echartsCoreCandidate
) as ComponentType<{
echarts: typeof echarts
option: EChartsCoreOption
notMerge?: boolean
lazyUpdate?: boolean
className?: string
}>
const chartSansFont =
'"Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", Arial, sans-serif'
const chartMonoFont = 'Bahnschrift, "DIN Alternate", Consolas, monospace'
interface BottomTelemetryProps {
orders: OrderSummary[]
selectedOrder: OrderSummary
onSelectNode: (nodeId: string) => void
}
export function BottomTelemetry({
orders,
selectedOrder,
onSelectNode,
}: BottomTelemetryProps) {
const delayTop = useMemo(
() =>
[...orders]
.filter((order) =>
order.delayDays > 0 || order.status === 'delayed' || order.status === 'blocked'
)
.sort((a, b) => b.delayDays - a.delayDays || b.progress - a.progress)
.slice(0, 5),
[orders],
)
return (
<footer className="bottom-grid">
<section className="dashboard-panel event-panel">
<PanelHeader icon={<Radio />} title="风险事件" subtitle="实时刷新" />
<div className="event-table-head" aria-hidden="true">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="event-list">
{selectedOrder.events.length === 0 ? (
<TelemetryEmptyState
icon={<CheckCircle2 />}
code="RISK 00"
title="当前零风险"
detail="未检测到延期、阻塞或高风险事件"
tone="success"
/>
) : selectedOrder.events.slice(0, 5).map((event) => (
<button
type="button"
key={event.id}
className={`event-row ${riskMeta[event.riskLevel].className}`}
onClick={() => onSelectNode(event.nodeId)}
>
<span className={`event-risk-icon ${riskMeta[event.riskLevel].className}`}>
<AlertTriangle aria-hidden="true" />
</span>
<time>{event.time}</time>
<strong>{event.orderCode}</strong>
<span className="event-message" title={`${event.nodeName}${event.message}`}>
{event.nodeName}{event.message}
</span>
<span className={`event-risk-level ${riskMeta[event.riskLevel].className}`}>
{riskMeta[event.riskLevel].label}
</span>
<em>{event.handlingStatus === 'resolved' ? '已处理' : '处理中'}</em>
</button>
))}
</div>
</section>
<section className="dashboard-panel delay-panel">
<PanelHeader icon={<TimerReset />} title="延期 TOP" subtitle="按订单聚合" />
<div className="delay-table-head" aria-hidden="true">
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<div className="delay-list">
{delayTop.length === 0 ? (
<TelemetryEmptyState
icon={<Clock3 />}
code="DELAY 00"
title="当前无延期订单"
detail="全部订单处于计划时间边界内"
tone="success"
/>
) : delayTop.map((order, index) => (
<div className={`delay-row delay-rank-${index + 1}`} key={order.id}>
<span className="delay-rank">{index + 1}</span>
<div className="delay-order">
<strong>{order.code}</strong>
<span>{order.productName}</span>
</div>
<span className={`delay-days ${riskMeta[order.riskLevel].className}`}>
<strong>{order.delayDays} </strong>
</span>
<span className="delay-reason" title={order.events[0]?.message ?? '计划节拍偏差'}>
{order.events[0]?.message ?? '计划节拍偏差'}
</span>
</div>
))}
</div>
</section>
<section className="dashboard-panel chart-panel">
<PanelHeader icon={<BarChart3 />} title="产能趋势" subtitle={selectedOrder.code} />
<TrendChart order={selectedOrder} />
</section>
<section className="dashboard-panel chart-panel">
<PanelHeader icon={<AlertTriangle />} title="订单状态分布" subtitle="总订单" />
<OrderStatusDonut orders={orders} />
</section>
</footer>
)
}
function TrendChart({ order }: { order: OrderSummary }) {
const option = useMemo<EChartsCoreOption>(
() => ({
backgroundColor: 'transparent',
color: [chartPalette.progress, chartPalette.success, chartPalette.warning],
grid: { left: 38, right: 16, top: 26, bottom: 22 },
legend: {
top: 0,
right: 4,
itemWidth: 10,
itemHeight: 4,
textStyle: { color: chartPalette.muted, fontSize: 10, fontFamily: chartSansFont },
},
tooltip: {
trigger: 'axis',
backgroundColor: chartPalette.panel,
borderColor: chartPalette.grid,
textStyle: { color: chartPalette.foreground, fontFamily: chartSansFont },
},
xAxis: {
type: 'category',
data: order.trend.map((point) => point.time),
axisLabel: { color: chartPalette.muted, fontSize: 10, fontFamily: chartMonoFont, margin: 9 },
axisLine: { lineStyle: { color: chartPalette.grid } },
axisTick: { show: false },
},
series: [
{
name: '计划完成数',
type: 'bar',
barWidth: 8,
barGap: '32%',
itemStyle: {
borderRadius: [1, 1, 0, 0],
opacity: 0.92,
shadowBlur: 5,
shadowColor: 'rgba(47, 148, 255, 0.16)',
},
data: order.trend.map((point) => point.plannedQty),
},
{
name: '实际完成数',
type: 'bar',
barWidth: 8,
itemStyle: {
borderRadius: [1, 1, 0, 0],
opacity: 0.82,
shadowBlur: 5,
shadowColor: 'rgba(53, 212, 125, 0.14)',
},
data: order.trend.map((point) => point.actualQty),
},
{
name: '计划达成率',
type: 'line',
smooth: true,
symbolSize: 4,
lineStyle: { width: 1.8 },
yAxisIndex: 1,
data: order.trend.map((point) => Math.round(point.achievement)),
},
],
yAxis: [
{
type: 'value',
min: 0,
splitLine: { lineStyle: { color: chartPalette.grid, type: 'dashed', width: 1 } },
axisLabel: { color: chartPalette.muted, fontSize: 10, fontFamily: chartMonoFont },
},
{
type: 'value',
min: 0,
max: 120,
splitLine: { show: false },
axisLabel: {
color: chartPalette.muted,
fontSize: 10,
fontFamily: chartMonoFont,
formatter: '{value}%',
},
},
],
}),
[order],
)
if (order.trend.length === 0) {
return <TrendStandby order={order} />
}
return (
<ReactEChartsCore
echarts={echarts}
option={option}
notMerge
lazyUpdate
className="echart"
/>
)
}
function TrendStandby({ order }: { order: OrderSummary }) {
return (
<div className="trend-standby">
<div className="trend-standby-signal" aria-hidden="true">
<span /><span /><span /><span /><span />
</div>
<div className="trend-standby-copy">
<DatabaseZap aria-hidden="true" />
<div>
<strong></strong>
<span> {order.code} </span>
</div>
</div>
<div className="trend-standby-meta">
<span><Activity /></span><strong>STANDBY</strong>
<span></span><strong>{order.trend.length}</strong>
</div>
</div>
)
}
function TelemetryEmptyState({
icon,
code,
title,
detail,
tone,
}: {
icon: React.ReactNode
code: string
title: string
detail: string
tone: 'success' | 'muted'
}) {
return (
<div className={`telemetry-empty telemetry-empty-${tone}`}>
<div className="telemetry-empty-icon" aria-hidden="true">{icon}</div>
<div className="telemetry-empty-copy">
<span>{code}</span>
<strong>{title}</strong>
<small>{detail}</small>
</div>
<div className="telemetry-empty-line" aria-hidden="true"><i /></div>
</div>
)
}
function OrderStatusDonut({ orders }: { orders: OrderSummary[] }) {
const option = useMemo<EChartsCoreOption>(() => {
const data = buildOrderStatusDistribution(orders)
const total = Math.max(orders.length, 1)
return {
backgroundColor: 'transparent',
color: [
chartPalette.success,
chartPalette.progress,
chartPalette.critical,
chartPalette.neutral,
],
tooltip: {
trigger: 'item',
backgroundColor: chartPalette.panel,
borderColor: chartPalette.grid,
textStyle: { color: chartPalette.foreground, fontFamily: chartSansFont },
},
legend: {
right: 6,
top: 34,
orient: 'vertical',
itemWidth: 8,
itemHeight: 8,
textStyle: {
color: chartPalette.muted,
fontSize: 10,
fontFamily: chartMonoFont,
},
formatter: (name: string) => {
const item = data.find((entry) => entry.name === name)
const value = item?.value ?? 0
return `${name} ${formatQuantity(value)} (${formatPercent((value / total) * 100)})`
},
},
series: [
{
name: '订单状态分布',
type: 'pie',
radius: ['45%', '68%'],
center: ['38%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderColor: '#061323',
borderWidth: 1,
},
label: { show: false },
labelLine: { show: false },
data,
},
],
}
}, [orders])
return (
<div className="order-status-donut">
<ReactEChartsCore
echarts={echarts}
option={option}
notMerge
lazyUpdate
className="echart"
/>
<div className="order-status-center" aria-hidden="true">
<strong>{formatQuantity(orders.length)}</strong>
<span></span>
</div>
</div>
)
}
function buildOrderStatusDistribution(orders: OrderSummary[]) {
const buckets = [
{ name: '按期', value: 0 },
{ name: '进行中', value: 0 },
{ name: '延期', value: 0 },
{ name: '未开始', value: 0 },
]
for (const order of orders) {
if (order.status === 'waiting') {
buckets[3].value += 1
} else if (order.delayDays > 0 || order.status === 'delayed' || order.status === 'blocked') {
buckets[2].value += 1
} else if (order.status === 'in_progress') {
buckets[1].value += 1
} else {
buckets[0].value += 1
}
}
return buckets
}
function PanelHeader({
icon,
title,
subtitle,
}: {
icon: React.ReactNode
title: string
subtitle: string
}) {
return (
<div className="mini-panel-head">
<span aria-hidden="true">{icon}</span>
<div>
<h2>{title}</h2>
<p>{subtitle}</p>
</div>
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
import { createMockOrders } from '@/data/mock-data'
import type { OrderSummary } from '@/types'
import {
connectDashboardRealtime,
fetchDashboardOrders,
reconcileDashboardSnapshot,
upsertOrderSnapshot,
type DashboardConnectionState,
} from '@/lib/dashboard-api'
import {
defaultExpandedIds,
defaultSelectedNodeId,
findNode,
flattenTree,
} from '@/lib/production'
import { formatTime } from '@/lib/utils'
import { BottomTelemetry } from './BottomTelemetry'
import { DetailPanel } from './DetailPanel'
import { HydroAtmosphere } from './HydroAtmosphere'
import { OrderSidebar } from './OrderSidebar'
import { ProductionFlow } from './ProductionFlow'
import { TopMetrics } from './TopMetrics'
export function Dashboard() {
const [orders, setOrders] = useState<OrderSummary[]>(() => createMockOrders())
const [selectedOrderId, setSelectedOrderId] = useState(() => orders[0]?.id ?? '')
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
() => (orders[0] ? defaultSelectedNodeId(orders[0].root) : null),
)
const [expandedIds, setExpandedIds] = useState<Set<string>>(
() => new Set(orders[0] ? defaultExpandedIds(orders[0].root) : []),
)
const [search, setSearch] = useState('')
const deferredSearch = useDeferredValue(search)
const [lastUpdated, setLastUpdated] = useState(() => formatTime())
const [connectionState, setConnectionState] = useState<DashboardConnectionState>('connecting')
const refreshSequenceRef = useRef(0)
const realtimeRevisionRef = useRef(0)
const realtimeOrderRevisionsRef = useRef(new Map<string, number>())
const refreshOrders = useCallback(async (signal?: AbortSignal) => {
const requestId = ++refreshSequenceRef.current
const requestRevision = realtimeRevisionRef.current
try {
const latestOrders = await fetchDashboardOrders(undefined, signal)
if (requestId !== refreshSequenceRef.current) {
return
}
const realtimeOrderIds = new Set(
[...realtimeOrderRevisionsRef.current]
.filter(([, revision]) => revision > requestRevision)
.map(([orderId]) => orderId),
)
setOrders((current) => reconcileDashboardSnapshot(
latestOrders,
current,
realtimeOrderIds,
))
setLastUpdated(formatTime())
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return
}
setConnectionState('offline')
console.warn('Dashboard API is unavailable; keeping the last local snapshot.', error)
}
}, [])
useEffect(() => {
const controller = new AbortController()
let disposed = false
let connection: ReturnType<typeof connectDashboardRealtime> | null = null
void refreshOrders(controller.signal)
connection = connectDashboardRealtime({
onOrderUpdated: (updatedOrder) => {
if (disposed) {
return
}
const revision = ++realtimeRevisionRef.current
realtimeOrderRevisionsRef.current.set(updatedOrder.id, revision)
setOrders((current) => upsertOrderSnapshot(current, updatedOrder))
setLastUpdated(formatTime())
},
onConnectionStateChange: (state) => {
if (!disposed) {
setConnectionState(state)
if (state === 'connected') {
void refreshOrders(controller.signal)
}
}
},
})
const fallbackPoll = window.setInterval(() => {
void refreshOrders(controller.signal)
}, 30_000)
return () => {
disposed = true
controller.abort()
window.clearInterval(fallbackPoll)
void connection?.stop()
}
}, [refreshOrders])
const selectedOrder = useMemo(
() => orders.find((order) => order.id === selectedOrderId) ?? orders[0],
[orders, selectedOrderId],
)
const selectedNode = useMemo(
() => findNode(selectedOrder?.root, selectedNodeId),
[selectedOrder, selectedNodeId],
)
useEffect(() => {
if (orders.length > 0 && !orders.some((order) => order.id === selectedOrderId)) {
const firstOrder = orders[0]
setSelectedOrderId(firstOrder.id)
setSelectedNodeId(defaultSelectedNodeId(firstOrder.root))
setExpandedIds(defaultExpandedIds(firstOrder.root))
}
}, [orders, selectedOrderId])
useEffect(() => {
if (selectedOrder && !selectedNode) {
setSelectedNodeId(selectedOrder.root.id)
}
}, [selectedOrder, selectedNode])
const visibleOrders = useMemo(() => {
const keyword = deferredSearch.trim().toLowerCase()
return orders.filter((order) => {
if (keyword.length === 0) {
return true
}
return [order.code, order.productName, order.lineName, order.owner]
.join(' ')
.toLowerCase()
.includes(keyword)
})
}, [orders, deferredSearch])
const handleSelectOrder = useCallback(
(orderId: string) => {
const nextOrder = orders.find((order) => order.id === orderId)
if (!nextOrder) {
return
}
setSelectedOrderId(orderId)
setSelectedNodeId(defaultSelectedNodeId(nextOrder.root))
setExpandedIds(defaultExpandedIds(nextOrder.root))
},
[orders],
)
const handleToggleNode = useCallback((nodeId: string) => {
setExpandedIds((current) => {
const next = new Set(current)
if (next.has(nodeId)) {
next.delete(nodeId)
} else {
next.add(nodeId)
}
return next
})
}, [])
const handleExpandAll = useCallback(() => {
if (!selectedOrder) {
return
}
setExpandedIds(new Set(flattenTree(selectedOrder.root).map((node) => node.id)))
}, [selectedOrder])
const handleCollapseAll = useCallback(() => {
if (!selectedOrder) {
return
}
setExpandedIds(new Set([selectedOrder.root.id]))
}, [selectedOrder])
if (!selectedOrder) {
return null
}
return (
<div className="dashboard-shell" data-connection-state={connectionState}>
<HydroAtmosphere />
<TopMetrics
orders={orders}
lastUpdated={lastUpdated}
connectionState={connectionState}
onRefresh={() => void refreshOrders()}
/>
<main className="dashboard-main">
<OrderSidebar
orders={visibleOrders}
selectedOrderId={selectedOrder.id}
search={search}
activeOrder={selectedOrder}
showProjectDossier={orders.length === 1}
onSearchChange={setSearch}
onSelectOrder={handleSelectOrder}
/>
<ProductionFlow
root={selectedOrder.root}
selectedNodeId={selectedNodeId}
expandedIds={expandedIds}
onSelectNode={setSelectedNodeId}
onToggleNode={handleToggleNode}
onExpandAll={handleExpandAll}
onCollapseAll={handleCollapseAll}
/>
<DetailPanel order={selectedOrder} node={selectedNode} />
</main>
<BottomTelemetry
orders={orders}
selectedOrder={selectedOrder}
onSelectNode={setSelectedNodeId}
/>
</div>
)
}
+148
View File
@@ -0,0 +1,148 @@
import {
CircleGauge,
FileSpreadsheet,
ListChecks,
PackageCheck,
} from 'lucide-react'
import type { OrderSummary, ProductionNode } from '@/types'
import { riskMeta, statusMeta } from '@/lib/production'
import { formatQuantity } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { RingProgress, TechVisual } from './TechVisual'
interface DetailPanelProps {
order: OrderSummary
node: ProductionNode | undefined
}
export function DetailPanel({ order, node }: DetailPanelProps) {
if (!node) {
return (
<aside className="dashboard-panel detail-panel">
<div className="empty-state detail-empty">
<PackageCheck aria-hidden="true" />
<strong></strong>
<span></span>
</div>
</aside>
)
}
const riskTone =
node.riskLevel === 'critical'
? 'critical'
: node.riskLevel === 'warning'
? 'warning'
: 'success'
return (
<aside className="dashboard-panel detail-panel">
<div className="panel-title detail-title">
<div className="panel-title-icon" aria-hidden="true">
<CircleGauge />
</div>
<div>
<h2></h2>
<p>{order.code}</p>
</div>
</div>
<section className="detail-reference">
<div className="detail-blueprint-mark" aria-hidden="true"><span /><i /></div>
<div className="detail-reference-head">
<span className="detail-accent" />
<strong>{node.operationCode || node.code}</strong>
<h3>{node.name}</h3>
<Badge tone={node.status === 'in_progress' ? 'info' : riskTone}>
{statusMeta[node.status].label}
</Badge>
</div>
<div className="detail-reference-body">
<div className="detail-image-slot">
<TechVisual visualKey={node.visualKey} className="detail-visual" label={node.name} />
</div>
<div className="detail-facts">
<FactRow label="所属物料" value={node.materialCode} />
<FactRow label="计划数量" value={formatQuantity(node.requiredQty)} />
<FactRow label="已完成量" value={formatQuantity(node.completedQty)} />
<FactRow
label="不良数量"
value={`${formatQuantity(node.defectQty)} (${((node.defectQty / Math.max(node.completedQty, 1)) * 100).toFixed(1)}%)`}
danger={node.defectQty > 0}
/>
<FactRow label="计划开始" value={formatPlannedTime(node.plannedStart, '08:00')} />
<FactRow label="计划完成" value={formatPlannedTime(node.plannedEnd, '18:00')} />
<FactRow label="实际完成" value={node.actualEnd || '--'} />
<FactRow label="负责人" value={node.owner || '--'} />
<FactRow label="产线" value={node.stationName || node.vendor || '--'} />
</div>
<RingProgress value={node.progress} tone={riskTone} compact={false} className="detail-ring" />
</div>
</section>
<section className="detail-section detail-bom-box">
<div className="detail-section-head">
<FileSpreadsheet aria-hidden="true" />
<span>BOM </span>
<em>EXCEL SOURCE</em>
</div>
<div className="detail-record-grid">
<FactRow label="图号/编码" value={node.materialCode || node.code || '--'} />
<FactRow label="型号规格" value={node.specification || '--'} />
<FactRow label="材质分类" value={node.material || '--'} />
<FactRow label="单件重量" value={formatWeight(node.unitWeightText, node.unitWeight)} />
<FactRow label="总重量" value={formatWeight(node.totalWeightText, node.totalWeight)} />
<FactRow label="来源位置" value={formatSource(node)} />
</div>
</section>
<section className="detail-section detail-risk-box">
<div className="detail-section-head">
<ListChecks aria-hidden="true" />
<span></span>
</div>
<FactRow label="当前状态" value={statusMeta[node.status].label} strongClass={statusMeta[node.status].className} />
<FactRow label="风险等级" value={riskMeta[node.riskLevel].label} strongClass={riskMeta[node.riskLevel].className} />
<FactRow label="风险原因" value={node.delayReason || '当前节点节拍正常,无需额外处理。'} />
<FactRow label="处理措施" value={node.delayDays > 0 ? '已通知设备组进行设备调优' : '持续监控节拍与良率'} />
<FactRow label="预计完成" value={node.plannedEnd ? (node.delayDays > 0 ? `${node.plannedEnd} 20:30` : node.plannedEnd) : '--'} />
<FactRow label="预计延误" value={node.delayDays > 0 ? `${node.delayDays}` : '--'} danger={node.delayDays > 0} />
</section>
</aside>
)
}
function formatPlannedTime(date: string, time: string) {
return date ? `${date} ${time}` : '--'
}
function formatWeight(text: string | undefined, value: number | null | undefined) {
const source = text?.trim() || (value == null ? '' : value.toLocaleString('zh-CN'))
return source ? `${source} kg` : '--'
}
function formatSource(node: ProductionNode) {
if (!node.sourceSheet) {
return '--'
}
return node.sourceRow ? `${node.sourceSheet} · 第 ${node.sourceRow}` : node.sourceSheet
}
function FactRow({
label,
value,
danger,
strongClass,
}: {
label: string
value: string
danger?: boolean
strongClass?: string
}) {
return (
<div className="fact-row">
<span>{label}</span>
<strong className={`${danger ? 'risk-critical' : ''} ${strongClass ?? ''}`}>{value}</strong>
</div>
)
}
@@ -0,0 +1,27 @@
const FLOW_TRACKS = [0, 1, 2, 3, 4, 5]
const SCALE_TICKS = Array.from({ length: 18 }, (_, index) => index)
export function HydroAtmosphere() {
return (
<div className="hydro-atmosphere" aria-hidden="true">
<div className="hydro-depth hydro-depth-a" />
<div className="hydro-depth hydro-depth-b" />
<div className="hydro-caustics" />
<div className="hydro-pressure-waves">
<span />
<span />
</div>
<div className="hydro-contours" />
<div className="hydro-flow-field">
{FLOW_TRACKS.map((track) => (
<span key={track} />
))}
</div>
<div className="hydro-gate-scale">
{SCALE_TICKS.map((tick) => <i key={tick} />)}
</div>
<div className="hydro-axis hydro-axis-top" />
<div className="hydro-axis hydro-axis-bottom" />
</div>
)
}
+218
View File
@@ -0,0 +1,218 @@
import { useMemo } from 'react'
import {
Boxes,
ChevronDown,
ChevronLeft,
ChevronRight,
DatabaseZap,
GitBranch,
Layers3,
PackageOpen,
Search,
ShieldAlert,
SlidersHorizontal,
} from 'lucide-react'
import type { OrderSummary, RiskLevel } from '@/types'
import { calculateOrderTreeStats, riskMeta, statusMeta } from '@/lib/production'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { RingProgress, TechVisual } from './TechVisual'
interface OrderSidebarProps {
orders: OrderSummary[]
activeOrder: OrderSummary
showProjectDossier: boolean
selectedOrderId: string
search: string
onSearchChange: (value: string) => void
onSelectOrder: (id: string) => void
}
export function OrderSidebar({
orders,
activeOrder,
showProjectDossier,
selectedOrderId,
search,
onSearchChange,
onSelectOrder,
}: OrderSidebarProps) {
return (
<aside className={`dashboard-panel sidebar-panel ${showProjectDossier ? 'has-project-dossier' : ''}`}>
<div className="sidebar-heading">
<PanelTitle icon={<Boxes />} title="订单总览" subtitle={`${orders.length} 个订单`} />
<button type="button" className="icon-tool-button" aria-label="筛选订单">
<SlidersHorizontal />
<span></span>
</button>
</div>
<div className="sidebar-filter-card">
<div className="select-row">
<button type="button" className="select-chip">
<span></span>
<ChevronDown />
</button>
<button type="button" className="select-chip">
<span>线</span>
<ChevronDown />
</button>
</div>
<label className="search-field">
<Search aria-hidden="true" />
<Input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="搜索订单号/产品"
aria-label="搜索订单"
/>
</label>
</div>
<div className="order-list" aria-label="生产订单列表">
{orders.map((order) => (
<button
type="button"
key={order.id}
className={`order-row ${selectedOrderId === order.id ? 'is-selected' : ''}`}
onClick={() => onSelectOrder(order.id)}
>
<TechVisual
visualKey={order.thumbnailKey}
className="order-thumb"
label={order.productName}
/>
<div className="order-row-main">
<div className="order-row-titleline">
<strong>{order.code}</strong>
<Badge tone={riskTone(order.riskLevel)}>
{riskMeta[order.riskLevel].label}
</Badge>
</div>
<span className="order-row-product">{order.productName}</span>
<div className="order-row-meta-line">
<span></span>
<strong title={order.root.materialCode}>{order.root.materialCode || '--'}</strong>
</div>
<div className="order-row-schedule">
<span>{order.plannedEnd || '--'}</span>
<span className={statusMeta[order.status].className}>
{order.delayDays > 0 ? `延期 ${order.delayDays}` : statusMeta[order.status].label}
</span>
</div>
</div>
<RingProgress
value={order.progress}
tone={riskTone(order.riskLevel)}
compact
className="order-row-ring"
/>
</button>
))}
{orders.length === 0 ? (
<div className="empty-state">
<ShieldAlert aria-hidden="true" />
<strong></strong>
<span></span>
</div>
) : null}
</div>
{showProjectDossier ? <ProjectDossier order={activeOrder} /> : (
<div className="sidebar-pagination" aria-label="订单分页">
<button type="button" aria-label="上一页"><ChevronLeft /></button>
<button type="button" className="is-active">1</button>
<button type="button">2</button>
<button type="button">3</button>
<button type="button">4</button>
<button type="button">5</button>
<span>...</span>
<button type="button">22</button>
<button type="button" aria-label="下一页"><ChevronRight /></button>
</div>
)}
</aside>
)
}
function ProjectDossier({ order }: { order: OrderSummary }) {
const stats = useMemo(() => calculateOrderTreeStats(order.root), [order.root])
const distribution = [
{ label: '部件', value: stats.partNodes, className: 'is-part' },
{ label: '组件', value: stats.componentNodes, className: 'is-component' },
{ label: '物料', value: stats.materialNodes, className: 'is-material' },
]
return (
<section className="project-dossier" aria-label="项目 BOM 档案">
<div className="project-dossier-head">
<div>
<span><DatabaseZap aria-hidden="true" /> BOM </span>
<strong>{order.root.materialCode || '未设置定额编号'}</strong>
</div>
<em>LIVE DATA</em>
</div>
<div className="project-node-total">
<span>STRUCTURE NODES</span>
<strong>{stats.totalNodes.toLocaleString('zh-CN')}</strong>
<small></small>
</div>
<div className="project-stat-grid">
<div><Layers3 /><span></span><strong>{stats.partNodes}</strong></div>
<div><GitBranch /><span>BOM </span><strong>{stats.maxDepth} </strong></div>
<div><PackageOpen /><span></span><strong>{stats.componentNodes}</strong></div>
<div><Boxes /><span></span><strong>{stats.materialNodes}</strong></div>
</div>
<div className="project-distribution" aria-label="BOM 类型分布">
<div className="project-distribution-rail">
{distribution.map((item) => (
<i
key={item.label}
className={item.className}
style={{ flexGrow: Math.max(item.value, 1) }}
/>
))}
</div>
<div className="project-distribution-legend">
{distribution.map((item) => (
<span key={item.label} className={item.className}>
<i />{item.label}<strong>{item.value}</strong>
</span>
))}
</div>
</div>
</section>
)
}
function riskTone(riskLevel: RiskLevel) {
if (riskLevel === 'critical') {
return 'critical'
}
if (riskLevel === 'warning') {
return 'warning'
}
return 'success'
}
function PanelTitle({
icon,
title,
subtitle,
}: {
icon: React.ReactNode
title: string
subtitle: string
}) {
return (
<div className="panel-title">
<div className="panel-title-icon" aria-hidden="true">
{icon}
</div>
<div>
<h2>{title}</h2>
<p>{subtitle}</p>
</div>
</div>
)
}
+607
View File
@@ -0,0 +1,607 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import ReactFlow, {
Background,
type CoordinateExtent,
type Edge,
type EdgeTypes,
type Node,
type NodeTypes,
type ReactFlowInstance,
} from 'reactflow'
import {
CircleMinus,
CirclePlus,
Maximize2,
ScanSearch,
ZoomIn,
ZoomOut,
} from 'lucide-react'
import 'reactflow/dist/style.css'
import type { ProductionNode } from '@/types'
import { findNodePath, getPathEdgeIds, riskMeta } from '@/lib/production'
import { ProductionOrthogonalEdge, type OrthogonalEdgeData } from './ProductionOrthogonalEdge'
import { ProductionTaskNode, type FlowNodeData } from './ProductionTaskNode'
const nodeTypes: NodeTypes = {
productionTask: ProductionTaskNode,
}
const edgeTypes: EdgeTypes = {
productionOrthogonal: ProductionOrthogonalEdge,
}
interface ProductionFlowProps {
root: ProductionNode
selectedNodeId: string | null
expandedIds: Set<string>
onSelectNode: (nodeId: string) => void
onToggleNode: (nodeId: string) => void
onExpandAll: () => void
onCollapseAll: () => void
}
export function ProductionFlow({
root,
selectedNodeId,
expandedIds,
onSelectNode,
onToggleNode,
onExpandAll,
onCollapseAll,
}: ProductionFlowProps) {
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance | null>(null)
const { nodes, edges, horizontal, layoutBounds, layoutKey } = useMemo(
() => buildFlowElements(root, expandedIds, selectedNodeId, onToggleNode),
[root, expandedIds, selectedNodeId, onToggleNode],
)
const translateExtent = useMemo<CoordinateExtent>(() => [
[-160, -140],
[layoutBounds.width + 160, layoutBounds.height + 140],
], [layoutBounds.height, layoutBounds.width])
const fitTree = useCallback((duration = 260) => {
if (!flowInstance) {
return
}
window.requestAnimationFrame(() => {
flowInstance.fitView({
padding: horizontal ? 0.06 : 0.08,
duration,
minZoom: horizontal ? 0.72 : 0.58,
maxZoom: 1,
})
})
}, [horizontal, flowInstance])
useEffect(() => {
fitTree(260)
}, [fitTree, layoutKey])
const enterFullscreen = () => {
const panel = document.querySelector('.flow-panel')
if (document.fullscreenElement) {
void document.exitFullscreen()
return
}
if (panel instanceof HTMLElement && panel.requestFullscreen) {
void panel.requestFullscreen()
}
}
return (
<section className={`dashboard-panel flow-panel ${horizontal ? 'is-horizontal-tree' : ''}`} aria-label="BOM 工序树">
<div className="flow-panel-head">
<div>
<h2>BOM </h2>
</div>
<div className="flow-head-actions">
<div className="flow-legend" aria-label="状态图例">
<span className="legend-item legend-success"></span>
<span className="legend-item legend-info"></span>
<span className="legend-item legend-muted"></span>
<span className="legend-item legend-critical"></span>
<span className="legend-item legend-warning"></span>
</div>
<div className="flow-toolbar" aria-label="树图工具栏">
<button type="button" onClick={onExpandAll}>
<CirclePlus />
<span></span>
</button>
<button type="button" onClick={onCollapseAll}>
<CircleMinus />
<span></span>
</button>
<button type="button" onClick={() => fitTree(240)}>
<ScanSearch />
<span></span>
</button>
<button type="button" aria-label="放大" onClick={() => flowInstance?.zoomIn()}>
<ZoomIn />
</button>
<button type="button" aria-label="缩小" onClick={() => flowInstance?.zoomOut()}>
<ZoomOut />
</button>
<button type="button" aria-label="全屏预览" onClick={enterFullscreen}>
<Maximize2 />
</button>
</div>
</div>
</div>
<div className="flow-canvas">
<div className="flow-atmosphere" aria-hidden="true">
<div className="flow-current-bands">
<span />
<span />
<span />
</div>
<div className="flow-topology-sweep" />
<div className="flow-pressure-rings" />
<div className="flow-corner-lock" />
</div>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultViewport={{ x: 0, y: 0, zoom: 1 }}
minZoom={horizontal ? 0.48 : 0.52}
maxZoom={1.18}
translateExtent={translateExtent}
nodesDraggable={false}
panOnDrag
panOnScroll={false}
zoomOnScroll={false}
zoomOnPinch
zoomOnDoubleClick={false}
proOptions={{ hideAttribution: true }}
onInit={setFlowInstance}
onNodeClick={(_, node) => onSelectNode(node.id)}
>
<Background color="var(--flow-grid)" gap={28} size={1} />
</ReactFlow>
</div>
</section>
)
}
type LayoutMode = 'standard' | 'horizontal'
interface NodeSize {
width: number
height: number
}
interface LayoutConfig {
paddingX: number
paddingY: number
horizontalGap: number
verticalGap: number
stackGap: number
sizes: Record<ProductionNode['nodeType'], NodeSize>
}
interface MeasuredLayout {
node: ProductionNode
depth: number
width: number
height: number
nodeWidth: number
nodeHeight: number
nodeX: number
nodeY: number
children: Array<{
layout: MeasuredLayout
x: number
y: number
}>
}
interface PositionedLayoutNode {
node: ProductionNode
x: number
y: number
width: number
height: number
depth: number
expanded: boolean
}
interface PositionedLayoutEdge {
parent: ProductionNode
child: ProductionNode
busY: number
mode?: OrthogonalEdgeData['mode']
trunkX?: number
}
const HORIZONTAL_NODE_THRESHOLD = 22
const layoutConfigs: Record<LayoutMode, LayoutConfig> = {
standard: {
paddingX: 44,
paddingY: 28,
horizontalGap: 42,
verticalGap: 58,
stackGap: 16,
sizes: {
order: { width: 480, height: 104 },
part: { width: 220, height: 96 },
component: { width: 220, height: 96 },
process: { width: 198, height: 96 },
material: { width: 198, height: 96 },
},
},
horizontal: {
paddingX: 72,
paddingY: 56,
horizontalGap: 96,
verticalGap: 0,
stackGap: 14,
sizes: {
order: { width: 460, height: 92 },
part: { width: 220, height: 88 },
component: { width: 220, height: 76 },
process: { width: 190, height: 38 },
material: { width: 190, height: 38 },
},
},
}
function buildFlowElements(
root: ProductionNode,
expandedIds: Set<string>,
selectedNodeId: string | null,
onToggleNode: (nodeId: string) => void,
) {
const selectedPathEdgeIds = getPathEdgeIds(findNodePath(root, selectedNodeId))
const visibleNodeCount = countVisibleNodes(root, expandedIds)
const mode: LayoutMode = visibleNodeCount > HORIZONTAL_NODE_THRESHOLD ? 'horizontal' : 'standard'
const config = layoutConfigs[mode]
const layout = mode === 'horizontal'
? buildHorizontalLayout(root, expandedIds, config)
: buildStandardLayout(root, expandedIds, config)
const horizontal = mode === 'horizontal'
const nodes: Node<FlowNodeData>[] = layout.positionedNodes.map((positioned) => ({
id: positioned.node.id,
type: 'productionTask',
position: { x: positioned.x, y: positioned.y },
data: {
node: positioned.node,
selected: selectedNodeId === positioned.node.id,
expanded: positioned.expanded,
hasChildren: Boolean(positioned.node.children?.length),
dense: horizontal,
horizontalCompact: horizontal,
onToggle: onToggleNode,
},
}))
const edges: Edge<OrthogonalEdgeData>[] = layout.positionedEdges.map(({ parent, child, busY, mode: edgeMode, trunkX }) => {
const edgeId = `${parent.id}-${child.id}`
return {
id: edgeId,
source: parent.id,
target: child.id,
type: 'productionOrthogonal',
animated: child.status === 'in_progress' || child.riskLevel === 'critical',
className: `flow-edge edge-${riskMeta[child.riskLevel].className} edge-${child.riskLevel}`,
data: {
tier: edgeTier(child),
riskLevel: child.riskLevel,
status: child.status,
busY,
mode: edgeMode,
trunkX,
selectedPath: selectedPathEdgeIds.has(edgeId),
},
}
})
return {
nodes,
edges,
horizontal,
layoutBounds: layout.layoutBounds,
layoutKey: `${mode}:${layout.positionedNodes.map(({ node }) => node.id).join('|')}`,
}
}
function buildStandardLayout(
root: ProductionNode,
expandedIds: Set<string>,
config: LayoutConfig,
) {
const measuredLayout = measureSubtree(root, expandedIds, config, 0)
const positionedNodes: PositionedLayoutNode[] = []
const positionedEdges: PositionedLayoutEdge[] = []
flattenMeasuredLayout(
measuredLayout,
config.paddingX,
config.paddingY,
expandedIds,
positionedNodes,
positionedEdges,
)
return {
positionedNodes,
positionedEdges,
layoutBounds: {
width: Math.ceil(measuredLayout.width + config.paddingX * 2),
height: Math.ceil(measuredLayout.height + config.paddingY * 2),
},
}
}
function buildHorizontalLayout(
root: ProductionNode,
expandedIds: Set<string>,
config: LayoutConfig,
) {
const measuredLayout = measureHorizontalSubtree(root, expandedIds, config, 0)
const positionedNodes: PositionedLayoutNode[] = []
const positionedEdges: PositionedLayoutEdge[] = []
flattenHorizontalLayout(
measuredLayout,
config.paddingX,
config.paddingY,
expandedIds,
positionedNodes,
positionedEdges,
)
return {
positionedNodes,
positionedEdges,
layoutBounds: {
width: Math.ceil(measuredLayout.width + config.paddingX * 2),
height: Math.ceil(measuredLayout.height + config.paddingY * 2),
},
}
}
function countVisibleNodes(node: ProductionNode, expandedIds: Set<string>): number {
if (!isNodeExpanded(node, expandedIds)) {
return 1
}
return 1 + (node.children ?? []).reduce(
(total, child) => total + countVisibleNodes(child, expandedIds),
0,
)
}
function measureSubtree(
node: ProductionNode,
expandedIds: Set<string>,
config: LayoutConfig,
depth: number,
): MeasuredLayout {
const size = config.sizes[node.nodeType]
const childLayouts = getVisibleChildren(node, expandedIds).map((child) =>
measureSubtree(child, expandedIds, config, depth + 1),
)
if (childLayouts.length === 0) {
return {
node,
depth,
width: size.width,
height: size.height,
nodeWidth: size.width,
nodeHeight: size.height,
nodeX: 0,
nodeY: 0,
children: [],
}
}
const childrenWidth =
childLayouts.reduce((total, child) => total + child.width, 0) +
config.horizontalGap * (childLayouts.length - 1)
const width = Math.max(size.width, childrenWidth)
const startX = (width - childrenWidth) / 2
const childY = size.height + config.verticalGap
let cursorX = startX
const children = childLayouts.map((layout) => {
const child = { layout, x: cursorX, y: childY }
cursorX += layout.width + config.horizontalGap
return child
})
return {
node,
depth,
width,
height: size.height + config.verticalGap + Math.max(...childLayouts.map((child) => child.height)),
nodeWidth: size.width,
nodeHeight: size.height,
nodeX: (width - size.width) / 2,
nodeY: 0,
children,
}
}
function measureHorizontalSubtree(
node: ProductionNode,
expandedIds: Set<string>,
config: LayoutConfig,
depth: number,
): MeasuredLayout {
const size = config.sizes[node.nodeType]
const childLayouts = getVisibleChildren(node, expandedIds).map((child) =>
measureHorizontalSubtree(child, expandedIds, config, depth + 1),
)
if (childLayouts.length === 0) {
return {
node,
depth,
width: size.width,
height: size.height,
nodeWidth: size.width,
nodeHeight: size.height,
nodeX: 0,
nodeY: 0,
children: [],
}
}
const childrenHeight =
childLayouts.reduce((total, child) => total + child.height, 0) +
config.stackGap * (childLayouts.length - 1)
const width =
size.width + config.horizontalGap + Math.max(...childLayouts.map((child) => child.width))
const height = Math.max(size.height, childrenHeight)
const startY = (height - childrenHeight) / 2
const childX = size.width + config.horizontalGap
let cursorY = startY
const children = childLayouts.map((layout) => {
const child = { layout, x: childX, y: cursorY }
cursorY += layout.height + config.stackGap
return child
})
return {
node,
depth,
width,
height,
nodeWidth: size.width,
nodeHeight: size.height,
nodeX: 0,
nodeY: (height - size.height) / 2,
children,
}
}
function flattenMeasuredLayout(
layout: MeasuredLayout,
offsetX: number,
offsetY: number,
expandedIds: Set<string>,
nodes: PositionedLayoutNode[],
edges: PositionedLayoutEdge[],
parent?: PositionedLayoutNode,
) {
const current: PositionedLayoutNode = {
node: layout.node,
x: round(offsetX + layout.nodeX),
y: round(offsetY + layout.nodeY),
width: layout.nodeWidth,
height: layout.nodeHeight,
depth: layout.depth,
expanded: isNodeExpanded(layout.node, expandedIds),
}
nodes.push(current)
if (parent) {
edges.push({
parent: parent.node,
child: current.node,
busY: calculateBusY(parent, current),
})
}
for (const child of layout.children) {
flattenMeasuredLayout(
child.layout,
offsetX + child.x,
offsetY + child.y,
expandedIds,
nodes,
edges,
current,
)
}
}
function flattenHorizontalLayout(
layout: MeasuredLayout,
offsetX: number,
offsetY: number,
expandedIds: Set<string>,
nodes: PositionedLayoutNode[],
edges: PositionedLayoutEdge[],
parent?: PositionedLayoutNode,
) {
const current: PositionedLayoutNode = {
node: layout.node,
x: round(offsetX + layout.nodeX),
y: round(offsetY + layout.nodeY),
width: layout.nodeWidth,
height: layout.nodeHeight,
depth: layout.depth,
expanded: isNodeExpanded(layout.node, expandedIds),
}
nodes.push(current)
if (parent) {
edges.push({
parent: parent.node,
child: current.node,
busY: calculateBusY(parent, current),
mode: 'horizontal',
trunkX: calculateTrunkX(parent, current),
})
}
for (const child of layout.children) {
flattenHorizontalLayout(
child.layout,
offsetX + child.x,
offsetY + child.y,
expandedIds,
nodes,
edges,
current,
)
}
}
function getVisibleChildren(node: ProductionNode, expandedIds: Set<string>) {
if (!isNodeExpanded(node, expandedIds)) {
return []
}
return node.children ?? []
}
function isNodeExpanded(node: ProductionNode, expandedIds: Set<string>) {
return node.parentId === null || expandedIds.has(node.id)
}
function calculateBusY(parent: PositionedLayoutNode, child: PositionedLayoutNode) {
const parentBottom = parent.y + parent.height
const childTop = child.y
const gap = Math.max(18, childTop - parentBottom)
return round(parentBottom + Math.max(14, gap * 0.44))
}
function calculateTrunkX(parent: PositionedLayoutNode, child: PositionedLayoutNode) {
const parentRight = parent.x + parent.width
const childLeft = child.x
const gap = Math.max(28, childLeft - parentRight)
return round(parentRight + gap * 0.46)
}
function edgeTier(node: ProductionNode): OrthogonalEdgeData['tier'] {
if (node.nodeType === 'process' || node.nodeType === 'material') {
return 'process'
}
if (node.nodeType === 'component') {
return 'component'
}
return 'part'
}
function round(value: number) {
return Number(value.toFixed(1))
}
@@ -0,0 +1,90 @@
import { memo } from 'react'
import { type EdgeProps } from 'reactflow'
import type { ProductionStatus, RiskLevel } from '@/types'
export interface OrthogonalEdgeData {
tier: 'part' | 'component' | 'process'
riskLevel: RiskLevel
status: ProductionStatus
busY: number
mode?: 'orthogonal' | 'horizontal'
trunkX?: number
selectedPath: boolean
}
export const ProductionOrthogonalEdge = memo(function ProductionOrthogonalEdge({
id,
sourceX,
sourceY,
targetX,
targetY,
data,
}: EdgeProps<OrthogonalEdgeData>) {
const tier = data?.tier ?? 'part'
const riskLevel = data?.riskLevel ?? 'normal'
const status = data?.status ?? 'waiting'
const mode = data?.mode ?? 'orthogonal'
const fallbackGap = Math.max(18, targetY - sourceY)
const busY = data?.busY ?? Math.round(sourceY + fallbackGap * tierRatio[tier])
const path = mode === 'horizontal'
? horizontalPath(sourceX, sourceY, targetX, targetY, data?.trunkX)
: orthogonalPath(sourceX, sourceY, targetX, targetY, busY)
const className = [
'ortho-edge',
`edge-mode-${mode}`,
`edge-${riskLevel}`,
`edge-tier-${tier}`,
status === 'in_progress' ? 'is-active' : '',
data?.selectedPath ? 'is-selected-path' : '',
].filter(Boolean).join(' ')
const trunkX = data?.trunkX ?? round(sourceX + (targetX - sourceX) * 0.52)
return (
<g className={className}>
<path id={`${id}-hit`} d={path} className="ortho-edge-hit" />
<path id={id} d={path} className="react-flow__edge-path ortho-edge-path" />
{data?.selectedPath ? <path d={path} className="ortho-edge-energy" /> : null}
<circle cx={sourceX} cy={sourceY} r={3.1} className="ortho-dot ortho-dot-source" />
<circle cx={mode === 'horizontal' ? trunkX : sourceX} cy={mode === 'horizontal' ? sourceY : busY} r={2.6} className="ortho-dot ortho-dot-branch" />
<circle cx={mode === 'horizontal' ? trunkX : targetX} cy={mode === 'horizontal' ? targetY : busY} r={2.6} className="ortho-dot ortho-dot-branch" />
<circle cx={targetX} cy={targetY} r={3.1} className="ortho-dot ortho-dot-target" />
</g>
)
})
const tierRatio: Record<OrthogonalEdgeData['tier'], number> = {
part: 0.54,
component: 0.5,
process: 0.46,
}
function round(value: number) {
return Number(value.toFixed(1))
}
function orthogonalPath(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
busY: number,
) {
return `M ${round(sourceX)} ${round(sourceY)} L ${round(sourceX)} ${busY} L ${round(targetX)} ${busY} L ${round(targetX)} ${round(targetY)}`
}
function horizontalPath(
sourceX: number,
sourceY: number,
targetX: number,
targetY: number,
trunkX?: number,
) {
const x = trunkX ?? round(sourceX + (targetX - sourceX) * 0.52)
return [
`M ${round(sourceX)} ${round(sourceY)}`,
`L ${round(x)} ${round(sourceY)}`,
`L ${round(x)} ${round(targetY)}`,
`L ${round(targetX)} ${round(targetY)}`,
].join(' ')
}
@@ -0,0 +1,105 @@
import { memo } from 'react'
import {
ChevronDown,
ChevronRight,
} from 'lucide-react'
import { Handle, Position, type NodeProps } from 'reactflow'
import type { ProductionNode } from '@/types'
import { riskMeta, statusMeta, supplyMeta } from '@/lib/production'
import { formatPercent, formatQuantity } from '@/lib/utils'
import { Badge } from '@/components/ui/badge'
import { Progress } from '@/components/ui/progress'
import { RingProgress, TechVisual } from './TechVisual'
export interface FlowNodeData {
node: ProductionNode
selected: boolean
expanded: boolean
hasChildren: boolean
dense: boolean
horizontalCompact: boolean
onToggle: (nodeId: string) => void
}
export const ProductionTaskNode = memo(function ProductionTaskNode({
data,
}: NodeProps<FlowNodeData>) {
const { node, selected, expanded, hasChildren, dense, horizontalCompact, onToggle } = data
const targetPosition = horizontalCompact ? Position.Left : Position.Top
const sourcePosition = horizontalCompact ? Position.Right : Position.Bottom
const riskTone =
node.riskLevel === 'critical'
? 'critical'
: node.riskLevel === 'warning'
? 'warning'
: 'success'
const display = getNodeDisplay(node)
return (
<article
className={`task-node node-${node.nodeType} ${node.nodeType === 'material' ? 'node-process' : ''} ${dense ? 'is-dense' : ''} ${horizontalCompact ? 'is-horizontal' : ''} ${selected ? 'is-selected' : ''} ${riskMeta[node.riskLevel].className}`}
aria-label={`${node.name} ${formatPercent(node.progress)}`}
title={node.name}
>
{selected ? <span className="node-selection-fx" aria-hidden="true" /> : null}
{node.parentId ? (
<Handle type="target" position={targetPosition} className="flow-handle flow-handle-target" />
) : null}
<TechVisual visualKey={node.visualKey} className="task-node-visual" label={node.name} />
<div className="task-node-title">
<span className="task-node-code">{display.code}</span>
<strong>{display.title}</strong>
<span>{display.subtitle}</span>
</div>
<div className={`task-node-controls ${hasChildren ? 'has-toggle' : ''}`}>
{hasChildren ? (
<button
type="button"
className="node-toggle nodrag"
aria-label={expanded ? '收起节点' : '展开节点'}
onClick={(event) => {
event.stopPropagation()
onToggle(node.id)
}}
>
{expanded ? <ChevronDown /> : <ChevronRight />}
</button>
) : null}
<RingProgress value={node.progress} tone={riskTone} compact />
</div>
<div className="task-node-body">
<div className="task-node-progress">
{node.nodeType === 'order' ? <Progress value={node.progress} tone={riskTone} /> : null}
<span>{formatQuantity(node.completedQty)} / {formatQuantity(node.requiredQty)}</span>
</div>
</div>
<div className="task-node-meta">
<Badge tone={riskTone}>{riskMeta[node.riskLevel].label}</Badge>
<span>{display.meta}</span>
</div>
{hasChildren && expanded ? (
<Handle type="source" position={sourcePosition} className="flow-handle flow-handle-source" />
) : null}
</article>
)
})
function getNodeDisplay(node: ProductionNode) {
const isProcess = node.nodeType === 'process'
const isMaterial = node.nodeType === 'material'
const nameParts = node.name.split(' - ')
const processName = isProcess && nameParts.length > 1
? nameParts[nameParts.length - 1]
: node.name
return {
code: isProcess ? node.operationCode || node.code : node.code,
title: isProcess ? processName : node.name,
subtitle: `${supplyMeta[node.supplyType].label} / ${statusMeta[node.status].label}`,
meta: node.delayDays > 0
? `延期 ${node.delayDays}`
: isMaterial
? node.material || node.specification || '--'
: node.stationName,
}
}
+217
View File
@@ -0,0 +1,217 @@
import type { CSSProperties } from 'react'
import type { RiskLevel, VisualKey } from '@/types'
import { formatPercent } from '@/lib/utils'
interface TechVisualProps {
visualKey: VisualKey
className?: string
label?: string
}
const visualTitles: Record<VisualKey, string> = {
'industrial-pc': '工业电脑',
workstation: '工作站',
aio: '一体机',
'edge-box': '边缘盒',
mainboard: '主板',
chip: '芯片',
memory: '内存',
storage: '存储',
chassis: '结构件',
thermal: '散热件',
power: '电源',
cable: '线束',
fixture: '夹具',
package: '包装',
generic: '物料',
}
export function TechVisual({ visualKey, className = '', label }: TechVisualProps) {
return (
<div className={`tech-visual ${className}`} aria-label={label ?? visualTitles[visualKey]}>
<svg viewBox="0 0 96 72" role="img" focusable="false">
<title>{label ?? visualTitles[visualKey]}</title>
<VisualPaths visualKey={visualKey} />
</svg>
</div>
)
}
function VisualPaths({ visualKey }: { visualKey: VisualKey }) {
if (visualKey === 'mainboard') {
return (
<>
<rect x="14" y="12" width="68" height="48" rx="4" />
<rect x="24" y="20" width="17" height="17" rx="2" />
<rect x="50" y="18" width="20" height="10" rx="2" />
<rect x="48" y="36" width="28" height="6" rx="1.5" />
<path d="M18 50h14m6 0h14m6 0h14M32 28h16m-7 0v20" />
<circle cx="70" cy="50" r="3" />
</>
)
}
if (visualKey === 'chip') {
return (
<>
<rect x="28" y="18" width="40" height="36" rx="4" />
<rect x="38" y="28" width="20" height="16" rx="2" />
<path d="M20 25h8m-8 8h8m-8 8h8m40-16h8m-8 8h8m-8 8h8M35 12v6m10-6v6m10-6v6M35 54v6m10-6v6m10-6v6" />
</>
)
}
if (visualKey === 'memory') {
return (
<>
<rect x="14" y="24" width="68" height="24" rx="3" />
<path d="M21 48v7m8-7v7m8-7v7m8-7v7m8-7v7m8-7v7m8-7v7m8-7v7" />
<rect x="22" y="31" width="9" height="8" rx="1" />
<rect x="38" y="31" width="9" height="8" rx="1" />
<rect x="54" y="31" width="9" height="8" rx="1" />
<rect x="70" y="31" width="6" height="8" rx="1" />
</>
)
}
if (visualKey === 'storage') {
return (
<>
<path d="M28 15h33l11 11v31H28z" />
<path d="M61 15v12h11M37 33h24M37 42h18" />
<circle cx="38" cy="52" r="3" />
<circle cx="62" cy="52" r="3" />
</>
)
}
if (visualKey === 'thermal') {
return (
<>
<circle cx="48" cy="36" r="20" />
<circle cx="48" cy="36" r="6" />
<path d="M48 16c6 6 7 12 0 20M68 36c-6 6-12 7-20 0M48 56c-6-6-7-12 0-20M28 36c6-6 12-7 20 0" />
<path d="M19 60h58" />
</>
)
}
if (visualKey === 'power') {
return (
<>
<rect x="18" y="22" width="50" height="32" rx="4" />
<path d="M68 30h8v16h-8M31 30v16M38 30v16M48 30v16M25 60h37" />
<path d="M31 15h19l8 7" />
</>
)
}
if (visualKey === 'cable') {
return (
<>
<path d="M17 45c16-24 28 19 44-5 7-10 10-18 20-12" />
<rect x="12" y="39" width="14" height="13" rx="2" />
<rect x="70" y="22" width="14" height="13" rx="2" />
<path d="M28 42h8m25-2h7" />
</>
)
}
if (visualKey === 'fixture') {
return (
<>
<path d="M21 54h54M29 54V24h38v30M37 24v-8h22v8" />
<rect x="34" y="32" width="28" height="14" rx="2" />
<path d="M18 22h16m28 0h16M28 14h40" />
</>
)
}
if (visualKey === 'package') {
return (
<>
<path d="M20 25l28-12 28 12-28 13z" />
<path d="M20 25v28l28 12 28-12V25M48 38v27" />
<path d="M33 31l28-12" />
</>
)
}
if (visualKey === 'chassis') {
return (
<>
<path d="M20 25l29-13 28 13v28L49 65 20 53z" />
<path d="M20 25l29 12 28-12M49 37v28" />
<path d="M32 48l9 4m16-8l9-4" />
</>
)
}
if (visualKey === 'aio') {
return (
<>
<rect x="17" y="14" width="62" height="40" rx="4" />
<path d="M33 61h30M43 54v7M53 54v7M26 24h44M26 33h20" />
<rect x="55" y="32" width="14" height="12" rx="2" />
</>
)
}
if (visualKey === 'edge-box') {
return (
<>
<path d="M22 27l26-12 28 13-27 13z" />
<path d="M22 27v22l27 13 27-13V28M49 41v21" />
<path d="M31 45h8m18 1h9M31 52h8m18 1h9" />
</>
)
}
if (visualKey === 'workstation') {
return (
<>
<rect x="18" y="16" width="46" height="31" rx="3" />
<path d="M31 56h20M39 47v9M47 47v9" />
<rect x="69" y="20" width="12" height="38" rx="2" />
<path d="M73 28h4M73 36h4M72 49h6" />
</>
)
}
return (
<>
<rect x="20" y="18" width="56" height="38" rx="4" />
<path d="M30 29h36M30 39h20M36 56v7h24v-7" />
</>
)
}
interface RingProgressProps {
value: number
tone?: RiskLevel | 'info' | 'success'
label?: string
compact?: boolean
className?: string
}
export function RingProgress({
value,
tone = 'info',
label,
compact = false,
className = '',
}: RingProgressProps) {
const safeValue = Math.max(0, Math.min(100, value))
const style = { '--ring-value': `${safeValue * 3.6}deg` } as CSSProperties
return (
<div
className={`ring-progress ring-${tone} ${compact ? 'ring-compact' : ''} ${className}`}
style={style}
aria-label={`${label ?? '完成率'} ${formatPercent(safeValue)}`}
>
<strong>{formatPercent(safeValue)}</strong>
{label ? <span>{label}</span> : null}
</div>
)
}
+211
View File
@@ -0,0 +1,211 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
AlertTriangle,
ClipboardList,
Gauge,
Radar,
RefreshCw,
RadioTower,
} from 'lucide-react'
import type { OrderSummary } from '@/types'
import type { DashboardConnectionState } from '@/lib/dashboard-api'
import { getConnectionPresentation } from '@/lib/dashboard-status'
import { flattenTree } from '@/lib/production'
import { formatPercent, formatQuantity } from '@/lib/utils'
const DASHBOARD_TITLE = '东方水利制造进度驾驶舱'
interface TopMetricsProps {
orders: OrderSummary[]
lastUpdated: string
connectionState: DashboardConnectionState
onRefresh: () => void
}
export function TopMetrics({
orders,
lastUpdated,
connectionState,
onRefresh,
}: TopMetricsProps) {
const totalOrders = orders.length
const totalRequired = orders.reduce((total, order) => total + order.requiredQty, 0)
const totalCompleted = orders.reduce((total, order) => total + order.completedQty, 0)
const overallProgress = totalRequired === 0 ? 0 : (totalCompleted / totalRequired) * 100
const delayedCount = orders.filter((order) =>
order.delayDays > 0 || order.status === 'delayed' || order.status === 'blocked'
).length
const riskCount = orders.filter((order) => order.riskLevel === 'critical').length
const warningCount = orders.filter((order) => order.riskLevel === 'warning').length
const planAchievement =
orders.length === 0
? 0
: orders.reduce((total, order) => total + order.planAchievement, 0) / orders.length
const averageDelta =
orders.length === 0
? 0
: orders.reduce((total, order) => total + order.dailyDelta, 0) / orders.length
const activeNodes = orders
.flatMap((order) => flattenTree(order.root))
.filter((node) => node.status === 'in_progress').length
const connection = getConnectionPresentation(connectionState)
return (
<header className="topbar">
<div className="topbar-title">
<DashboardTitle />
</div>
<div className="kpi-strip" aria-label="关键指标">
<MetricItem
icon={<ClipboardList />}
label="订单总数"
value={formatQuantity(totalOrders)}
detail={`较昨日 +${Math.max(1, Math.round(activeNodes / 25))}`}
/>
<MetricItem
icon={<Gauge />}
label="总完成率"
value={formatPercent(overallProgress)}
detail={`${formatQuantity(totalCompleted)} / ${formatQuantity(totalRequired)}`}
tone="success"
/>
<MetricItem
icon={<AlertTriangle />}
label="延期订单"
value={formatQuantity(delayedCount)}
detail="较昨日 +3"
tone="critical"
/>
<MetricItem
icon={<RadioTower />}
label="高风险订单"
value={formatQuantity(riskCount)}
detail={`较昨日 +${warningCount}`}
tone="warning"
/>
<MetricItem
icon={<Radar />}
label="计划达成率"
value={formatPercent(planAchievement)}
detail={`较昨日 ${averageDelta >= 0 ? '+' : ''}${averageDelta.toFixed(1)}%`}
tone="info"
/>
</div>
<div
className={`refresh-status refresh-${connection.tone}`}
data-state={connection.tone}
aria-label="刷新状态"
>
<span className="refresh-dot" />
<div className="refresh-status-copy">
<strong>{connection.label}</strong>
<span>DATA LINK</span>
</div>
<button type="button" aria-label="手动刷新" onClick={onRefresh}>
<RefreshCw />
</button>
<time title={`最后刷新:${lastUpdated}`}>{lastUpdated}</time>
<span className="refresh-flow" aria-hidden="true" />
</div>
</header>
)
}
function DashboardTitle() {
const frameRef = useRef<HTMLDivElement>(null)
const textRef = useRef<HTMLSpanElement>(null)
const [isOverflowing, setIsOverflowing] = useState(false)
useEffect(() => {
const measure = () => {
const frame = frameRef.current
const text = textRef.current
if (!frame || !text) {
return
}
setIsOverflowing(text.scrollWidth > frame.clientWidth + 2)
}
measure()
window.addEventListener('resize', measure)
if (typeof ResizeObserver === 'undefined') {
return () => window.removeEventListener('resize', measure)
}
const observer = new ResizeObserver(measure)
if (frameRef.current) {
observer.observe(frameRef.current)
}
if (textRef.current) {
observer.observe(textRef.current)
}
return () => {
observer.disconnect()
window.removeEventListener('resize', measure)
}
}, [])
return (
<div className="topbar-brand-lockup">
<div className="topbar-brand-mark" aria-hidden="true">
<span>DF</span>
<i />
</div>
<div className="topbar-brand-copy">
<span className="topbar-brand-kicker">DONGFANG HYDRO · CONTROL 01</span>
<div
ref={frameRef}
className={`topbar-title-window ${isOverflowing ? 'is-overflowing' : ''}`}
title={DASHBOARD_TITLE}
>
<h1 className="topbar-title-track">
<span ref={textRef} className="topbar-title-text">
<b></b><span></span>
</span>
<span aria-hidden="true" className="topbar-title-ghost topbar-title-text">
<b></b><span></span>
</span>
</h1>
</div>
</div>
</div>
)
}
function MetricItem({
icon,
label,
value,
detail,
tone = 'info',
}: {
icon: ReactNode
label: string
value: string
detail: string
tone?: 'info' | 'success' | 'warning' | 'critical'
}) {
return (
<div className={`metric-item metric-${tone}`}>
<div className="metric-instrument" aria-hidden="true">
<span className="metric-arc" />
<span className="metric-crosshair" />
<div className="metric-icon">
{icon}
</div>
</div>
<div className="metric-copy">
<span>{label}</span>
<strong key={value} className="metric-value">{value}</strong>
<small>{detail}</small>
</div>
<span className="metric-energy-rail" aria-hidden="true" />
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
tone?: 'normal' | 'success' | 'info' | 'warning' | 'critical' | 'muted'
}
export function Badge({ className, tone = 'muted', ...props }: BadgeProps) {
return <span className={cn('ui-badge', `ui-badge-${tone}`, className)} {...props} />
}
+39
View File
@@ -0,0 +1,39 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva('ui-button', {
variants: {
variant: {
default: 'ui-button-default',
secondary: 'ui-button-secondary',
ghost: 'ui-button-ghost',
danger: 'ui-button-danger',
},
size: {
default: 'ui-button-md',
sm: 'ui-button-sm',
icon: 'ui-button-icon',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
})
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
),
)
Button.displayName = 'Button'
+12
View File
@@ -0,0 +1,12 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => (
<input ref={ref} type={type} className={cn('ui-input', className)} {...props} />
),
)
Input.displayName = 'Input'
+30
View File
@@ -0,0 +1,30 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface ProgressProps extends React.HTMLAttributes<HTMLDivElement> {
value: number
tone?: 'normal' | 'success' | 'info' | 'warning' | 'critical'
}
export function Progress({
className,
value,
tone = 'info',
...props
}: ProgressProps) {
return (
<div
className={cn('ui-progress', className)}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(value)}
{...props}
>
<span
className={cn('ui-progress-value', `ui-progress-${tone}`)}
style={{ width: `${Math.max(0, Math.min(value, 100))}%` }}
/>
</div>
)
}
+671
View File
@@ -0,0 +1,671 @@
import type {
NodeType,
OrderSummary,
ProductionNode,
ProductionStatus,
RiskLevel,
SupplyType,
TrendPoint,
VisualKey,
} from '@/types'
import { clamp, formatTime } from '@/lib/utils'
import { recalculateOrder } from '@/lib/production'
interface OrderBlueprint {
id: string
code: string
productName: string
batchQty: number
lineName: string
owner: string
plannedStart: string
plannedEnd: string
baseProgress: number
priorityOffset: number
thumbnailKey: VisualKey
dailyDelta: number
}
interface PartBlueprint {
name: string
supplyType: SupplyType
qtyPerUnit: number
owner: string
vendor: string
visualKey: VisualKey
components: Array<{
name: string
supplyType: SupplyType
qtyPerUnit: number
owner: string
vendor: string
visualKey: VisualKey
}>
}
const orderBlueprints: OrderBlueprint[] = [
{
id: 'order-001',
code: 'MO-260708-001',
productName: '青峪口尾水单向门机 2×160/10',
batchQty: 1200,
lineName: '总装一线',
owner: '周敏',
plannedStart: '2026-07-01',
plannedEnd: '2026-07-18',
baseProgress: 68,
priorityOffset: 7,
thumbnailKey: 'industrial-pc',
dailyDelta: 4.3,
},
{
id: 'order-002',
code: 'MO-260708-002',
productName: '电站尾水2×160kN/100kN单向门式启闭机',
batchQty: 860,
lineName: '总装二线',
owner: '李澈',
plannedStart: '2026-07-02',
plannedEnd: '2026-07-16',
baseProgress: 82,
priorityOffset: 2,
thumbnailKey: 'workstation',
dailyDelta: 2.8,
},
{
id: 'order-003',
code: 'MO-260708-003',
productName: '主起升机构',
batchQty: 420,
lineName: '高配小批线',
owner: '陈嘉',
plannedStart: '2026-06-29',
plannedEnd: '2026-07-14',
baseProgress: 54,
priorityOffset: 13,
thumbnailKey: 'workstation',
dailyDelta: -1.2,
},
{
id: 'order-004',
code: 'MO-260708-004',
productName: '100kN回转吊',
batchQty: 1500,
lineName: '总装三线',
owner: '罗岚',
plannedStart: '2026-07-03',
plannedEnd: '2026-07-22',
baseProgress: 43,
priorityOffset: 18,
thumbnailKey: 'aio',
dailyDelta: -3.1,
},
{
id: 'order-005',
code: 'MO-260708-005',
productName: '门架',
batchQty: 2100,
lineName: '柔性装配线',
owner: '宋一',
plannedStart: '2026-07-04',
plannedEnd: '2026-07-19',
baseProgress: 76,
priorityOffset: 5,
thumbnailKey: 'industrial-pc',
dailyDelta: 1.6,
},
{
id: 'order-006',
code: 'MO-260708-006',
productName: '大车行走机构',
batchQty: 640,
lineName: '精密装配线',
owner: '韩策',
plannedStart: '2026-07-01',
plannedEnd: '2026-07-15',
baseProgress: 61,
priorityOffset: 11,
thumbnailKey: 'edge-box',
dailyDelta: -0.8,
},
]
const partBlueprints: PartBlueprint[] = [
{
name: '主板模组',
supplyType: 'self_made',
qtyPerUnit: 1,
owner: 'SMT 车间',
vendor: '自产工段',
visualKey: 'mainboard',
components: [
{
name: 'PCB 压合板',
supplyType: 'self_made',
qtyPerUnit: 1,
owner: '压合组',
vendor: '自产工段',
visualKey: 'mainboard',
},
{
name: 'CPU 插座与芯片组',
supplyType: 'purchased',
qtyPerUnit: 1,
owner: '物控组',
vendor: '华东电子',
visualKey: 'chip',
},
{
name: 'IO 接口焊接件',
supplyType: 'outsourced',
qtyPerUnit: 1,
owner: '外协组',
vendor: '启明精焊',
visualKey: 'cable',
},
],
},
{
name: '计算与存储',
supplyType: 'purchased',
qtyPerUnit: 1,
owner: '物控组',
vendor: '核心器件仓',
visualKey: 'chip',
components: [
{
name: 'CPU 处理器',
supplyType: 'purchased',
qtyPerUnit: 1,
owner: '采购一组',
vendor: '晶核科技',
visualKey: 'chip',
},
{
name: 'DDR5 内存条',
supplyType: 'purchased',
qtyPerUnit: 2,
owner: '采购二组',
vendor: '星存供应链',
visualKey: 'memory',
},
{
name: 'NVMe 固态硬盘',
supplyType: 'purchased',
qtyPerUnit: 1,
owner: '仓储组',
vendor: '闪芯电子',
visualKey: 'storage',
},
],
},
{
name: '结构与散热',
supplyType: 'outsourced',
qtyPerUnit: 1,
owner: '结构工程',
vendor: '远航五金',
visualKey: 'chassis',
components: [
{
name: '铝合金外壳',
supplyType: 'outsourced',
qtyPerUnit: 1,
owner: '外协组',
vendor: '远航五金',
visualKey: 'chassis',
},
{
name: '散热鳍片组件',
supplyType: 'outsourced',
qtyPerUnit: 1,
owner: '热设计组',
vendor: '恒冷科技',
visualKey: 'thermal',
},
{
name: '风扇与导热垫',
supplyType: 'purchased',
qtyPerUnit: 1,
owner: '物控组',
vendor: '风擎电子',
visualKey: 'thermal',
},
],
},
{
name: '电源与线束',
supplyType: 'self_made',
qtyPerUnit: 1,
owner: '电装车间',
vendor: '自产工段',
visualKey: 'power',
components: [
{
name: '电源适配器',
supplyType: 'purchased',
qtyPerUnit: 1,
owner: '采购三组',
vendor: '安源电气',
visualKey: 'power',
},
{
name: '内部线束',
supplyType: 'self_made',
qtyPerUnit: 1,
owner: '电装组',
vendor: '自产工段',
visualKey: 'cable',
},
{
name: '电源测试夹具',
supplyType: 'self_made',
qtyPerUnit: 1,
owner: '测试工程',
vendor: '自产工段',
visualKey: 'fixture',
},
],
},
{
name: '整机测试包装',
supplyType: 'self_made',
qtyPerUnit: 1,
owner: '总装测试',
vendor: '自产工段',
visualKey: 'package',
components: [
{
name: 'BIOS 烧录',
supplyType: 'self_made',
qtyPerUnit: 1,
owner: '软件烧录组',
vendor: '自产工段',
visualKey: 'chip',
},
{
name: '老化测试',
supplyType: 'self_made',
qtyPerUnit: 1,
owner: '测试一组',
vendor: '自产工段',
visualKey: 'fixture',
},
{
name: '包装入库',
supplyType: 'outsourced',
qtyPerUnit: 1,
owner: '物流组',
vendor: '快联包装',
visualKey: 'package',
},
],
},
]
const processNames = ['备料齐套', '生产装配', '检验入库']
export function createMockOrders(): OrderSummary[] {
return orderBlueprints.map((blueprint, orderIndex) => {
const root = createOrderTree(blueprint, orderIndex)
const draft: OrderSummary = {
id: blueprint.id,
code: blueprint.code,
productName: blueprint.productName,
batchQty: blueprint.batchQty,
requiredQty: blueprint.batchQty,
completedQty: Math.round((blueprint.batchQty * blueprint.baseProgress) / 100),
progress: blueprint.baseProgress,
status: 'in_progress',
riskLevel: 'normal',
delayDays: 0,
plannedStart: blueprint.plannedStart,
plannedEnd: blueprint.plannedEnd,
lineName: blueprint.lineName,
owner: blueprint.owner,
planAchievement: clamp(blueprint.baseProgress + 12 - blueprint.priorityOffset * 0.18, 42, 99),
dailyDelta: blueprint.dailyDelta,
thumbnailKey: blueprint.thumbnailKey,
root,
trend: createInitialTrend(blueprint.baseProgress, orderIndex, blueprint.batchQty),
events: [],
}
return recalculateOrder(draft)
})
}
function createOrderTree(
order: OrderBlueprint,
orderIndex: number,
): ProductionNode {
const orderId = order.id
const rootId = `${orderId}-root`
const children = partBlueprints.map((part, partIndex) =>
createPartNode(order, rootId, part, orderIndex, partIndex),
)
return makeNode({
id: rootId,
orderId,
parentId: null,
name: `${order.productName} / ${order.batchQty}`,
level: 0,
nodeType: 'order',
supplyType: 'self_made',
requiredQty: order.batchQty,
progress: order.baseProgress,
plannedStart: order.plannedStart,
plannedEnd: order.plannedEnd,
owner: order.owner,
vendor: order.lineName,
code: order.code,
operationCode: 'MO',
materialCode: `FG-${String(orderIndex + 1).padStart(3, '0')}`,
stationName: order.lineName,
visualKey: order.thumbnailKey,
children,
})
}
function createPartNode(
order: OrderBlueprint,
parentId: string,
part: PartBlueprint,
orderIndex: number,
partIndex: number,
): ProductionNode {
const id = `${order.id}-part-${partIndex}`
const partProgress = progressFor(order.baseProgress, order.priorityOffset, partIndex, 0, 0)
return makeNode({
id,
orderId: order.id,
parentId,
name: part.name,
level: 1,
nodeType: 'part',
supplyType: part.supplyType,
requiredQty: order.batchQty * part.qtyPerUnit,
progress: partProgress,
plannedStart: offsetDate(order.plannedStart, partIndex),
plannedEnd: offsetDate(order.plannedEnd, partIndex - 4),
owner: part.owner,
vendor: part.vendor,
code: `P-${String(partIndex + 1).padStart(2, '0')}`,
operationCode: `P-${String((partIndex + 1) * 10).padStart(2, '0')}`,
materialCode: `BOM-${order.id.slice(-3)}-${String(partIndex + 1).padStart(2, '0')}`,
stationName: part.owner,
visualKey: part.visualKey,
children: part.components.map((component, componentIndex) =>
createComponentNode(order, id, component, orderIndex, partIndex, componentIndex),
),
})
}
function createComponentNode(
order: OrderBlueprint,
parentId: string,
component: PartBlueprint['components'][number],
orderIndex: number,
partIndex: number,
componentIndex: number,
): ProductionNode {
const id = `${order.id}-part-${partIndex}-component-${componentIndex}`
const componentProgress = progressFor(
order.baseProgress,
order.priorityOffset,
partIndex,
componentIndex,
0,
)
return makeNode({
id,
orderId: order.id,
parentId,
name: component.name,
level: 2,
nodeType: 'component',
supplyType: component.supplyType,
requiredQty: order.batchQty * component.qtyPerUnit,
progress: componentProgress,
plannedStart: offsetDate(order.plannedStart, partIndex + componentIndex),
plannedEnd: offsetDate(order.plannedEnd, componentIndex - partIndex - 2),
owner: component.owner,
vendor: component.vendor,
code: `C-${String(partIndex + 1).padStart(2, '0')}-${String(componentIndex + 1).padStart(2, '0')}`,
operationCode: `C${partIndex + 1}${componentIndex + 1}`,
materialCode: `MAT-${String(partIndex + 1).padStart(2, '0')}${String(componentIndex + 1).padStart(2, '0')}`,
stationName: component.owner,
visualKey: component.visualKey,
children: processNames.map((processName, processIndex) =>
createProcessNode(
order,
id,
component,
processName,
orderIndex,
partIndex,
componentIndex,
processIndex,
),
),
})
}
function createProcessNode(
order: OrderBlueprint,
parentId: string,
component: PartBlueprint['components'][number],
processName: string,
orderIndex: number,
partIndex: number,
componentIndex: number,
processIndex: number,
): ProductionNode {
const progress = progressFor(
order.baseProgress,
order.priorityOffset,
partIndex,
componentIndex,
processIndex,
)
const riskLevel = riskFor(orderIndex, partIndex, componentIndex, processIndex, progress)
const delayDays = riskLevel === 'critical' ? 3 + ((partIndex + componentIndex) % 3) : riskLevel === 'warning' ? 1 : 0
const status = statusFor(progress, riskLevel, processIndex)
const requiredQty = order.batchQty * component.qtyPerUnit
return makeNode({
id: `${order.id}-part-${partIndex}-component-${componentIndex}-process-${processIndex}`,
orderId: order.id,
parentId,
name: `${component.name} - ${processName}`,
level: 3,
nodeType: 'process',
supplyType: component.supplyType,
requiredQty,
progress,
status,
riskLevel,
delayDays,
delayReason: reasonFor(riskLevel, component.supplyType, processName),
plannedStart: offsetDate(order.plannedStart, partIndex + componentIndex + processIndex),
plannedEnd: offsetDate(order.plannedEnd, processIndex - partIndex - componentIndex - 3),
actualStart: progress > 0 ? offsetDate(order.plannedStart, partIndex) : '',
actualEnd: progress >= 100 ? offsetDate(order.plannedEnd, -1) : '',
owner: component.owner,
vendor: component.vendor,
code: `OP-${String((processIndex + 1) * 10).padStart(2, '0')}`,
operationCode: `OP-${String((processIndex + 1) * 10).padStart(2, '0')}`,
materialCode: `MAT-${String(partIndex + 1).padStart(2, '0')}${String(componentIndex + 1).padStart(2, '0')}`,
stationName: `${component.owner}-${processIndex + 1}`,
visualKey: processIndex === 2 ? 'package' : component.visualKey,
})
}
function makeNode(input: {
id: string
orderId: string
parentId: string | null
name: string
level: number
nodeType: NodeType
supplyType: SupplyType
requiredQty: number
progress: number
plannedStart: string
plannedEnd: string
owner: string
vendor: string
code?: string
operationCode?: string
materialCode?: string
stationName?: string
defectQty?: number
visualKey?: VisualKey
status?: ProductionStatus
riskLevel?: RiskLevel
delayDays?: number
delayReason?: string
actualStart?: string
actualEnd?: string
children?: ProductionNode[]
}): ProductionNode {
const progress = clamp(input.progress, 0, 100)
return {
id: input.id,
orderId: input.orderId,
parentId: input.parentId,
code: input.code ?? input.id,
operationCode: input.operationCode ?? input.code ?? input.id,
materialCode: input.materialCode ?? `MAT-${input.id.slice(-4).toUpperCase()}`,
name: input.name,
level: input.level,
nodeType: input.nodeType,
supplyType: input.supplyType,
requiredQty: input.requiredQty,
completedQty: Math.round((input.requiredQty * progress) / 100),
defectQty: input.defectQty ?? Math.round(input.requiredQty * defectRate(input.riskLevel ?? 'normal', progress)),
progress,
status: input.status ?? statusFor(progress, input.riskLevel ?? 'normal', 0),
riskLevel: input.riskLevel ?? 'normal',
delayDays: input.delayDays ?? 0,
delayReason: input.delayReason ?? '',
plannedStart: input.plannedStart,
plannedEnd: input.plannedEnd,
actualStart: input.actualStart ?? (progress > 0 ? input.plannedStart : ''),
actualEnd: input.actualEnd ?? (progress >= 100 ? input.plannedEnd : ''),
owner: input.owner,
vendor: input.vendor,
stationName: input.stationName ?? input.owner,
visualKey: input.visualKey ?? 'generic',
children: input.children,
}
}
function defectRate(riskLevel: RiskLevel, progress: number) {
if (riskLevel === 'critical') {
return 0.018 + (100 - progress) / 18000
}
if (riskLevel === 'warning') {
return 0.008 + (100 - progress) / 26000
}
return progress > 95 ? 0.001 : 0.003
}
function progressFor(
base: number,
offset: number,
partIndex: number,
componentIndex: number,
processIndex: number,
) {
const wave = ((partIndex * 11 + componentIndex * 7 + processIndex * 13 + offset) % 34) - 17
const processPenalty = processIndex * 5
return clamp(base + wave - processPenalty, 4, 100)
}
function riskFor(
orderIndex: number,
partIndex: number,
componentIndex: number,
processIndex: number,
progress: number,
): RiskLevel {
const signature = orderIndex * 13 + partIndex * 7 + componentIndex * 5 + processIndex * 3
if (signature % 19 === 0 || (progress < 35 && processIndex === 2)) {
return 'critical'
}
if (signature % 7 === 0 || progress < 48) {
return 'warning'
}
return 'normal'
}
function statusFor(
progress: number,
riskLevel: RiskLevel,
processIndex: number,
): ProductionStatus {
if (progress >= 100) {
return 'done'
}
if (riskLevel === 'critical' && processIndex === 2) {
return 'blocked'
}
if (riskLevel === 'critical') {
return 'delayed'
}
if (progress > 0) {
return 'in_progress'
}
return 'waiting'
}
function reasonFor(
riskLevel: RiskLevel,
supplyType: SupplyType,
processName: string,
) {
if (riskLevel === 'normal') {
return ''
}
if (supplyType === 'outsourced') {
return `${processName} 外协回货节拍低于计划`
}
if (supplyType === 'purchased') {
return `${processName} 供应到料存在批次差异`
}
return `${processName} 工序良率波动,需要复检`
}
function offsetDate(date: string, offset: number) {
const next = new Date(`${date}T00:00:00`)
next.setDate(next.getDate() + offset)
return next.toISOString().slice(0, 10)
}
function createInitialTrend(
baseProgress: number,
orderIndex: number,
requiredQty: number,
): TrendPoint[] {
return Array.from({ length: 10 }, (_, index) => {
const completion = clamp(baseProgress - (9 - index) * 2 + ((index + orderIndex) % 3), 0, 100)
const risk = Math.max(1, 9 - index + (orderIndex % 3))
const timestamp = new Date(Date.now() - (9 - index) * 1000 * 60 * 8)
const plannedQty = Math.round(requiredQty * clamp(completion + 8, 0, 100) / 100)
const actualQty = Math.round(requiredQty * completion / 100)
return {
time: formatTime(timestamp),
completion: Math.round(completion),
risk,
plannedQty,
actualQty,
achievement: plannedQty === 0 ? 0 : clamp((actualQty / plannedQty) * 100, 0, 120),
}
})
}
+4141
View File
File diff suppressed because it is too large Load Diff
+158
View File
@@ -0,0 +1,158 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OrderSummary } from '@/types'
import {
fetchDashboardOrders,
reconcileDashboardSnapshot,
upsertOrderSnapshot,
} from './dashboard-api'
describe('dashboard API client', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('loads all dashboard orders from the paged API response', async () => {
const order = createOrder('order-001', '门架')
const fetchMock = vi.fn().mockResolvedValue(new Response(
JSON.stringify({ items: [order], total: 1, page: 1, pageSize: 100 }),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
))
vi.stubGlobal('fetch', fetchMock)
const result = await fetchDashboardOrders('http://dashboard.test')
expect(result).toEqual([order])
expect(fetchMock).toHaveBeenCalledWith(
'http://dashboard.test/api/orders?page=1&pageSize=100',
expect.objectContaining({ headers: { Accept: 'application/json' } }),
)
})
it('replaces an existing order snapshot without changing list order', () => {
const first = createOrder('order-001', '门架')
const second = createOrder('order-002', '主起升机构')
const updated = { ...first, progress: 91, completedQty: 91 }
const result = upsertOrderSnapshot([first, second], updated)
expect(result).toEqual([updated, second])
})
it('appends a newly announced order snapshot', () => {
const first = createOrder('order-001', '门架')
const added = createOrder('order-003', '大车行走机构')
expect(upsertOrderSnapshot([first], added)).toEqual([first, added])
})
it('continues through API pages when the dashboard has more than 100 orders', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
createOrder(`order-${String(index + 1).padStart(3, '0')}`, `产品 ${index + 1}`))
const finalOrder = createOrder('order-101', '产品 101')
const fetchMock = vi.fn().mockImplementation((url: string) => {
const isSecondPage = url.includes('page=2')
return Promise.resolve(new Response(JSON.stringify({
items: isSecondPage ? [finalOrder] : firstPage,
total: 101,
page: isSecondPage ? 2 : 1,
pageSize: 100,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
})
vi.stubGlobal('fetch', fetchMock)
const result = await fetchDashboardOrders('http://dashboard.test')
expect(result).toHaveLength(101)
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('does not let a stale REST snapshot overwrite an order updated by SignalR', () => {
const stale = createOrder('order-001', '门架')
const realtime = { ...stale, progress: 93, completedQty: 93 }
const another = createOrder('order-002', '主起升机构')
const result = reconcileDashboardSnapshot(
[stale, another],
[realtime, another],
new Set(['order-001']),
)
expect(result).toEqual([realtime, another])
})
it('deduplicates an order that shifts across page boundaries', async () => {
const firstPage = Array.from({ length: 100 }, (_, index) =>
createOrder(`order-${String(index + 1).padStart(3, '0')}`, `产品 ${index + 1}`))
const finalOrder = createOrder('order-101', '产品 101')
const fetchMock = vi.fn().mockImplementation((url: string) => {
const items = url.includes('page=2')
? [firstPage[99], finalOrder]
: firstPage
return Promise.resolve(new Response(JSON.stringify({
items,
total: 101,
page: url.includes('page=2') ? 2 : 1,
pageSize: 100,
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
})
vi.stubGlobal('fetch', fetchMock)
const result = await fetchDashboardOrders('http://dashboard.test')
expect(result).toHaveLength(101)
expect(new Set(result.map((order) => order.id)).size).toBe(101)
})
})
function createOrder(id: string, productName: string): OrderSummary {
return {
id,
code: `MO-${id}`,
productName,
batchQty: 100,
requiredQty: 100,
completedQty: 60,
progress: 60,
status: 'in_progress',
riskLevel: 'normal',
delayDays: 0,
plannedStart: '2026-07-01',
plannedEnd: '2026-07-18',
lineName: '总装一线',
owner: '周敏',
planAchievement: 88,
dailyDelta: 2.1,
thumbnailKey: 'industrial-pc',
trend: [],
events: [],
root: {
id: `${id}-root`,
orderId: id,
parentId: null,
code: 'ROOT',
operationCode: 'ROOT',
materialCode: 'MAT-ROOT',
name: productName,
level: 0,
nodeType: 'order',
supplyType: 'self_made',
requiredQty: 100,
completedQty: 60,
defectQty: 0,
progress: 60,
status: 'in_progress',
riskLevel: 'normal',
delayDays: 0,
delayReason: '',
plannedStart: '2026-07-01',
plannedEnd: '2026-07-18',
actualStart: '2026-07-01',
actualEnd: '',
owner: '周敏',
vendor: '东方水利',
stationName: '总装一线',
visualKey: 'industrial-pc',
children: [],
},
}
}
+171
View File
@@ -0,0 +1,171 @@
import type { HubConnection } from '@microsoft/signalr'
import type { OrderSummary } from '@/types'
interface PagedOrdersResponse {
items: OrderSummary[]
total: number
page: number
pageSize: number
}
export type DashboardConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'offline'
export interface DashboardRealtimeHandlers {
onOrderUpdated: (order: OrderSummary) => void
onConnectionStateChange?: (state: DashboardConnectionState) => void
}
export interface DashboardRealtimeSubscription {
stop: () => Promise<void>
}
export const dashboardApiBaseUrl = (
import.meta.env.VITE_DASHBOARD_API_URL ?? 'http://localhost:5080'
).replace(/\/$/, '')
export async function fetchDashboardOrders(
baseUrl = dashboardApiBaseUrl,
signal?: AbortSignal,
): Promise<OrderSummary[]> {
const ordersById = new Map<string, OrderSummary>()
let page = 1
let total = 0
do {
const response = await fetch(`${baseUrl}/api/orders?page=${page}&pageSize=100`, {
signal,
headers: { Accept: 'application/json' },
})
if (!response.ok) {
throw new Error(`Dashboard orders request failed with status ${response.status}.`)
}
const payload = await response.json() as PagedOrdersResponse
for (const order of payload.items) {
ordersById.set(order.id, order)
}
total = payload.total
page += 1
if (payload.items.length === 0) {
break
}
} while (ordersById.size < total)
return [...ordersById.values()]
}
export function upsertOrderSnapshot(
orders: OrderSummary[],
updatedOrder: OrderSummary,
): OrderSummary[] {
const index = orders.findIndex((order) => order.id === updatedOrder.id)
if (index < 0) {
return [...orders, updatedOrder]
}
return orders.map((order, orderIndex) =>
orderIndex === index ? updatedOrder : order,
)
}
export function reconcileDashboardSnapshot(
snapshot: OrderSummary[],
current: OrderSummary[],
realtimeOrderIds: ReadonlySet<string>,
): OrderSummary[] {
if (realtimeOrderIds.size === 0) {
return snapshot
}
const currentById = new Map(current.map((order) => [order.id, order]))
const snapshotIds = new Set(snapshot.map((order) => order.id))
const reconciled = snapshot.map((order) =>
realtimeOrderIds.has(order.id) ? currentById.get(order.id) ?? order : order,
)
for (const orderId of realtimeOrderIds) {
const currentOrder = currentById.get(orderId)
if (currentOrder && !snapshotIds.has(orderId)) {
reconciled.push(currentOrder)
}
}
return reconciled
}
export function connectDashboardRealtime(
handlers: DashboardRealtimeHandlers,
baseUrl = dashboardApiBaseUrl,
): DashboardRealtimeSubscription {
handlers.onConnectionStateChange?.('connecting')
let stopped = false
let connection: HubConnection | null = null
let retryTimer: ReturnType<typeof setTimeout> | null = null
const scheduleRetry = () => {
if (stopped || retryTimer) {
return
}
retryTimer = setTimeout(() => {
retryTimer = null
void start()
}, 5000)
}
const start = async () => {
if (stopped) {
return
}
if (!connection) {
const { HubConnectionBuilder, LogLevel } = await import('@microsoft/signalr')
if (stopped) {
return
}
connection = new HubConnectionBuilder()
.withUrl(`${baseUrl}/hubs/dashboard`)
.withAutomaticReconnect([0, 2000, 5000, 10000])
.configureLogging(LogLevel.Warning)
.build()
connection.on('orderUpdated', handlers.onOrderUpdated)
connection.onreconnecting(() => handlers.onConnectionStateChange?.('reconnecting'))
connection.onreconnected(() => handlers.onConnectionStateChange?.('connected'))
connection.onclose(() => {
if (!stopped) {
handlers.onConnectionStateChange?.('offline')
scheduleRetry()
}
})
}
try {
await connection.start()
handlers.onConnectionStateChange?.('connected')
} catch {
handlers.onConnectionStateChange?.('offline')
scheduleRetry()
}
}
void start()
return {
stop: async () => {
stopped = true
if (retryTimer) {
clearTimeout(retryTimer)
retryTimer = null
}
await stopDashboardRealtime(connection)
},
}
}
export async function stopDashboardRealtime(connection: HubConnection | null) {
if (connection) {
await connection.stop()
}
}
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { getConnectionPresentation } from './dashboard-status'
describe('getConnectionPresentation', () => {
it.each([
['connected', '实时刷新', 'connected'],
['connecting', '连接中', 'connecting'],
['reconnecting', '重新连接', 'reconnecting'],
['offline', '离线数据', 'offline'],
] as const)('maps %s to a matching visual state', (state, label, tone) => {
expect(getConnectionPresentation(state)).toEqual({ label, tone })
})
})
+17
View File
@@ -0,0 +1,17 @@
import type { DashboardConnectionState } from './dashboard-api'
export interface ConnectionPresentation {
label: string
tone: DashboardConnectionState
}
const connectionPresentations: Record<DashboardConnectionState, ConnectionPresentation> = {
connected: { label: '实时刷新', tone: 'connected' },
connecting: { label: '连接中', tone: 'connecting' },
reconnecting: { label: '重新连接', tone: 'reconnecting' },
offline: { label: '离线数据', tone: 'offline' },
}
export function getConnectionPresentation(state: DashboardConnectionState) {
return connectionPresentations[state]
}
+13
View File
@@ -0,0 +1,13 @@
export const chartPalette = {
background: '#020812',
panel: '#061323',
foreground: '#dceef6',
muted: '#7c9aaa',
grid: 'rgba(86, 149, 177, 0.18)',
success: '#35d47d',
cyan: '#12c7f3',
warning: '#f5a524',
critical: '#ff515d',
progress: '#2f94ff',
neutral: '#7b8998',
}
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import {
calculateOrderTreeStats,
findNodePath,
getPathEdgeIds,
recalculateTree,
} from './production'
import type { ProductionNode } from '@/types'
describe('recalculateTree', () => {
it('keeps a zero-quantity material node at zero progress', () => {
const material: ProductionNode = {
id: 'material-1',
orderId: 'order-1',
parentId: 'root-1',
code: 'GB/T 5782',
operationCode: '',
materialCode: 'GB/T 5782',
name: '螺栓',
level: 3,
nodeType: 'material',
supplyType: 'purchased',
requiredQty: 0,
completedQty: 0,
defectQty: 0,
progress: 0,
status: 'waiting',
riskLevel: 'normal',
delayDays: 0,
delayReason: '',
plannedStart: '',
plannedEnd: '',
actualStart: '',
actualEnd: '',
owner: '',
vendor: '',
stationName: '',
visualKey: 'generic',
children: [],
}
const result = recalculateTree(material)
expect(result.progress).toBe(0)
expect(result.status).toBe('waiting')
})
})
describe('calculateOrderTreeStats', () => {
it('summarizes the real BOM shape without hard-coded counts', () => {
const material = makeNode('material', 3)
const component = makeNode('component', 2, [material])
const part = makeNode('part', 1, [component])
const root = makeNode('order', 0, [part])
expect(calculateOrderTreeStats(root)).toEqual({
totalNodes: 4,
partNodes: 1,
componentNodes: 1,
materialNodes: 1,
processNodes: 0,
maxDepth: 3,
})
})
})
describe('findNodePath', () => {
const material = makeNode('material', 3)
const sibling = { ...makeNode('material', 3), id: 'material-sibling' }
const component = makeNode('component', 2, [material, sibling])
const part = makeNode('part', 1, [component])
const root = makeNode('order', 0, [part])
it('returns the root as a one-node path', () => {
expect(findNodePath(root, root.id).map(({ id }) => id)).toEqual([root.id])
})
it('returns the complete ancestor chain for a deep node', () => {
expect(findNodePath(root, material.id).map(({ id }) => id)).toEqual([
root.id,
part.id,
component.id,
material.id,
])
})
it('returns an empty path when the node does not exist', () => {
expect(findNodePath(root, 'missing-node')).toEqual([])
})
it('builds edge ids only for adjacent nodes on the selected path', () => {
const edgeIds = getPathEdgeIds(findNodePath(root, material.id))
expect([...edgeIds]).toEqual([
`${root.id}-${part.id}`,
`${part.id}-${component.id}`,
`${component.id}-${material.id}`,
])
expect(edgeIds.has(`${component.id}-${sibling.id}`)).toBe(false)
})
})
function makeNode(
nodeType: ProductionNode['nodeType'],
level: number,
children: ProductionNode[] = [],
): ProductionNode {
return {
id: `${nodeType}-${level}`,
orderId: 'order-1',
parentId: level === 0 ? null : 'parent',
code: `${nodeType}-${level}`,
operationCode: '',
materialCode: '',
name: nodeType,
level,
nodeType,
supplyType: 'self_made',
requiredQty: 1,
completedQty: 0,
defectQty: 0,
progress: 0,
status: 'waiting',
riskLevel: 'normal',
delayDays: 0,
delayReason: '',
plannedStart: '',
plannedEnd: '',
actualStart: '',
actualEnd: '',
owner: '',
vendor: '',
stationName: '',
visualKey: 'generic',
children,
}
}
+396
View File
@@ -0,0 +1,396 @@
import type {
OrderSummary,
ProductionNode,
ProductionStatus,
RiskEvent,
RiskLevel,
SupplyType,
} from '@/types'
import { clamp, formatTime } from '@/lib/utils'
export const supplyMeta: Record<
SupplyType,
{ label: string; shortLabel: string; className: string }
> = {
self_made: { label: '自产', shortLabel: '自产', className: 'supply-self' },
outsourced: { label: '委外', shortLabel: '委外', className: 'supply-outsourced' },
purchased: { label: '外购', shortLabel: '外购', className: 'supply-purchased' },
}
export const statusMeta: Record<
ProductionStatus,
{ label: string; className: string }
> = {
waiting: { label: '待开始', className: 'status-waiting' },
in_progress: { label: '进行中', className: 'status-progress' },
done: { label: '已完成', className: 'status-done' },
delayed: { label: '已延期', className: 'status-delayed' },
blocked: { label: '阻塞', className: 'status-blocked' },
}
export const riskMeta: Record<RiskLevel, { label: string; className: string }> = {
normal: { label: '正常', className: 'risk-normal' },
warning: { label: '预警', className: 'risk-warning' },
critical: { label: '严重', className: 'risk-critical' },
}
export function flattenTree(node: ProductionNode): ProductionNode[] {
return [node, ...(node.children ?? []).flatMap(flattenTree)]
}
export interface OrderTreeStats {
totalNodes: number
partNodes: number
componentNodes: number
materialNodes: number
processNodes: number
maxDepth: number
}
export function calculateOrderTreeStats(root: ProductionNode): OrderTreeStats {
const stats: OrderTreeStats = {
totalNodes: 0,
partNodes: 0,
componentNodes: 0,
materialNodes: 0,
processNodes: 0,
maxDepth: 0,
}
const visit = (node: ProductionNode, depth: number) => {
stats.totalNodes += 1
stats.maxDepth = Math.max(stats.maxDepth, depth)
if (node.nodeType === 'part') stats.partNodes += 1
if (node.nodeType === 'component') stats.componentNodes += 1
if (node.nodeType === 'material') stats.materialNodes += 1
if (node.nodeType === 'process') stats.processNodes += 1
for (const child of node.children ?? []) {
visit(child, depth + 1)
}
}
visit(root, 0)
return stats
}
export function findNode(
root: ProductionNode | undefined,
nodeId: string | null,
): ProductionNode | undefined {
if (!root || !nodeId) {
return undefined
}
if (root.id === nodeId) {
return root
}
for (const child of root.children ?? []) {
const found = findNode(child, nodeId)
if (found) {
return found
}
}
return undefined
}
export function findNodePath(
root: ProductionNode,
nodeId: string | null,
): ProductionNode[] {
if (!nodeId) {
return []
}
if (root.id === nodeId) {
return [root]
}
for (const child of root.children ?? []) {
const childPath = findNodePath(child, nodeId)
if (childPath.length > 0) {
return [root, ...childPath]
}
}
return []
}
export function getPathEdgeIds(path: ProductionNode[]) {
return new Set(
path.slice(1).map((node, index) => `${path[index].id}-${node.id}`),
)
}
export function defaultExpandedIds(root: ProductionNode) {
const expanded = new Set<string>([root.id])
const priorityPart = root.children?.[1] ?? root.children?.[0]
if (priorityPart) {
expanded.add(priorityPart.id)
}
for (const component of priorityPart?.children?.slice(0, 2) ?? []) {
expanded.add(component.id)
}
return expanded
}
export function defaultSelectedNodeId(root: ProductionNode) {
const priorityPart = root.children?.[1] ?? root.children?.[0]
const priorityComponent = priorityPart?.children?.[1] ?? priorityPart?.children?.[0]
const priorityOperation = priorityComponent?.children?.[0]
return priorityOperation?.id ?? priorityComponent?.id ?? priorityPart?.id ?? root.id
}
export function recalculateTree(node: ProductionNode): ProductionNode {
const children = node.children?.map(recalculateTree) ?? []
const hasChildren = children.length > 0
if (!hasChildren) {
const progress = node.requiredQty <= 0
? 0
: clamp((node.completedQty / node.requiredQty) * 100, 0, 100)
const status = deriveLeafStatus(node, progress)
const riskLevel = deriveLeafRisk(node, progress, status)
return {
...node,
completedQty: Math.min(node.completedQty, node.requiredQty),
defectQty: Math.min(node.defectQty, Math.max(0, node.completedQty)),
progress,
status,
riskLevel,
}
}
const totalWeight = children.reduce((total, child) => total + child.requiredQty, 0)
const weightedProgress =
totalWeight === 0
? 0
: children.reduce(
(total, child) => total + child.progress * child.requiredQty,
0,
) / totalWeight
const delayDays = Math.max(node.delayDays, ...children.map((child) => child.delayDays))
const status = deriveParentStatus(children, weightedProgress)
const riskLevel = deriveParentRisk(children, delayDays, status)
return {
...node,
children,
completedQty: Math.round((node.requiredQty * weightedProgress) / 100),
defectQty: children.reduce((total, child) => total + child.defectQty, 0),
progress: weightedProgress,
status,
riskLevel,
delayDays,
}
}
export function recalculateOrder(order: OrderSummary): OrderSummary {
const root = recalculateTree(order.root)
const nodes = flattenTree(root)
const events = buildRiskEvents(order, nodes)
const riskCount = nodes.filter((node) => node.riskLevel !== 'normal').length
const plannedQty = Math.max(root.completedQty, Math.round(root.requiredQty * clamp(root.progress + 10, 0, 100) / 100))
const planAchievement = plannedQty === 0 ? 0 : clamp((root.completedQty / plannedQty) * 100, 0, 120)
return {
...order,
root,
requiredQty: root.requiredQty,
completedQty: root.completedQty,
progress: root.progress,
status: root.status,
riskLevel: root.riskLevel,
delayDays: root.delayDays,
planAchievement,
dailyDelta: clamp(order.dailyDelta + (Math.random() - 0.45) * 0.28, -9.9, 9.9),
events,
trend: [
...order.trend.slice(-11),
{
time: formatTime(),
completion: Math.round(root.progress),
risk: riskCount,
plannedQty,
actualQty: root.completedQty,
achievement: planAchievement,
},
],
}
}
export function simulateOrdersTick(orders: OrderSummary[]): OrderSummary[] {
return orders.map((order, orderIndex) => {
const root = updateLeaves(order.root, orderIndex)
return recalculateOrder({ ...order, root })
})
}
function updateLeaves(node: ProductionNode, orderIndex: number): ProductionNode {
if (node.children?.length) {
return {
...node,
children: node.children.map((child) => updateLeaves(child, orderIndex)),
}
}
if (node.status === 'done') {
return node
}
const steadyBlocked = node.status === 'blocked' && Math.random() < 0.72
const criticalPause = node.riskLevel === 'critical' && Math.random() < 0.45
if (steadyBlocked || criticalPause) {
return node
}
const baseRate =
node.supplyType === 'self_made'
? 0.055
: node.supplyType === 'outsourced'
? 0.036
: 0.028
const momentum = 0.65 + Math.random() * 0.7 + orderIndex * 0.035
const increment = Math.max(1, Math.round(node.requiredQty * baseRate * momentum))
const completedQty = Math.min(node.requiredQty, node.completedQty + increment)
const progress = clamp((completedQty / node.requiredQty) * 100, 0, 100)
const defectBump =
node.riskLevel === 'critical'
? Math.random() < 0.28
: node.riskLevel === 'warning'
? Math.random() < 0.14
: Math.random() < 0.04
const delayDays =
progress < 100 && node.riskLevel !== 'normal' && Math.random() < 0.12
? Math.min(node.delayDays + 1, 9)
: node.delayDays
return {
...node,
completedQty,
defectQty: Math.min(completedQty, node.defectQty + (defectBump ? Math.max(1, Math.round(increment * 0.018)) : 0)),
progress,
delayDays,
actualStart: node.actualStart || node.plannedStart,
actualEnd: completedQty >= node.requiredQty ? formatDateOffset(0) : '',
}
}
function deriveLeafStatus(
node: ProductionNode,
progress: number,
): ProductionStatus {
if (progress >= 100) {
return 'done'
}
if (node.status === 'blocked') {
return 'blocked'
}
if (node.delayDays > 0 || node.riskLevel === 'critical') {
return 'delayed'
}
if (progress > 0) {
return 'in_progress'
}
return 'waiting'
}
function deriveLeafRisk(
node: ProductionNode,
progress: number,
status: ProductionStatus,
): RiskLevel {
if (status === 'blocked' || node.delayDays >= 3) {
return 'critical'
}
if (status === 'delayed' || node.delayDays > 0 || progress < 35) {
return 'warning'
}
return 'normal'
}
function deriveParentStatus(
children: ProductionNode[],
progress: number,
): ProductionStatus {
if (children.some((child) => child.status === 'blocked')) {
return 'blocked'
}
if (children.some((child) => child.status === 'delayed')) {
return 'delayed'
}
if (progress >= 100) {
return 'done'
}
if (progress > 0) {
return 'in_progress'
}
return 'waiting'
}
function deriveParentRisk(
children: ProductionNode[],
delayDays: number,
status: ProductionStatus,
): RiskLevel {
if (
delayDays >= 3 ||
status === 'blocked' ||
children.some((child) => child.riskLevel === 'critical')
) {
return 'critical'
}
if (
delayDays > 0 ||
status === 'delayed' ||
children.some((child) => child.riskLevel === 'warning')
) {
return 'warning'
}
return 'normal'
}
function buildRiskEvents(order: OrderSummary, nodes: ProductionNode[]): RiskEvent[] {
return nodes
.filter((node) => node.riskLevel !== 'normal' || node.delayDays > 0)
.sort((a, b) => {
const riskWeight = riskRank(b.riskLevel) - riskRank(a.riskLevel)
return riskWeight || b.delayDays - a.delayDays
})
.slice(0, 8)
.map((node, index) => ({
id: `${order.id}-${node.id}-${index}`,
orderId: order.id,
nodeId: node.id,
orderCode: order.code,
nodeName: node.name,
riskLevel: node.riskLevel,
message:
node.delayReason ||
(node.delayDays > 0
? `计划偏差 ${node.delayDays} 天,需要复核节拍`
: '进度低于当前节拍预测'),
time: formatTime(new Date(Date.now() - index * 1000 * 64)),
}))
}
function riskRank(riskLevel: RiskLevel) {
if (riskLevel === 'critical') {
return 3
}
if (riskLevel === 'warning') {
return 2
}
return 1
}
function formatDateOffset(offset: number) {
const date = new Date()
date.setDate(date.getDate() + offset)
return date.toISOString().slice(0, 10)
}
+27
View File
@@ -0,0 +1,27 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
export function formatPercent(value: number) {
return `${Math.round(value)}%`
}
export function formatQuantity(value: number) {
return new Intl.NumberFormat('zh-CN').format(Math.round(value))
}
export function formatTime(date = new Date()) {
return date.toLocaleTimeString('zh-CN', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+137
View File
@@ -0,0 +1,137 @@
export type SupplyType = 'self_made' | 'outsourced' | 'purchased'
export type ProductionStatus =
| 'waiting'
| 'in_progress'
| 'done'
| 'delayed'
| 'blocked'
export type RiskLevel = 'normal' | 'warning' | 'critical'
export type NodeType = 'order' | 'part' | 'component' | 'process' | 'material'
export type VisualKey =
| 'industrial-pc'
| 'workstation'
| 'aio'
| 'edge-box'
| 'mainboard'
| 'chip'
| 'memory'
| 'storage'
| 'chassis'
| 'thermal'
| 'power'
| 'cable'
| 'fixture'
| 'package'
| 'generic'
export interface ProductionNode {
id: string
orderId: string
parentId: string | null
code: string
operationCode: string
materialCode: string
name: string
sourceSequence?: string
specification?: string
material?: string
unitWeight?: number | null
totalWeight?: number | null
unitWeightText?: string
totalWeightText?: string
bomQuantity?: number | null
bomQuantityText?: string
remark?: string
sourceSheet?: string
sourceRow?: number
level: number
nodeType: NodeType
supplyType: SupplyType
requiredQty: number
completedQty: number
defectQty: number
progress: number
status: ProductionStatus
riskLevel: RiskLevel
delayDays: number
delayReason: string
plannedStart: string
plannedEnd: string
actualStart: string
actualEnd: string
owner: string
vendor: string
stationName: string
visualKey: VisualKey
version?: string
children?: ProductionNode[]
}
export interface MaterialQuota {
id: string
orderId: string
category: string
sourceSequence: string
materialCode: string
materialName: string
specification: string
unit: string
quantity: number | null
netWeight: number | null
consumptionQuota: number | null
quantityText: string
netWeightText: string
consumptionQuotaText: string
brand: string
remark: string
sourceSheet: string
sourceRow: number
}
export interface TrendPoint {
time: string
completion: number
risk: number
plannedQty: number
actualQty: number
achievement: number
}
export interface RiskEvent {
id: string
orderId: string
nodeId: string
orderCode: string
nodeName: string
riskLevel: RiskLevel
message: string
time: string
handlingStatus?: 'processing' | 'resolved'
}
export interface OrderSummary {
id: string
code: string
productName: string
batchQty: number
requiredQty: number
completedQty: number
progress: number
status: ProductionStatus
riskLevel: RiskLevel
delayDays: number
plannedStart: string
plannedEnd: string
lineName: string
owner: string
planAchievement: number
dailyDelta: number
thumbnailKey: VisualKey
root: ProductionNode
trend: TrendPoint[]
events: RiskEvent[]
}
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"ignoreDeprecations": "6.0",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"ignoreDeprecations": "6.0",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+39
View File
@@ -0,0 +1,39 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
chunkSizeWarningLimit: 650,
rollupOptions: {
onwarn(warning, defaultHandler) {
if (
warning.code === 'INVALID_ANNOTATION'
&& warning.id?.includes('@microsoft/signalr')
) {
return
}
defaultHandler(warning)
},
output: {
manualChunks(id) {
if (id.includes('echarts') || id.includes('zrender')) {
return 'charts'
}
if (id.includes('reactflow') || id.includes('@reactflow')) {
return 'flow'
}
return undefined
},
},
},
},
})