commit 658a23b6306921f3a958b172656a07d8ac51074c Author: czc <5+czc@noreply.localhost> Date: Mon Jul 13 18:07:59 2026 +0800 feat: initialize manufacturing progress dashboard diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..717b02d --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.20", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..284af97 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +VITE_DASHBOARD_API_URL=http://localhost:5080 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eda7ba --- /dev/null +++ b/.gitignore @@ -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? diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -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 }] + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..38c0e2d --- /dev/null +++ b/README.md @@ -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 +``` diff --git a/components.json b/components.json new file mode 100644 index 0000000..f846ad7 --- /dev/null +++ b/components.json @@ -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" +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..7356696 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + 制造订单进度驾驶舱 + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ef25297 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2957 @@ +{ + "name": "bi", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bi", + "version": "0.0.0", + "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" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@microsoft/signalr": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@microsoft/signalr/-/signalr-10.0.0.tgz", + "integrity": "sha512-0BRqz/uCx3JdrOqiqgFhih/+hfTERaUfCZXFB52uMaZJrKaPRzHzMuqVsJC/V3pt7NozcNXGspjKiQEK+X7P2w==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "eventsource": "^2.0.2", + "fetch-cookie": "^2.0.3", + "node-fetch": "^2.6.7", + "ws": "^7.5.10" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.73.0.tgz", + "integrity": "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.73.0.tgz", + "integrity": "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.73.0.tgz", + "integrity": "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.73.0.tgz", + "integrity": "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.73.0.tgz", + "integrity": "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.73.0.tgz", + "integrity": "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.73.0.tgz", + "integrity": "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.73.0.tgz", + "integrity": "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.73.0.tgz", + "integrity": "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.73.0.tgz", + "integrity": "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.73.0.tgz", + "integrity": "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.73.0.tgz", + "integrity": "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.73.0.tgz", + "integrity": "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.73.0.tgz", + "integrity": "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.73.0.tgz", + "integrity": "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.73.0.tgz", + "integrity": "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.73.0.tgz", + "integrity": "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.73.0.tgz", + "integrity": "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.73.0.tgz", + "integrity": "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@reactflow/background": { + "version": "11.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz", + "integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/controls": { + "version": "11.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz", + "integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/core": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz", + "integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==", + "license": "MIT", + "dependencies": { + "@types/d3": "^7.4.0", + "@types/d3-drag": "^3.0.1", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/minimap": { + "version": "11.7.14", + "resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz", + "integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "@types/d3-selection": "^3.0.3", + "@types/d3-zoom": "^3.0.1", + "classcat": "^5.0.3", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-resizer": { + "version": "2.2.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz", + "integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.4", + "d3-drag": "^3.0.0", + "d3-selection": "^3.0.0", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@reactflow/node-toolbar": { + "version": "1.3.14", + "resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz", + "integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==", + "license": "MIT", + "dependencies": { + "@reactflow/core": "11.11.4", + "classcat": "^5.0.3", + "zustand": "^4.4.1" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.4.tgz", + "integrity": "sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-cookie": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/fetch-cookie/-/fetch-cookie-2.2.0.tgz", + "integrity": "sha512-h9AgfjURuCgA2+2ISl8GbavpUdR+WGAM2McW/ovn4tVccegp8ZqCKWSBR8uRdM8dDNlx5WdKRWxBYUwteLDCNQ==", + "license": "Unlicense", + "dependencies": { + "set-cookie-parser": "^2.4.8", + "tough-cookie": "^4.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/oxlint": { + "version": "1.73.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.73.0.tgz", + "integrity": "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.73.0", + "@oxlint/binding-android-arm64": "1.73.0", + "@oxlint/binding-darwin-arm64": "1.73.0", + "@oxlint/binding-darwin-x64": "1.73.0", + "@oxlint/binding-freebsd-x64": "1.73.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", + "@oxlint/binding-linux-arm-musleabihf": "1.73.0", + "@oxlint/binding-linux-arm64-gnu": "1.73.0", + "@oxlint/binding-linux-arm64-musl": "1.73.0", + "@oxlint/binding-linux-ppc64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-gnu": "1.73.0", + "@oxlint/binding-linux-riscv64-musl": "1.73.0", + "@oxlint/binding-linux-s390x-gnu": "1.73.0", + "@oxlint/binding-linux-x64-gnu": "1.73.0", + "@oxlint/binding-linux-x64-musl": "1.73.0", + "@oxlint/binding-openharmony-arm64": "1.73.0", + "@oxlint/binding-win32-arm64-msvc": "1.73.0", + "@oxlint/binding-win32-ia32-msvc": "1.73.0", + "@oxlint/binding-win32-x64-msvc": "1.73.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.24.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/reactflow": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz", + "integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==", + "license": "MIT", + "dependencies": { + "@reactflow/background": "11.3.14", + "@reactflow/controls": "11.2.14", + "@reactflow/core": "11.11.4", + "@reactflow/minimap": "11.7.14", + "@reactflow/node-resizer": "2.2.14", + "@reactflow/node-toolbar": "1.3.14" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/size-sensor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", + "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1d1323c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/server/DongfangHydro.Dashboard.Api/Contracts/DashboardContracts.cs b/server/DongfangHydro.Dashboard.Api/Contracts/DashboardContracts.cs new file mode 100644 index 0000000..1477b9c --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Contracts/DashboardContracts.cs @@ -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 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 Trend, + IReadOnlyList 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( + IReadOnlyList 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); diff --git a/server/DongfangHydro.Dashboard.Api/Data/DashboardDbContext.cs b/server/DongfangHydro.Dashboard.Api/Data/DashboardDbContext.cs new file mode 100644 index 0000000..9a5102c --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/DashboardDbContext.cs @@ -0,0 +1,172 @@ +using DongfangHydro.Dashboard.Api.Domain; +using Microsoft.EntityFrameworkCore; + +namespace DongfangHydro.Dashboard.Api.Data; + +public sealed class DashboardDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet ProductionOrders => Set(); + public DbSet ProductionNodes => Set(); + public DbSet ProductionTrendPoints => Set(); + public DbSet RiskEvents => Set(); + public DbSet ProductionLines => Set(); + public DbSet Partners => Set(); + public DbSet MaterialQuotas => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(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() + .WithMany() + .HasForeignKey(order => order.LineId) + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity(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() + .WithMany() + .HasForeignKey(node => node.OrderId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne() + .WithMany() + .HasForeignKey(node => new { node.OrderId, node.ParentNodeId }) + .HasPrincipalKey(node => new { node.OrderId, node.Id }) + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("ProductionTrendPoints"); + entity.HasKey(point => point.Id); + entity.HasIndex(point => new { point.OrderId, point.SampleTime }).IsDescending(false, true); + entity.HasOne() + .WithMany() + .HasForeignKey(point => point.OrderId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(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() + .WithMany() + .HasForeignKey(riskEvent => riskEvent.OrderId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne() + .WithMany() + .HasForeignKey(riskEvent => new { riskEvent.OrderId, riskEvent.NodeId }) + .HasPrincipalKey(node => new { node.OrderId, node.Id }) + .OnDelete(DeleteBehavior.NoAction); + }); + + modelBuilder.Entity(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() + .WithMany() + .HasForeignKey(item => item.OrderId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(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(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); + }); + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/DashboardSeeder.cs b/server/DongfangHydro.Dashboard.Api/Data/DashboardSeeder.cs new file mode 100644 index 0000000..5b28278 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/DashboardSeeder.cs @@ -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 BuildNodes( + ProductionOrder order, + OrderBlueprint blueprint, + int orderIndex, + DateTimeOffset now) + { + var nodes = new List(); + 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 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 BuildRiskEvents( + ProductionOrder order, + IReadOnlyCollection 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); +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/DatabaseInitializer.cs b/server/DongfangHydro.Dashboard.Api/Data/DatabaseInitializer.cs new file mode 100644 index 0000000..13ddf87 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/DatabaseInitializer.cs @@ -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(); + + if (autoMigrate) + { + if (dbContext.Database.IsRelational()) + { + await dbContext.Database.MigrateAsync(); + } + else + { + await dbContext.Database.EnsureCreatedAsync(); + } + } + + if (seed) + { + await scope.ServiceProvider.GetRequiredService().SeedAsync(CancellationToken.None); + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710025438_InitialCreate.Designer.cs b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710025438_InitialCreate.Designer.cs new file mode 100644 index 0000000..8406a1d --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710025438_InitialCreate.Designer.cs @@ -0,0 +1,457 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetime2"); + + b.Property("ActualStart") + .HasColumnType("datetime2"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("CompletedQty") + .HasColumnType("int"); + + b.Property("DefectQty") + .HasColumnType("int"); + + b.Property("DelayDays") + .HasColumnType("int"); + + b.Property("DelayReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("MaterialCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("OperationCode") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ParentNodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlannedEnd") + .HasColumnType("datetime2"); + + b.Property("PlannedStart") + .HasColumnType("datetime2"); + + b.Property("Progress") + .HasColumnType("int"); + + b.Property("RequiredQty") + .HasColumnType("int"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("StationName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SupplyType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Vendor") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BatchQty") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CompletedQty") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DailyDelta") + .HasColumnType("float"); + + b.Property("DelayDays") + .HasColumnType("int"); + + b.Property("LineId") + .HasColumnType("uniqueidentifier"); + + b.Property("LineName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("PlanAchievement") + .HasColumnType("float"); + + b.Property("PlannedEnd") + .HasColumnType("datetime2"); + + b.Property("PlannedStart") + .HasColumnType("datetime2"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Progress") + .HasColumnType("int"); + + b.Property("RequiredQty") + .HasColumnType("int"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ThumbnailKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Achievement") + .HasColumnType("float"); + + b.Property("ActualQty") + .HasColumnType("int"); + + b.Property("Completion") + .HasColumnType("int"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlannedQty") + .HasColumnType("int"); + + b.Property("Risk") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("HandlingStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("NodeName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrderCode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("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 + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710025438_InitialCreate.cs b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710025438_InitialCreate.cs new file mode 100644 index 0000000..7a87c0e --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710025438_InitialCreate.cs @@ -0,0 +1,266 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DongfangHydro.Dashboard.Api.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Partners", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Code = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + PartnerType = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + ContactName = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + ContactPhone = table.Column(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(type: "uniqueidentifier", nullable: false), + Code = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + Name = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + Owner = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + IsActive = table.Column(type: "bit", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProductionLines", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "ProductionOrders", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + Code = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + ProductName = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + BatchQty = table.Column(type: "int", nullable: false), + RequiredQty = table.Column(type: "int", nullable: false), + CompletedQty = table.Column(type: "int", nullable: false), + Progress = table.Column(type: "int", nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + RiskLevel = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + DelayDays = table.Column(type: "int", nullable: false), + PlannedStart = table.Column(type: "datetime2", nullable: false), + PlannedEnd = table.Column(type: "datetime2", nullable: false), + LineId = table.Column(type: "uniqueidentifier", nullable: true), + LineName = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + Owner = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + PlanAchievement = table.Column(type: "float", nullable: false), + DailyDelta = table.Column(type: "float", nullable: false), + ThumbnailKey = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + CreatedAt = table.Column(type: "datetimeoffset", nullable: false), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false), + RowVersion = table.Column(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(type: "uniqueidentifier", nullable: false), + OrderId = table.Column(type: "uniqueidentifier", nullable: false), + ParentNodeId = table.Column(type: "uniqueidentifier", nullable: true), + Code = table.Column(type: "nvarchar(60)", maxLength: 60, nullable: false), + OperationCode = table.Column(type: "nvarchar(60)", maxLength: 60, nullable: false), + MaterialCode = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + Name = table.Column(type: "nvarchar(240)", maxLength: 240, nullable: false), + Level = table.Column(type: "int", nullable: false), + NodeType = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + SupplyType = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + RequiredQty = table.Column(type: "int", nullable: false), + CompletedQty = table.Column(type: "int", nullable: false), + DefectQty = table.Column(type: "int", nullable: false), + Progress = table.Column(type: "int", nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + RiskLevel = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + DelayDays = table.Column(type: "int", nullable: false), + DelayReason = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + PlannedStart = table.Column(type: "datetime2", nullable: false), + PlannedEnd = table.Column(type: "datetime2", nullable: false), + ActualStart = table.Column(type: "datetime2", nullable: true), + ActualEnd = table.Column(type: "datetime2", nullable: true), + Owner = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Vendor = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + StationName = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + VisualKey = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + SortOrder = table.Column(type: "int", nullable: false), + UpdatedAt = table.Column(type: "datetimeoffset", nullable: false), + RowVersion = table.Column(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(type: "uniqueidentifier", nullable: false), + OrderId = table.Column(type: "uniqueidentifier", nullable: false), + SampleTime = table.Column(type: "datetimeoffset", nullable: false), + Completion = table.Column(type: "int", nullable: false), + Risk = table.Column(type: "int", nullable: false), + PlannedQty = table.Column(type: "int", nullable: false), + ActualQty = table.Column(type: "int", nullable: false), + Achievement = table.Column(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(type: "uniqueidentifier", nullable: false), + OrderId = table.Column(type: "uniqueidentifier", nullable: false), + NodeId = table.Column(type: "uniqueidentifier", nullable: false), + OrderCode = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + NodeName = table.Column(type: "nvarchar(240)", maxLength: 240, nullable: false), + RiskLevel = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Message = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + HandlingStatus = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + OccurredAt = table.Column(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 }); + } + + /// + 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"); + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710095939_ImportRealBom.Designer.cs b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710095939_ImportRealBom.Designer.cs new file mode 100644 index 0000000..84959f1 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710095939_ImportRealBom.Designer.cs @@ -0,0 +1,652 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Brand") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ConsumptionQuota") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ConsumptionQuotaText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("MaterialCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("MaterialName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("NetWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QuantityText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SourceRow") + .HasColumnType("int"); + + b.Property("SourceSequence") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SourceSheet") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Specification") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetime2"); + + b.Property("ActualStart") + .HasColumnType("datetime2"); + + b.Property("BomQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BomQuantityText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("CompletedQty") + .HasColumnType("int"); + + b.Property("DefectQty") + .HasColumnType("int"); + + b.Property("DelayDays") + .HasColumnType("int"); + + b.Property("DelayReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Material") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("MaterialCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("OperationCode") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ParentNodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlannedEnd") + .HasColumnType("datetime2"); + + b.Property("PlannedStart") + .HasColumnType("datetime2"); + + b.Property("Progress") + .HasColumnType("int"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequiredQty") + .HasColumnType("int"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("SourceRow") + .HasColumnType("int"); + + b.Property("SourceSequence") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SourceSheet") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Specification") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("StationName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SupplyType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TotalWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UnitWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UnitWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Vendor") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BatchQty") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CompletedQty") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DailyDelta") + .HasColumnType("float"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DelayDays") + .HasColumnType("int"); + + b.Property("DrawingProductName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("ImportedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LineId") + .HasColumnType("uniqueidentifier"); + + b.Property("LineName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("PlanAchievement") + .HasColumnType("float"); + + b.Property("PlannedEnd") + .HasColumnType("datetime2"); + + b.Property("PlannedStart") + .HasColumnType("datetime2"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Progress") + .HasColumnType("int"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiredQty") + .HasColumnType("int"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SourceDocumentCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("SourceFileHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ThumbnailKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Achievement") + .HasColumnType("float"); + + b.Property("ActualQty") + .HasColumnType("int"); + + b.Property("Completion") + .HasColumnType("int"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlannedQty") + .HasColumnType("int"); + + b.Property("Risk") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("HandlingStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("NodeName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrderCode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("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 + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710095939_ImportRealBom.cs b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710095939_ImportRealBom.cs new file mode 100644 index 0000000..3c0f5fd --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710095939_ImportRealBom.cs @@ -0,0 +1,378 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DongfangHydro.Dashboard.Api.Data.Migrations +{ + /// + public partial class ImportRealBom : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "PlannedStart", + table: "ProductionOrders", + type: "datetime2", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "datetime2"); + + migrationBuilder.AlterColumn( + name: "PlannedEnd", + table: "ProductionOrders", + type: "datetime2", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "datetime2"); + + migrationBuilder.AddColumn( + name: "DataSource", + table: "ProductionOrders", + type: "nvarchar(32)", + maxLength: 32, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "DrawingProductName", + table: "ProductionOrders", + type: "nvarchar(240)", + maxLength: 240, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "ImportedAt", + table: "ProductionOrders", + type: "datetimeoffset", + nullable: true); + + migrationBuilder.AddColumn( + name: "ProjectName", + table: "ProductionOrders", + type: "nvarchar(200)", + maxLength: 200, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SourceDocumentCode", + table: "ProductionOrders", + type: "nvarchar(80)", + maxLength: 80, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SourceFileHash", + table: "ProductionOrders", + type: "nvarchar(64)", + maxLength: 64, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + 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( + name: "PlannedStart", + table: "ProductionNodes", + type: "datetime2", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "datetime2"); + + migrationBuilder.AlterColumn( + name: "PlannedEnd", + table: "ProductionNodes", + type: "datetime2", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "datetime2"); + + migrationBuilder.AddColumn( + name: "BomQuantity", + table: "ProductionNodes", + type: "decimal(18,4)", + precision: 18, + scale: 4, + nullable: true); + + migrationBuilder.AddColumn( + name: "BomQuantityText", + table: "ProductionNodes", + type: "nvarchar(80)", + maxLength: 80, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Material", + table: "ProductionNodes", + type: "nvarchar(120)", + maxLength: 120, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Remark", + table: "ProductionNodes", + type: "nvarchar(500)", + maxLength: 500, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SourceRow", + table: "ProductionNodes", + type: "int", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "SourceSequence", + table: "ProductionNodes", + type: "nvarchar(40)", + maxLength: 40, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "SourceSheet", + table: "ProductionNodes", + type: "nvarchar(40)", + maxLength: 40, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "Specification", + table: "ProductionNodes", + type: "nvarchar(240)", + maxLength: 240, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "TotalWeight", + table: "ProductionNodes", + type: "decimal(18,4)", + precision: 18, + scale: 4, + nullable: true); + + migrationBuilder.AddColumn( + name: "TotalWeightText", + table: "ProductionNodes", + type: "nvarchar(80)", + maxLength: 80, + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "UnitWeight", + table: "ProductionNodes", + type: "decimal(18,4)", + precision: 18, + scale: 4, + nullable: true); + + migrationBuilder.AddColumn( + name: "UnitWeightText", + table: "ProductionNodes", + type: "nvarchar(80)", + maxLength: 80, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "MaterialQuotas", + columns: table => new + { + Id = table.Column(type: "uniqueidentifier", nullable: false), + OrderId = table.Column(type: "uniqueidentifier", nullable: false), + Category = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + SourceSequence = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + MaterialCode = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + MaterialName = table.Column(type: "nvarchar(240)", maxLength: 240, nullable: false), + Specification = table.Column(type: "nvarchar(240)", maxLength: 240, nullable: false), + Unit = table.Column(type: "nvarchar(24)", maxLength: 24, nullable: false), + Quantity = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true), + NetWeight = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true), + ConsumptionQuota = table.Column(type: "decimal(18,4)", precision: 18, scale: 4, nullable: true), + QuantityText = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + NetWeightText = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + ConsumptionQuotaText = table.Column(type: "nvarchar(80)", maxLength: 80, nullable: false), + Brand = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + Remark = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + SourceSheet = table.Column(type: "nvarchar(40)", maxLength: 40, nullable: false), + SourceRow = table.Column(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); + } + + /// + 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( + 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( + 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( + 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( + 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); + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710103033_AllowRepeatedWorkOrderCodes.Designer.cs b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710103033_AllowRepeatedWorkOrderCodes.Designer.cs new file mode 100644 index 0000000..701f681 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710103033_AllowRepeatedWorkOrderCodes.Designer.cs @@ -0,0 +1,651 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Brand") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ConsumptionQuota") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ConsumptionQuotaText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("MaterialCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("MaterialName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("NetWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QuantityText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SourceRow") + .HasColumnType("int"); + + b.Property("SourceSequence") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SourceSheet") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Specification") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetime2"); + + b.Property("ActualStart") + .HasColumnType("datetime2"); + + b.Property("BomQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BomQuantityText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("CompletedQty") + .HasColumnType("int"); + + b.Property("DefectQty") + .HasColumnType("int"); + + b.Property("DelayDays") + .HasColumnType("int"); + + b.Property("DelayReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Material") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("MaterialCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("OperationCode") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ParentNodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlannedEnd") + .HasColumnType("datetime2"); + + b.Property("PlannedStart") + .HasColumnType("datetime2"); + + b.Property("Progress") + .HasColumnType("int"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequiredQty") + .HasColumnType("int"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("SourceRow") + .HasColumnType("int"); + + b.Property("SourceSequence") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SourceSheet") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Specification") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("StationName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SupplyType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TotalWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UnitWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UnitWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Vendor") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BatchQty") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CompletedQty") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DailyDelta") + .HasColumnType("float"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DelayDays") + .HasColumnType("int"); + + b.Property("DrawingProductName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("ImportedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LineId") + .HasColumnType("uniqueidentifier"); + + b.Property("LineName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("PlanAchievement") + .HasColumnType("float"); + + b.Property("PlannedEnd") + .HasColumnType("datetime2"); + + b.Property("PlannedStart") + .HasColumnType("datetime2"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Progress") + .HasColumnType("int"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiredQty") + .HasColumnType("int"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SourceDocumentCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("SourceFileHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ThumbnailKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Achievement") + .HasColumnType("float"); + + b.Property("ActualQty") + .HasColumnType("int"); + + b.Property("Completion") + .HasColumnType("int"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlannedQty") + .HasColumnType("int"); + + b.Property("Risk") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("HandlingStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("NodeName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrderCode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("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 + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710103033_AllowRepeatedWorkOrderCodes.cs b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710103033_AllowRepeatedWorkOrderCodes.cs new file mode 100644 index 0000000..1da13e8 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/Migrations/20260710103033_AllowRepeatedWorkOrderCodes.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DongfangHydro.Dashboard.Api.Data.Migrations +{ + /// + public partial class AllowRepeatedWorkOrderCodes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ProductionOrders_Code", + table: "ProductionOrders"); + + migrationBuilder.CreateIndex( + name: "IX_ProductionOrders_Code", + table: "ProductionOrders", + column: "Code"); + } + + /// + 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); + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/Migrations/DashboardDbContextModelSnapshot.cs b/server/DongfangHydro.Dashboard.Api/Data/Migrations/DashboardDbContextModelSnapshot.cs new file mode 100644 index 0000000..b1d81d8 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/Migrations/DashboardDbContextModelSnapshot.cs @@ -0,0 +1,648 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Brand") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ConsumptionQuota") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("ConsumptionQuotaText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("MaterialCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("MaterialName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("NetWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("NetWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Quantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("QuantityText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("SourceRow") + .HasColumnType("int"); + + b.Property("SourceSequence") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SourceSheet") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Specification") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("ContactName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("ContactPhone") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ActualEnd") + .HasColumnType("datetime2"); + + b.Property("ActualStart") + .HasColumnType("datetime2"); + + b.Property("BomQuantity") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("BomQuantityText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("CompletedQty") + .HasColumnType("int"); + + b.Property("DefectQty") + .HasColumnType("int"); + + b.Property("DelayDays") + .HasColumnType("int"); + + b.Property("DelayReason") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Level") + .HasColumnType("int"); + + b.Property("Material") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("MaterialCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("NodeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("OperationCode") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("nvarchar(60)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ParentNodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlannedEnd") + .HasColumnType("datetime2"); + + b.Property("PlannedStart") + .HasColumnType("datetime2"); + + b.Property("Progress") + .HasColumnType("int"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("RequiredQty") + .HasColumnType("int"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SortOrder") + .HasColumnType("int"); + + b.Property("SourceRow") + .HasColumnType("int"); + + b.Property("SourceSequence") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("SourceSheet") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("Specification") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("StationName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("SupplyType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TotalWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("TotalWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UnitWeight") + .HasPrecision(18, 4) + .HasColumnType("decimal(18,4)"); + + b.Property("UnitWeightText") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UpdatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("Vendor") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("BatchQty") + .HasColumnType("int"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("CompletedQty") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetimeoffset"); + + b.Property("DailyDelta") + .HasColumnType("float"); + + b.Property("DataSource") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("DelayDays") + .HasColumnType("int"); + + b.Property("DrawingProductName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("ImportedAt") + .HasColumnType("datetimeoffset"); + + b.Property("LineId") + .HasColumnType("uniqueidentifier"); + + b.Property("LineName") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("Owner") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("PlanAchievement") + .HasColumnType("float"); + + b.Property("PlannedEnd") + .HasColumnType("datetime2"); + + b.Property("PlannedStart") + .HasColumnType("datetime2"); + + b.Property("ProductName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Progress") + .HasColumnType("int"); + + b.Property("ProjectName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("RequiredQty") + .HasColumnType("int"); + + b.Property("RiskLevel") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .IsRequired() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.Property("SourceDocumentCode") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("SourceFileHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("SourceFileName") + .IsRequired() + .HasMaxLength(260) + .HasColumnType("nvarchar(260)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("ThumbnailKey") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Achievement") + .HasColumnType("float"); + + b.Property("ActualQty") + .HasColumnType("int"); + + b.Property("Completion") + .HasColumnType("int"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("PlannedQty") + .HasColumnType("int"); + + b.Property("Risk") + .HasColumnType("int"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("HandlingStatus") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NodeId") + .HasColumnType("uniqueidentifier"); + + b.Property("NodeName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("nvarchar(240)"); + + b.Property("OccurredAt") + .HasColumnType("datetimeoffset"); + + b.Property("OrderCode") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("nvarchar(40)"); + + b.Property("OrderId") + .HasColumnType("uniqueidentifier"); + + b.Property("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 + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Data/Migrations/InitialCreate.sql b/server/DongfangHydro.Dashboard.Api/Data/Migrations/InitialCreate.sql new file mode 100644 index 0000000..29e54c5 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Data/Migrations/InitialCreate.sql @@ -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 diff --git a/server/DongfangHydro.Dashboard.Api/Domain/MaterialQuota.cs b/server/DongfangHydro.Dashboard.Api/Domain/MaterialQuota.cs new file mode 100644 index 0000000..bb28ea5 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Domain/MaterialQuota.cs @@ -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; } +} diff --git a/server/DongfangHydro.Dashboard.Api/Domain/Partner.cs b/server/DongfangHydro.Dashboard.Api/Domain/Partner.cs new file mode 100644 index 0000000..315c6f2 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Domain/Partner.cs @@ -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; +} diff --git a/server/DongfangHydro.Dashboard.Api/Domain/ProductionLine.cs b/server/DongfangHydro.Dashboard.Api/Domain/ProductionLine.cs new file mode 100644 index 0000000..1a4cf18 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Domain/ProductionLine.cs @@ -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; +} diff --git a/server/DongfangHydro.Dashboard.Api/Domain/ProductionNode.cs b/server/DongfangHydro.Dashboard.Api/Domain/ProductionNode.cs new file mode 100644 index 0000000..8957a0d --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Domain/ProductionNode.cs @@ -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; } = []; +} diff --git a/server/DongfangHydro.Dashboard.Api/Domain/ProductionOrder.cs b/server/DongfangHydro.Dashboard.Api/Domain/ProductionOrder.cs new file mode 100644 index 0000000..edb663a --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Domain/ProductionOrder.cs @@ -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; } = []; +} diff --git a/server/DongfangHydro.Dashboard.Api/Domain/ProductionTrendPoint.cs b/server/DongfangHydro.Dashboard.Api/Domain/ProductionTrendPoint.cs new file mode 100644 index 0000000..5c16b56 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Domain/ProductionTrendPoint.cs @@ -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; } +} diff --git a/server/DongfangHydro.Dashboard.Api/Domain/RiskEvent.cs b/server/DongfangHydro.Dashboard.Api/Domain/RiskEvent.cs new file mode 100644 index 0000000..36a9b94 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Domain/RiskEvent.cs @@ -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; } +} diff --git a/server/DongfangHydro.Dashboard.Api/DongfangHydro.Dashboard.Api.csproj b/server/DongfangHydro.Dashboard.Api/DongfangHydro.Dashboard.Api.csproj new file mode 100644 index 0000000..7bdd6ad --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/DongfangHydro.Dashboard.Api.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + DongfangHydro.Dashboard.Api + DongfangHydro.Dashboard.Api + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/server/DongfangHydro.Dashboard.Api/Importing/BomImportCommand.cs b/server/DongfangHydro.Dashboard.Api/Importing/BomImportCommand.cs new file mode 100644 index 0000000..5298ed3 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Importing/BomImportCommand.cs @@ -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 。"); + } + + return new BomImportCommand(filePath, purgeDemo, validateOnly, force); + } +} + +public static class BomImportCommandRunner +{ + public static async Task 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(); + if (dbContext.Database.IsRelational()) + { + await dbContext.Database.MigrateAsync(cancellationToken); + } + else + { + await dbContext.Database.EnsureCreatedAsync(cancellationToken); + } + + return await scope.ServiceProvider.GetRequiredService() + .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 LevelCounts, + IReadOnlyList Warnings); diff --git a/server/DongfangHydro.Dashboard.Api/Importing/BomImportModels.cs b/server/DongfangHydro.Dashboard.Api/Importing/BomImportModels.cs new file mode 100644 index 0000000..fb97dc6 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Importing/BomImportModels.cs @@ -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 BomNodes, + IReadOnlyList MaterialQuotas, + int BlockCount, + IReadOnlyList Warnings); + +public sealed class BomImportException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/server/DongfangHydro.Dashboard.Api/Importing/BomImportService.cs b/server/DongfangHydro.Dashboard.Api/Importing/BomImportService.cs new file mode 100644 index 0000000..da904ea --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Importing/BomImportService.cs @@ -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 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 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 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(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 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 LevelCounts, + IReadOnlyList Warnings, + bool SkippedUnchanged); diff --git a/server/DongfangHydro.Dashboard.Api/Importing/BomWorkbookParser.cs b/server/DongfangHydro.Dashboard.Api/Importing/BomWorkbookParser.cs new file mode 100644 index 0000000..abe70e7 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Importing/BomWorkbookParser.cs @@ -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 + { + ["定额编号"] = 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>(); + var warnings = new List(); + var current = new List(); + + 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 BuildBomNodes(IReadOnlyList> blocks) + { + var builders = new List(); + 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 ReadMaterialQuotas(IXLWorksheet sheet) + { + var lastRow = sheet.LastRowUsed()?.RowNumber() ?? 7; + var category = string.Empty; + var rows = new List(); + + 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 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(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(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(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> Blocks, + IReadOnlyList 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); +} diff --git a/server/DongfangHydro.Dashboard.Api/Program.cs b/server/DongfangHydro.Dashboard.Api/Program.cs new file mode 100644 index 0000000..54541ae --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Program.cs @@ -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(optional: true); +} + +var connectionString = builder.Configuration.GetConnectionString("DashboardDb") + ?? throw new InvalidOperationException("Connection string 'DashboardDb' is not configured."); + +builder.Services.AddDbContext(options => options.UseSqlServer(connectionString)); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSignalR(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(services => + services.GetRequiredService()); +builder.Services.AddHostedService(services => services.GetRequiredService()); +builder.Services.AddHealthChecks(); +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; + 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 + { + ["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("/hubs/dashboard"); +await DatabaseInitializer.InitializeAsync(app); +app.Run(); + +public partial class Program; diff --git a/server/DongfangHydro.Dashboard.Api/Realtime/DashboardHub.cs b/server/DongfangHydro.Dashboard.Api/Realtime/DashboardHub.cs new file mode 100644 index 0000000..a5db435 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Realtime/DashboardHub.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.SignalR; + +namespace DongfangHydro.Dashboard.Api.Realtime; + +public sealed class DashboardHub : Hub; diff --git a/server/DongfangHydro.Dashboard.Api/Realtime/DashboardUpdateQueue.cs b/server/DongfangHydro.Dashboard.Api/Realtime/DashboardUpdateQueue.cs new file mode 100644 index 0000000..736a85c --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Realtime/DashboardUpdateQueue.cs @@ -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 logger) + : BackgroundService, IDashboardUpdateQueue +{ + private const int MaximumAttempts = 5; + private readonly ConcurrentDictionary 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() + .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); +} diff --git a/server/DongfangHydro.Dashboard.Api/Realtime/IDashboardNotifier.cs b/server/DongfangHydro.Dashboard.Api/Realtime/IDashboardNotifier.cs new file mode 100644 index 0000000..ed89699 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Realtime/IDashboardNotifier.cs @@ -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); +} diff --git a/server/DongfangHydro.Dashboard.Api/Realtime/IDashboardUpdateQueue.cs b/server/DongfangHydro.Dashboard.Api/Realtime/IDashboardUpdateQueue.cs new file mode 100644 index 0000000..70efa63 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Realtime/IDashboardUpdateQueue.cs @@ -0,0 +1,10 @@ +using DongfangHydro.Dashboard.Api.Contracts; + +namespace DongfangHydro.Dashboard.Api.Realtime; + +public interface IDashboardUpdateQueue +{ + ValueTask EnqueueAsync( + UpdateNodeProgressResultDto update, + CancellationToken cancellationToken); +} diff --git a/server/DongfangHydro.Dashboard.Api/Realtime/SignalRDashboardNotifier.cs b/server/DongfangHydro.Dashboard.Api/Realtime/SignalRDashboardNotifier.cs new file mode 100644 index 0000000..b53e346 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Realtime/SignalRDashboardNotifier.cs @@ -0,0 +1,24 @@ +using DongfangHydro.Dashboard.Api.Contracts; +using Microsoft.AspNetCore.SignalR; + +namespace DongfangHydro.Dashboard.Api.Realtime; + +public sealed class SignalRDashboardNotifier(IHubContext 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)); + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Services/DashboardMapper.cs b/server/DongfangHydro.Dashboard.Api/Services/DashboardMapper.cs new file mode 100644 index 0000000..bb7eec2 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Services/DashboardMapper.cs @@ -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 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>(), []); + } + + 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 trend, + IReadOnlyList 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> childrenByParent, + HashSet 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(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; + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Services/DashboardQueryService.cs b/server/DongfangHydro.Dashboard.Api/Services/DashboardQueryService.cs new file mode 100644 index 0000000..62a91b7 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Services/DashboardQueryService.cs @@ -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> 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(items, total, page, pageSize); + } + + public async Task> 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(items, total, page, pageSize); + } + + public async Task 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 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> 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> 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 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> 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(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> BuildOrderSummariesAsync( + IReadOnlyList 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(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> LoadRecentTrendAsync( + IReadOnlyCollection 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> LoadRecentRiskEventsAsync( + IReadOnlyCollection 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 BuildRecentTrendQuery( + DashboardDbContext dbContext, + IReadOnlyCollection 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 BuildRecentRiskEventQuery( + DashboardDbContext dbContext, + IReadOnlyCollection 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 Parameters) BuildOrderIdParameters( + IReadOnlyCollection 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); + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Services/ProgressAggregationService.cs b/server/DongfangHydro.Dashboard.Api/Services/ProgressAggregationService.cs new file mode 100644 index 0000000..44e41cf --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Services/ProgressAggregationService.cs @@ -0,0 +1,122 @@ +using DongfangHydro.Dashboard.Api.Domain; + +namespace DongfangHydro.Dashboard.Api.Services; + +public sealed class ProgressAggregationService +{ + public void Recalculate(ProductionOrder order, IReadOnlyCollection 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> 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 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 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"; + } +} diff --git a/server/DongfangHydro.Dashboard.Api/Services/ProgressUpdateService.cs b/server/DongfangHydro.Dashboard.Api/Services/ProgressUpdateService.cs new file mode 100644 index 0000000..d42008e --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/Services/ProgressUpdateService.cs @@ -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 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)); + } + } +} diff --git a/server/DongfangHydro.Dashboard.Api/appsettings.Development.json b/server/DongfangHydro.Dashboard.Api/appsettings.Development.json new file mode 100644 index 0000000..cb18fa6 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/appsettings.Development.json @@ -0,0 +1,6 @@ +{ + "Database": { + "AutoMigrate": true, + "Seed": true + } +} diff --git a/server/DongfangHydro.Dashboard.Api/appsettings.json b/server/DongfangHydro.Dashboard.Api/appsettings.json new file mode 100644 index 0000000..44d480b --- /dev/null +++ b/server/DongfangHydro.Dashboard.Api/appsettings.json @@ -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": "*" +} diff --git a/server/DongfangHydro.Dashboard.Tests/BomDataApiTests.cs b/server/DongfangHydro.Dashboard.Tests/BomDataApiTests.cs new file mode 100644 index 0000000..12ec27f --- /dev/null +++ b/server/DongfangHydro.Dashboard.Tests/BomDataApiTests.cs @@ -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 +{ + [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(); + 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(); + + 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(); + 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($"/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(); + 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($"/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(); + + 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; + } +} diff --git a/server/DongfangHydro.Dashboard.Tests/BomImportCommandTests.cs b/server/DongfangHydro.Dashboard.Tests/BomImportCommandTests.cs new file mode 100644 index 0000000..190ed2f --- /dev/null +++ b/server/DongfangHydro.Dashboard.Tests/BomImportCommandTests.cs @@ -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(() => BomImportCommand.Parse(["import-bom"])); + + Assert.Contains("--file", exception.Message); + } +} diff --git a/server/DongfangHydro.Dashboard.Tests/BomImportServiceTests.cs b/server/DongfangHydro.Dashboard.Tests/BomImportServiceTests.cs new file mode 100644 index 0000000..bb12f46 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Tests/BomImportServiceTests.cs @@ -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() + .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() + .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, + []); + } +} diff --git a/server/DongfangHydro.Dashboard.Tests/BomWorkbookParserTests.cs b/server/DongfangHydro.Dashboard.Tests/BomWorkbookParserTests.cs new file mode 100644 index 0000000..dd44406 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Tests/BomWorkbookParserTests.cs @@ -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(() => + 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; + } +} diff --git a/server/DongfangHydro.Dashboard.Tests/DashboardApiTests.cs b/server/DongfangHydro.Dashboard.Tests/DashboardApiTests.cs new file mode 100644 index 0000000..ded56ff --- /dev/null +++ b/server/DongfangHydro.Dashboard.Tests/DashboardApiTests.cs @@ -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 +{ + [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(); + + 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("/api/orders?page=1&pageSize=20"); + var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString(); + + var root = await client.GetFromJsonAsync($"/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("/api/orders?page=1&pageSize=20"); + var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString(); + var root = await client.GetFromJsonAsync($"/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(); + + 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 GetFirstProcessAsync(HttpClient client) + { + var orders = await client.GetFromJsonAsync("/api/orders?page=1&pageSize=20"); + var orderId = orders.GetProperty("items")[0].GetProperty("id").GetString(); + var root = await client.GetFromJsonAsync($"/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 +{ + 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 + { + ["Database:AutoMigrate"] = "true", + ["Database:Seed"] = "true", + }); + }); + builder.ConfigureServices(services => + { + services.RemoveAll>(); + services.AddDbContext(options => + options.UseInMemoryDatabase(databaseName)); + services.RemoveAll(); + services.AddSingleton(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; + } +} diff --git a/server/DongfangHydro.Dashboard.Tests/DongfangHydro.Dashboard.Tests.csproj b/server/DongfangHydro.Dashboard.Tests/DongfangHydro.Dashboard.Tests.csproj new file mode 100644 index 0000000..2037190 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Tests/DongfangHydro.Dashboard.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/server/DongfangHydro.Dashboard.Tests/ProgressAggregationServiceTests.cs b/server/DongfangHydro.Dashboard.Tests/ProgressAggregationServiceTests.cs new file mode 100644 index 0000000..ff918e7 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Tests/ProgressAggregationServiceTests.cs @@ -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", + }; + } +} diff --git a/server/DongfangHydro.Dashboard.Tests/SqlServerQueryShapeTests.cs b/server/DongfangHydro.Dashboard.Tests/SqlServerQueryShapeTests.cs new file mode 100644 index 0000000..c1e06f7 --- /dev/null +++ b/server/DongfangHydro.Dashboard.Tests/SqlServerQueryShapeTests.cs @@ -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() + .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); + } +} diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..9a68c14 --- /dev/null +++ b/server/docker-compose.yml @@ -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: diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..375ddb6 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,7 @@ +import { Dashboard } from '@/components/dashboard/Dashboard' + +function App() { + return +} + +export default App diff --git a/src/components/dashboard/BottomTelemetry.tsx b/src/components/dashboard/BottomTelemetry.tsx new file mode 100644 index 0000000..d971fa6 --- /dev/null +++ b/src/components/dashboard/BottomTelemetry.tsx @@ -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 ( +
+
+ } title="风险事件" subtitle="实时刷新" /> + +
+ {selectedOrder.events.length === 0 ? ( + } + code="RISK 00" + title="当前零风险" + detail="未检测到延期、阻塞或高风险事件" + tone="success" + /> + ) : selectedOrder.events.slice(0, 5).map((event) => ( + + ))} +
+
+ +
+ } title="延期 TOP" subtitle="按订单聚合" /> + +
+ {delayTop.length === 0 ? ( + } + code="DELAY 00" + title="当前无延期订单" + detail="全部订单处于计划时间边界内" + tone="success" + /> + ) : delayTop.map((order, index) => ( +
+ {index + 1} +
+ {order.code} + {order.productName} +
+ + {order.delayDays} 天 + + + {order.events[0]?.message ?? '计划节拍偏差'} + +
+ ))} +
+
+ +
+ } title="产能趋势" subtitle={selectedOrder.code} /> + +
+ +
+ } title="订单状态分布" subtitle="总订单" /> + +
+
+ ) +} + +function TrendChart({ order }: { order: OrderSummary }) { + const option = useMemo( + () => ({ + 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 + } + + return ( + + ) +} + +function TrendStandby({ order }: { order: OrderSummary }) { + return ( +
+ +
+
+
+ 采集通道STANDBY + 数据点{order.trend.length} +
+
+ ) +} + +function TelemetryEmptyState({ + icon, + code, + title, + detail, + tone, +}: { + icon: React.ReactNode + code: string + title: string + detail: string + tone: 'success' | 'muted' +}) { + return ( +
+ +
+ {code} + {title} + {detail} +
+ +
+ ) +} + +function OrderStatusDonut({ orders }: { orders: OrderSummary[] }) { + const option = useMemo(() => { + 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 ( +
+ + +
+ ) +} + +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 ( +
+ +
+

{title}

+

{subtitle}

+
+
+ ) +} diff --git a/src/components/dashboard/Dashboard.tsx b/src/components/dashboard/Dashboard.tsx new file mode 100644 index 0000000..b4cc2d8 --- /dev/null +++ b/src/components/dashboard/Dashboard.tsx @@ -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(() => createMockOrders()) + const [selectedOrderId, setSelectedOrderId] = useState(() => orders[0]?.id ?? '') + const [selectedNodeId, setSelectedNodeId] = useState( + () => (orders[0] ? defaultSelectedNodeId(orders[0].root) : null), + ) + const [expandedIds, setExpandedIds] = useState>( + () => 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('connecting') + const refreshSequenceRef = useRef(0) + const realtimeRevisionRef = useRef(0) + const realtimeOrderRevisionsRef = useRef(new Map()) + + 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 | 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 ( +
+ + void refreshOrders()} + /> +
+ + + +
+ +
+ ) +} diff --git a/src/components/dashboard/DetailPanel.tsx b/src/components/dashboard/DetailPanel.tsx new file mode 100644 index 0000000..dfb684c --- /dev/null +++ b/src/components/dashboard/DetailPanel.tsx @@ -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 ( + + ) + } + + const riskTone = + node.riskLevel === 'critical' + ? 'critical' + : node.riskLevel === 'warning' + ? 'warning' + : 'success' + return ( + + ) +} + +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 ( +
+ {label} + {value} +
+ ) +} diff --git a/src/components/dashboard/HydroAtmosphere.tsx b/src/components/dashboard/HydroAtmosphere.tsx new file mode 100644 index 0000000..dd391fe --- /dev/null +++ b/src/components/dashboard/HydroAtmosphere.tsx @@ -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 ( +