feat: initialize manufacturing progress dashboard
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"dotnet-ef": {
|
||||
"version": "8.0.20",
|
||||
"commands": [
|
||||
"dotnet-ef"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
VITE_DASHBOARD_API_URL=http://localhost:5080
|
||||
+29
@@ -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?
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
@@ -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>
|
||||
Generated
+2957
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+457
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+652
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+651
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+648
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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[]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user