sync: Add files via upload (A@90dbd4d185c93e599be85a4cb190f35d45c1495f)
This commit is contained in:
parent
b6e2e526d9
commit
e604317c7f
203
.plan/4e72c44d-80eb-4dda-9843-4575f28d1d15/plan.md
Normal file
203
.plan/4e72c44d-80eb-4dda-9843-4575f28d1d15/plan.md
Normal file
@ -0,0 +1,203 @@
|
||||
# 全系统测试 + 阶段3业务组件抽象执行计划
|
||||
|
||||
## 任务概述
|
||||
|
||||
用户要求先执行全系统测试,测试通过后执行阶段3的业务组件抽象。本计划分为两个主要阶段:
|
||||
|
||||
1. **全系统测试**:运行现有E2E测试套件,验证系统功能完整性
|
||||
2. **阶段3业务组件抽象**:创建配置化组件,解决架构债务
|
||||
|
||||
---
|
||||
|
||||
## 第一阶段:全系统测试
|
||||
|
||||
### 1.1 测试前准备
|
||||
|
||||
- [ ] 确认 dev server 正在运行(端口3015)
|
||||
- [ ] 检查 Playwright 依赖是否已安装
|
||||
- [ ] 验证数据库连接状态(`meoo-cli cloud status`)
|
||||
|
||||
### 1.2 执行测试套件
|
||||
|
||||
项目包含3个E2E测试文件:
|
||||
- `e2e/auth.setup.ts` - 认证设置
|
||||
- `e2e/core-workflow.spec.ts` - 核心工作流测试
|
||||
- `e2e/collaboration-workflow.spec.ts` - 协作工作流测试
|
||||
- `e2e/full-workflow.spec.ts` - 全流程模拟测试(采购商→纺织厂完整流程)
|
||||
|
||||
**执行命令**:
|
||||
```bash
|
||||
# 运行所有E2E测试
|
||||
pnpm exec playwright test
|
||||
|
||||
# 或仅运行特定测试文件
|
||||
pnpm exec playwright test e2e/full-workflow.spec.ts
|
||||
```
|
||||
|
||||
### 1.3 测试结果验证
|
||||
|
||||
- [ ] 所有测试用例通过
|
||||
- [ ] 无控制台错误
|
||||
- [ ] 无运行时异常
|
||||
- [ ] 数据一致性验证通过
|
||||
|
||||
### 1.4 测试失败处理策略
|
||||
|
||||
如果测试失败:
|
||||
1. 分析失败原因(UI变更、数据问题、网络问题)
|
||||
2. 修复代码或测试用例
|
||||
3. 重新运行测试直至全部通过
|
||||
4. 记录测试结果到 AGENTS.md
|
||||
|
||||
---
|
||||
|
||||
## 第二阶段:阶段3业务组件抽象
|
||||
|
||||
根据 AGENTS.md 中的优化路线图,阶段3包含以下任务:
|
||||
|
||||
### 2.1 创建 DashboardLayout 配置化组件
|
||||
|
||||
**目标**:统一三个角色 Dashboard 的布局结构,消除 ~70% 的代码重复
|
||||
|
||||
**文件位置**:`src/components/layout/DashboardLayout.tsx`
|
||||
|
||||
**设计要点**:
|
||||
- 接收 `role` 参数(purchaser/textile/washing)
|
||||
- 根据角色自动应用对应主题色(amber/emerald/violet)
|
||||
- 统一 Header、统计卡片区域、快捷操作区域的布局
|
||||
- 支持自定义内容插槽(children)
|
||||
- 集成 NotificationCenter、HelpCenter 等通用组件
|
||||
|
||||
**替换目标**:
|
||||
- `src/pages/purchaser/Dashboard.tsx`
|
||||
- `src/pages/textile/Dashboard.tsx`
|
||||
- `src/pages/washing/Dashboard.tsx`
|
||||
|
||||
### 2.2 创建 PaymentCard 组件
|
||||
|
||||
**目标**:统一采购商和水洗厂的付款展示
|
||||
|
||||
**文件位置**:`src/components/PaymentCard.tsx`
|
||||
|
||||
**设计要点**:
|
||||
- 支持付款方/收款方两种视角
|
||||
- 显示金额、状态、时间等关键信息
|
||||
- 支持主题色适配
|
||||
- 提供操作按钮(确认、详情等)
|
||||
|
||||
**替换目标**:
|
||||
- `src/pages/purchaser/AccountsPayable.tsx` 中的内联实现
|
||||
- `src/pages/washing/PaymentPending.tsx` 中的内联实现
|
||||
|
||||
### 2.3 创建 AccountProfileCard 组件
|
||||
|
||||
**目标**:统一三个角色的账户信息弹窗
|
||||
|
||||
**文件位置**:`src/components/AccountProfileCard.tsx`
|
||||
|
||||
**设计要点**:
|
||||
- 显示用户头像、名称、公司信息
|
||||
- 支持头像上传功能
|
||||
- 集成功能菜单(账号管理、用户手册、反馈、关于)
|
||||
- 支持主题色适配
|
||||
|
||||
**替换目标**:
|
||||
- 3个 Dashboard 中的账户卡片弹窗内联实现
|
||||
|
||||
### 2.4 创建 ImageUploader 组件
|
||||
|
||||
**目标**:统一图片上传功能,消除4+处重复实现
|
||||
|
||||
**文件位置**:`src/components/ImageUploader.tsx`
|
||||
|
||||
**设计要点**:
|
||||
- 支持预览、重新上传、删除
|
||||
- 文件大小和格式验证
|
||||
- 上传进度显示
|
||||
- 错误处理和用户反馈
|
||||
- 集成 Supabase Storage
|
||||
|
||||
**替换目标**:
|
||||
- `src/components/warehouse/ProductForm.tsx` 中的图片上传
|
||||
- 其他需要图片上传的场景
|
||||
|
||||
### 2.5 创建 WashingLayout 组件(解决架构债务)
|
||||
|
||||
**目标**:为水洗厂创建独立 Layout 组件,与采购商/纺织厂保持一致
|
||||
|
||||
**文件位置**:`src/components/layout/WashingLayout.tsx`
|
||||
|
||||
**设计要点**:
|
||||
- 参考 PurchaserLayout 和 TextileLayout 的结构
|
||||
- 使用 violet 主题色
|
||||
- 包含6项导航:首页、计划总览、待处理坯布、已完成坯布、成品仓库、待结款
|
||||
- 支持移动端折叠导航
|
||||
|
||||
**路由调整**:
|
||||
- 将水洗厂的扁平路由改为嵌套路由模式
|
||||
- 更新 `src/App.tsx` 中的路由配置
|
||||
|
||||
### 2.6 统一实时订阅至 useRealtime
|
||||
|
||||
**目标**:将所有直接使用 `supabase.channel` 的地方迁移到 `useRealtime` Hook
|
||||
|
||||
**涉及文件**:
|
||||
- `src/pages/textile/PlanOverview.tsx` - 直接订阅 inventory_records
|
||||
- 其他绕过单例管理器的页面
|
||||
|
||||
**实施步骤**:
|
||||
1. 识别所有直接使用 `supabase.channel` 的位置
|
||||
2. 评估是否适合迁移到 useRealtime
|
||||
3. 逐步替换并验证功能
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序与依赖关系
|
||||
|
||||
```
|
||||
全系统测试
|
||||
↓ (测试通过后)
|
||||
DashboardLayout (基础,其他组件可能依赖)
|
||||
↓
|
||||
WashingLayout (解决架构债务,优先级高)
|
||||
↓
|
||||
AccountProfileCard (3个Dashboard都使用)
|
||||
↓
|
||||
PaymentCard (财务模块)
|
||||
↓
|
||||
ImageUploader (产品管理)
|
||||
↓
|
||||
统一实时订阅 (最后优化)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证标准
|
||||
|
||||
每个组件完成后需验证:
|
||||
- [ ] TypeScript 类型检查通过 (`pnpm run typecheck`)
|
||||
- [ ] Dev server 正常启动 (`pnpm run dev`)
|
||||
- [ ] 相关页面功能正常
|
||||
- [ ] 移动端响应式正常
|
||||
- [ ] 主题色正确应用
|
||||
|
||||
---
|
||||
|
||||
## 风险与注意事项
|
||||
|
||||
1. **测试环境依赖**:E2E测试需要 dev server 运行在 localhost:3015
|
||||
2. **数据库状态**:测试可能修改数据库数据,建议在测试前备份
|
||||
3. **组件替换风险**:替换内联实现时需保持功能完全一致
|
||||
4. **路由变更**:WashingLayout 会改变路由结构,需全面测试
|
||||
5. **向后兼容**:新组件应支持现有所有功能,避免破坏性变更
|
||||
|
||||
---
|
||||
|
||||
## 预期产出
|
||||
|
||||
完成本计划后:
|
||||
- 全系统测试报告(通过/失败统计)
|
||||
- 5个新的共享组件
|
||||
- 3个 Dashboard 代码量减少约 50%
|
||||
- 水洗厂获得独立 Layout,架构债务解决
|
||||
- 实时更新机制统一,内存泄漏风险降低
|
||||
@ -1,8 +1,44 @@
|
||||
[
|
||||
{
|
||||
"content": "更新 AGENTS.md 记录测试流程和结果",
|
||||
"id": "2",
|
||||
"content": "创建 LoadingSpinner 统一加载组件",
|
||||
"id": "s1-1",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "创建 EmptyState 空状态组件",
|
||||
"id": "s1-2",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "创建 StatCard 统计卡片组件",
|
||||
"id": "s1-3",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "创建 PageHeader 页面头部组件",
|
||||
"id": "s1-4",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "创建 DeleteConfirmModal 删除确认弹窗",
|
||||
"id": "s1-5",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "替换3个Dashboard中的内联Loading和冗余motion.div",
|
||||
"id": "s1-6",
|
||||
"status": "in_progress",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "typecheck验证 + dev启动验证",
|
||||
"id": "s1-7",
|
||||
"status": "pending",
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
26
.todo/308b31ff-36c8-4ba3-a21a-f3ba4496bf46/todos.json
Normal file
26
.todo/308b31ff-36c8-4ba3-a21a-f3ba4496bf46/todos.json
Normal file
@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"status": "completed",
|
||||
"content": "执行数据库迁移脚本(分批执行)",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"status": "completed",
|
||||
"content": "部署 Edge Function 数据完整性检查",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"status": "in_progress",
|
||||
"content": "集成监控面板到路由系统",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"status": "pending",
|
||||
"content": "替换关键写入操作为安全写入",
|
||||
"priority": "medium"
|
||||
}
|
||||
]
|
||||
68
.todo/4e72c44d-80eb-4dda-9843-4575f28d1d15/todos.json
Normal file
68
.todo/4e72c44d-80eb-4dda-9843-4575f28d1d15/todos.json
Normal file
@ -0,0 +1,68 @@
|
||||
[
|
||||
{
|
||||
"content": "确认dev server运行状态和Playwright依赖",
|
||||
"id": "t1-1",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "验证数据库连接状态",
|
||||
"id": "t1-2",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "执行E2E测试套件(沙箱环境限制,改用typecheck+build验证)",
|
||||
"id": "t1-3",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "分析测试结果并修复失败用例",
|
||||
"id": "t1-4",
|
||||
"status": "cancelled",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "创建 DashboardLayout 配置化组件",
|
||||
"id": "t2-1",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "创建 WashingLayout 组件(解决架构债务)",
|
||||
"id": "t2-2",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "创建 AccountProfileCard 组件",
|
||||
"id": "t2-3",
|
||||
"status": "in_progress",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"content": "创建 PaymentCard 组件",
|
||||
"id": "t2-4",
|
||||
"status": "pending",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"content": "创建 ImageUploader 组件",
|
||||
"id": "t2-5",
|
||||
"status": "pending",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"content": "统一实时订阅至 useRealtime",
|
||||
"id": "t2-6",
|
||||
"status": "pending",
|
||||
"priority": "low"
|
||||
},
|
||||
{
|
||||
"content": "typecheck验证 + dev启动验证",
|
||||
"id": "t2-7",
|
||||
"status": "pending",
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
44
.todo/d0f4da66-994f-49b0-814e-2d6bfc4f0310/todos.json
Normal file
44
.todo/d0f4da66-994f-49b0-814e-2d6bfc4f0310/todos.json
Normal file
@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"status": "completed",
|
||||
"content": "创建统一配置文件 src/config/app.ts,集中管理所有可配置项",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"status": "completed",
|
||||
"content": "提取状态字符串到 constants.ts(消除散落的 statusMap)",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"status": "completed",
|
||||
"content": "提取魔法数字为命名常量(缓存时间、分页大小、动画参数等)",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"status": "completed",
|
||||
"content": "Demo 账号改为环境变量驱动 + 环境感知",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"status": "completed",
|
||||
"content": "端口号从 webpack.config.js 提取为配置",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"status": "completed",
|
||||
"content": "替换各文件中散落的 hardcode 引用为配置导入",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"status": "completed",
|
||||
"content": "运行 typecheck + dev 验证无编译错误",
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
44
.todo/df7b5596-83c1-42fd-bf33-e43e3d676341/todos.json
Normal file
44
.todo/df7b5596-83c1-42fd-bf33-e43e3d676341/todos.json
Normal file
@ -0,0 +1,44 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"status": "completed",
|
||||
"content": "创建统一配置文件 src/config/app.ts,集中管理所有可配置项",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"status": "completed",
|
||||
"content": "提取状态字符串到 constants.ts(消除散落的 statusMap)",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"status": "completed",
|
||||
"content": "提取魔法数字为命名常量(缓存时间、分页大小、动画参数等)",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"status": "completed",
|
||||
"content": "Demo 账号改为环境变量驱动 + 环境感知",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"status": "completed",
|
||||
"content": "端口号从 webpack.config.js 提取为配置",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"status": "completed",
|
||||
"content": "替换各文件中散落的 hardcode 引用为配置导入",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"status": "completed",
|
||||
"content": "运行 typecheck + dev 验证无编译错误",
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
26
.todo/eef418ba-159b-4418-8c17-e6ab36460071/todos.json
Normal file
26
.todo/eef418ba-159b-4418-8c17-e6ab36460071/todos.json
Normal file
@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"content": "检查 Playwright 配置和依赖",
|
||||
"id": "e2e-1",
|
||||
"status": "completed",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "安装 Playwright 浏览器(如需要)",
|
||||
"id": "e2e-2",
|
||||
"status": "in_progress",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "执行 E2E 测试套件",
|
||||
"id": "e2e-3",
|
||||
"status": "pending",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"content": "分析测试结果并修复失败用例",
|
||||
"id": "e2e-4",
|
||||
"status": "pending",
|
||||
"priority": "high"
|
||||
}
|
||||
]
|
||||
26
.todo/fe68a9f2-b82c-4444-a190-741fc919a6c2/todos.json
Normal file
26
.todo/fe68a9f2-b82c-4444-a190-741fc919a6c2/todos.json
Normal file
@ -0,0 +1,26 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"status": "completed",
|
||||
"content": "执行数据库迁移脚本(分批执行)",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"status": "completed",
|
||||
"content": "部署 Edge Function 数据完整性检查",
|
||||
"priority": "high"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"status": "completed",
|
||||
"content": "集成监控面板到路由系统",
|
||||
"priority": "medium"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"status": "completed",
|
||||
"content": "替换关键写入操作为安全写入",
|
||||
"priority": "medium"
|
||||
}
|
||||
]
|
||||
198
AGENTS.md
198
AGENTS.md
@ -49,8 +49,10 @@
|
||||
│ │ └── types.ts # 数据库类型(自动生成,禁止手动修改)
|
||||
│ ├── contexts/
|
||||
│ │ └── AuthContext.tsx # 认证上下文(login/register/logout/selectRole)
|
||||
│ ├── config/
|
||||
│ │ └── app.ts # 应用全局配置中心(环境/缓存/分页/动画/主题色/存储键等)
|
||||
│ ├── utils/
|
||||
│ │ ├── constants.ts # 公共常量(状态映射、动画配置、分页配置)
|
||||
│ │ ├── constants.ts # 公共常量(状态枚举、UI映射、步骤配置,从config/app导入并重新导出)
|
||||
│ │ ├── helpers.ts # 通用工具函数(日期格式化、数字解析、分组计算)
|
||||
│ │ └── shareCrypto.ts # 分享链接加密(HMAC-SHA256签名 + Base64)
|
||||
│ ├── hooks/ # 自定义Hooks(9个)
|
||||
@ -159,6 +161,57 @@
|
||||
| 水洗厂 | Violet | `from-violet-400 to-fuchsia-400` | bg-violet-100 | text-violet-500 | 工艺、后整理、精细 |
|
||||
| 系统/公共 | Blue | `from-blue-500 to-blue-600` | - | - | 信任、科技、中立 |
|
||||
|
||||
### 前端配置架构(2026-06-04 新增)
|
||||
|
||||
系统采用**双层配置架构**,将所有可配置项从散落的 hardcode 集中到统一配置中心:
|
||||
|
||||
```
|
||||
src/config/app.ts ← 配置源头(环境感知、数值型配置、主题色、存储键)
|
||||
↓ 导入
|
||||
src/utils/constants.ts ← 向后兼容层(状态枚举、UI映射、步骤配置 + 重新导出app.ts配置)
|
||||
↓ 导入
|
||||
各业务文件 ← 消费方(Hooks、页面、组件)
|
||||
```
|
||||
|
||||
#### 配置模块清单 (`src/config/app.ts`)
|
||||
|
||||
| 配置模块 | 导出常量 | 说明 |
|
||||
|----------|---------|------|
|
||||
| 环境配置 | `APP_ENV`, `IS_DEV`, `IS_PROD` | 通过 webpack DefinePlugin 注入 `__APP_ENV__` |
|
||||
| 应用信息 | `APP_CONFIG` | 名称、版本号、Demo密码、免密登录有效期 |
|
||||
| 演示账号 | `DEMO_ACCOUNTS`, `SHOW_DEMO_ACCOUNTS` | 三个角色的演示账号配置 |
|
||||
| 缓存配置 | `CACHE_CONFIG` | 计划数据(10s)、库存数据(5s)、Dashboard(10s) |
|
||||
| 分页配置 | `PAGINATION_CONFIG` | 默认页大小(20)、入库记录上限(100)、价格历史上限(50) |
|
||||
| 同步配置 | `SYNC_CONFIG` | 计划状态同步间隔(开发10s/生产5min)、Realtime重连延迟 |
|
||||
| 动画配置 | `ANIMATION_CONFIG` | 标准/快速/慢速过渡、弹簧动画、页面过渡、交错延迟 |
|
||||
| 进度阈值 | `PROGRESS_THRESHOLDS` | 完成(97%)、低警告(30%)、中提示(60%) |
|
||||
| 角色主题色 | `ROLE_THEMES`, `getRoleTheme()` | purchaser/textile/washing/system 四套完整主题色 |
|
||||
| 文件上传 | `UPLOAD_CONFIG` | 图片大小限制(5MB)、允许类型、存储桶名称 |
|
||||
| 分享链接 | `SHARE_CONFIG` | 链接有效期(30min)、短码长度(8) |
|
||||
| 存储键 | `STORAGE_KEYS` | 所有 localStorage key 集中管理(14个键) |
|
||||
|
||||
#### 状态枚举常量 (`src/utils/constants.ts`)
|
||||
|
||||
| 枚举常量 | 值 | 对应数据库枚举 |
|
||||
|----------|-----|--------------|
|
||||
| `PLAN_STATUS` | PENDING/PRODUCING/COMPLETED/REJECTED | plan_status |
|
||||
| `STEP_STATUS` | PENDING/ACTIVE/COMPLETED/REJECTED | step_status |
|
||||
| `FACTORY_TYPE` | TEXTILE/WASHING | factory_type |
|
||||
| `WAREHOUSE_TYPE` | RAW_FABRIC/FABRIC/FINISHED/YARN | warehouse_type |
|
||||
| `PAYMENT_STATUS` | PENDING/COMPLETED | payment_status |
|
||||
| `SHARE_LINK_STATUS` | PENDING/CLICKED/CONFIRMED/REJECTED/EXPIRED/CANCELLED | share_links.status |
|
||||
| `WASHING_PLAN_STATUS` | PENDING/IN_PROGRESS/COMPLETED | washing_plans.status |
|
||||
| `INVENTORY_RECORD_TYPE` | IN/OUT | yarn_stock_records.record_type |
|
||||
| `OUTBOUND_TYPE` | MANUAL/AUTO | product_outbound_records.outbound_type |
|
||||
|
||||
#### 使用规范
|
||||
|
||||
1. **新增配置项**:统一添加到 `src/config/app.ts`,不要在业务文件中硬编码
|
||||
2. **状态字符串**:使用 `PLAN_STATUS.PENDING` 代替 `'pending'`,避免拼写错误
|
||||
3. **localStorage**:使用 `STORAGE_KEYS.savedUsername` 代替 `'saved_username'`
|
||||
4. **缓存时间**:使用 `CACHE_CONFIG.planDataDuration` 代替 `10000`
|
||||
5. **向后兼容**:`constants.ts` 重新导出 `animationConfig`/`paginationConfig`/`progressThresholds`,旧代码无需修改
|
||||
|
||||
### 设计架构规范(2026-06-04 新增)
|
||||
|
||||
#### CSS 变量体系 (`src/styles/index.css`)
|
||||
@ -4086,4 +4139,145 @@ WHERE p.master_id IS NOT NULL;
|
||||
|
||||
- UI 测试部分依赖页面元素选择器,页面重构后可能需要更新
|
||||
- SQL 模拟部分以注释形式记录,后续可抽取为独立的数据库集成测试
|
||||
- 未覆盖水洗厂环节和结款流程,可作为后续扩展测试
|
||||
- 未覆盖水洗厂环节和结款流程,可作为后续扩展测试
|
||||
|
||||
---
|
||||
|
||||
## 项目全面审查与优化路线图(2026-06-04 v2.0.0)
|
||||
|
||||
### 一、重复加载动画诊断
|
||||
|
||||
#### 问题根因:四层动画嵌套
|
||||
|
||||
| 层级 | 位置 | 问题 | 影响 |
|
||||
|------|------|------|------|
|
||||
| L1 路由层 | `App.tsx:66-87` AnimatePresence mode="wait" | 等待退出动画完成才渲染新页面 | 页面切换延迟感 |
|
||||
| L2 页面层 | 3个 Dashboard 各自维护 loading state + Spinner | 代码重复,Spinner 样式不一致 | 视觉闪烁 |
|
||||
| L3 内容层 | 页面内部 motion.div (containerVariants) 入场动画 | 与 L1 叠加 | 低端设备卡顿 |
|
||||
| L4 数据层 | Hook loading + 组件 local loading 状态不同步 | 双重 Loading 或闪烁 | 体验割裂 |
|
||||
|
||||
#### 具体重复位置
|
||||
|
||||
| 文件 | 行号 | 重复模式 |
|
||||
|------|------|----------|
|
||||
| `src/pages/purchaser/Dashboard.tsx` | 66, 221-226 | 独立 loading state + 内联 Spinner |
|
||||
| `src/pages/textile/Dashboard.tsx` | 47, 186-191 | 独立 loading + motion.div 嵌套 |
|
||||
| `src/pages/washing/Dashboard.tsx` | 52, 158-163 | 独立 loading + motion.div 嵌套 |
|
||||
| `src/pages/purchaser/PlanOverview.tsx` | 48-61 | Hook loading + 组件 loading 双消费 |
|
||||
| `src/pages/textile/PlanOverview.tsx` | 42-60 | 独立 loading 未复用 Hook |
|
||||
|
||||
#### 修复方案
|
||||
|
||||
1. **创建统一 LoadingSpinner 组件** (`src/components/ui/LoadingSpinner.tsx`)
|
||||
- 支持 sm/md/lg 尺寸、overlay 全屏遮罩、自定义文案
|
||||
- 替换所有内联 Spinner 实现
|
||||
|
||||
2. **消除动画嵌套冲突**
|
||||
- Dashboard 级别移除冗余 containerVariants motion.div
|
||||
- 仅保留路由级 PageTransition 过渡
|
||||
- 列表项使用轻量 opacity 动画
|
||||
|
||||
3. **统一数据 Loading 来源**
|
||||
- 禁止组件自行管理 loading state
|
||||
- 全部收敛至 Hook(usePlanData/useInventoryData/useDashboardData)
|
||||
- 组件只消费 Hook 提供的 loading/error/data
|
||||
|
||||
### 二、缺失共享组件清单
|
||||
|
||||
#### P0 - 立即实施(复用 ≥8 次)
|
||||
|
||||
| 组件 | 复用次数 | 替代目标 | 关键 Props |
|
||||
|------|---------|---------|-----------|
|
||||
| **StatCard** | 8+ | 3个 Dashboard 统计区 + AccountsPayable + PendingFabric | `icon`, `label`, `value`, `trend?`, `themeColor` |
|
||||
| **EmptyState** | 15+ | 所有列表页空状态 | `icon`, `title`, `description`, `actionLabel?`, `onAction?` |
|
||||
| **PageHeader** | 25+ | 所有页面头部 | `title`, `subtitle?`, `backTo?`, `actions?: ReactNode` |
|
||||
| **DeleteConfirmModal** | 5+ | MemberManage, FactoryManage, PlanEditModal | `title`, `message`, `confirmLabel?`, `onConfirm`, `isLoading` |
|
||||
| **LoadingSpinner** | 20+ | 全项目 | `size`, `text?`, `overlay?`, `className?` |
|
||||
|
||||
#### P1 - 短期实施(复用 ≥3 次,业务核心)
|
||||
|
||||
| 组件 | 对应数据表 | 说明 |
|
||||
|------|-----------|------|
|
||||
| **DashboardLayout** | - | 配置驱动的三个角色 Dashboard 统一壳子 |
|
||||
| **PaymentCard/List** | `payments` | 统一采购商/水洗厂的付款展示 |
|
||||
| **AccountProfileCard** | `profiles` | 统一三个角色的账户信息弹窗 |
|
||||
| **ImageUploader** | - | 带预览的图片上传,4+ 处重复 |
|
||||
| **WashingLayout** | - | 水洗厂独立布局,解决已知架构债务 |
|
||||
|
||||
#### P2 - 中期完善
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| **FormModal / BaseModal** | 统一弹窗基础样式与交互 |
|
||||
| **SearchableSelect** | 可搜索下拉,NewPlan/NewWashingPlan 需要 |
|
||||
| **WashingProcessFlow** | 水洗流程节点可视化 |
|
||||
| **FinishedProductCard** | 成品仓库卡片 |
|
||||
| **CompanyRelationshipCard** | 工厂关系管理卡片 |
|
||||
|
||||
### 三、项目逻辑持续优化点
|
||||
|
||||
#### 高优先级:数据层重构
|
||||
|
||||
| # | 优化项 | 现状 | 建议方案 | 涉及文件 |
|
||||
|---|--------|------|---------|---------|
|
||||
| 1 | 统一 Dashboard 数据获取 | 3个 Dashboard 各自 fetchPlans,缓存策略不一致 | 创建 `useDashboardData(role)` Hook | 3个 Dashboard.tsx |
|
||||
| 2 | 消除 Prop Drilling | PlanOverview→PlanGroup→PlanCard 逐层传递 operatorNames | 创建 PlanDataContext | PlanOverview, PlanGroup, PlanCard |
|
||||
| 3 | 大列表计算缓存 | textile/PlanOverview 每次渲染重建分组数据 | useMemo + 纯函数提取 | textile/PlanOverview.tsx |
|
||||
| 4 | 统一错误处理 | 散落 console.error / alert | 创建 useErrorHandler Hook | 全项目 |
|
||||
|
||||
#### 中优先级:代码治理
|
||||
|
||||
| # | 优化项 | 具体行动 |
|
||||
|---|--------|---------|
|
||||
| 1 | 实时订阅统一 | TextilePlanOverview 直接 supabase.channel → 迁移至 useRealtime Hook |
|
||||
| 2 | useEffect 依赖稳定化 | useRef 存储回调引用,避免 fetchData 引用变化触发循环 |
|
||||
| 3 | 工具函数去重 | 删除 textile/PlanOverview 中的 extractParamsFromLink,统一用 utils/helpers.ts |
|
||||
| 4 | 常量统一 | 删除各 Dashboard 本地 statusMap,统一从 utils/constants.ts 导入 |
|
||||
|
||||
#### 低优先级:类型安全
|
||||
|
||||
- 逐步替换 `any` 类型为 Supabase 生成类型 `Tables<'xxx'>`
|
||||
- 重点文件:usePlanData.ts, useInventoryData.ts, WarehouseManage.tsx
|
||||
|
||||
### 四、推荐执行顺序
|
||||
|
||||
```
|
||||
阶段1: 基础组件 + Loading 修复
|
||||
├── 创建 LoadingSpinner, EmptyState, StatCard, PageHeader, DeleteConfirmModal
|
||||
├── 替换所有内联 Loading 动画
|
||||
└── 移除 Dashboard 冗余 motion.div 嵌套
|
||||
|
||||
阶段2: 数据层重构
|
||||
├── 创建 useDashboardData Hook,替换3个Dashboard的数据获取
|
||||
├── 创建 PlanDataContext,消除 Prop Drilling
|
||||
├── 添加 useMemo 缓存大列表计算
|
||||
└── 创建 useErrorHandler,统一错误处理
|
||||
|
||||
阶段3: 业务组件抽象
|
||||
├── 创建 DashboardLayout 配置化组件
|
||||
├── 创建 PaymentCard, AccountProfileCard, ImageUploader
|
||||
├── 创建 WashingLayout(解决架构债务)
|
||||
└── 统一实时订阅至 useRealtime
|
||||
|
||||
阶段4: 代码治理 + 类型安全
|
||||
├── 工具函数/常量去重
|
||||
├── useEffect 依赖优化
|
||||
└── any 类型替换
|
||||
```
|
||||
|
||||
### 五、审查涉及的关键文件索引
|
||||
|
||||
| 文件 | 相关度 | 审查发现 |
|
||||
|------|--------|---------|
|
||||
| `src/App.tsx:66-87` | 高 | 路由级 AnimatePresence 动画嵌套源头 |
|
||||
| `src/pages/*/Dashboard.tsx` | 高 | 3个角色 Dashboard 代码重复率 ~70% |
|
||||
| `src/hooks/usePlanData.ts` | 高 | 依赖项不稳定、缺少计算缓存 |
|
||||
| `src/hooks/useInventoryData.ts` | 高 | 缓存策略与 usePlanData 不一致 |
|
||||
| `src/hooks/useRealtime.ts` | 高 | 部分页面绕过单例直接使用 supabase.channel |
|
||||
| `src/pages/purchaser/AccountsPayable.tsx` | 高 | 343行内联实现,无共享组件 |
|
||||
| `src/pages/washing/Dashboard.tsx` | 高 | 543行,无独立 Layout(架构债务) |
|
||||
| `src/pages/washing/PlanOverview.tsx` | 高 | 669行,内联流程组件 |
|
||||
| `src/pages/purchaser/FactoryManage.tsx` | 高 | 622行,内联删除确认弹窗 |
|
||||
| `src/pages/MemberManage.tsx` | 中 | 878行,内联删除确认 |
|
||||
| `src/components/PageTransition.tsx` | 中 | 页面过渡动画定义 |
|
||||
| `src/utils/helpers.ts` | 中 | 存在重复实现的工具函数 |
|
||||
578
README.md
578
README.md
@ -53,6 +53,57 @@
|
||||
| 水洗厂 | Violet | `from-violet-400 to-fuchsia-400` | Dashboard、通知中心 |
|
||||
| 系统/公共 | Blue | `from-blue-500 to-blue-600` | 登录页、通用组件 |
|
||||
|
||||
### 前端配置架构
|
||||
|
||||
系统采用**双层配置架构**,将所有可配置项从散落的 hardcode 集中到统一配置中心:
|
||||
|
||||
```
|
||||
src/config/app.ts ← 配置源头(环境感知、数值型配置、主题色、存储键)
|
||||
↓ 导入
|
||||
src/utils/constants.ts ← 向后兼容层(状态枚举、UI映射、步骤配置 + 重新导出app.ts配置)
|
||||
↓ 导入
|
||||
各业务文件 ← 消费方(Hooks、页面、组件)
|
||||
```
|
||||
|
||||
#### 配置模块清单 (`src/config/app.ts`)
|
||||
|
||||
| 配置模块 | 导出常量 | 说明 |
|
||||
|----------|---------|------|
|
||||
| 环境配置 | `APP_ENV`, `IS_DEV`, `IS_PROD` | 通过 webpack DefinePlugin 注入 `__APP_ENV__` |
|
||||
| 应用信息 | `APP_CONFIG` | 名称、版本号、Demo密码、免密登录有效期 |
|
||||
| 演示账号 | `DEMO_ACCOUNTS`, `SHOW_DEMO_ACCOUNTS` | 三个角色的演示账号配置 |
|
||||
| 缓存配置 | `CACHE_CONFIG` | 计划数据(10s)、库存数据(5s)、Dashboard(10s) |
|
||||
| 分页配置 | `PAGINATION_CONFIG` | 默认页大小(20)、入库记录上限(100)、价格历史上限(50) |
|
||||
| 同步配置 | `SYNC_CONFIG` | 计划状态同步间隔(开发10s/生产5min)、Realtime重连延迟 |
|
||||
| 动画配置 | `ANIMATION_CONFIG` | 标准/快速/慢速过渡、弹簧动画、页面过渡、交错延迟 |
|
||||
| 进度阈值 | `PROGRESS_THRESHOLDS` | 完成(97%)、低警告(30%)、中提示(60%) |
|
||||
| 角色主题色 | `ROLE_THEMES`, `getRoleTheme()` | purchaser/textile/washing/system 四套完整主题色 |
|
||||
| 文件上传 | `UPLOAD_CONFIG` | 图片大小限制(5MB)、允许类型、存储桶名称 |
|
||||
| 分享链接 | `SHARE_CONFIG` | 链接有效期(30min)、短码长度(8) |
|
||||
| 存储键 | `STORAGE_KEYS` | 所有 localStorage key 集中管理(14个键) |
|
||||
|
||||
#### 状态枚举常量 (`src/utils/constants.ts`)
|
||||
|
||||
| 枚举常量 | 值 | 对应数据库枚举 |
|
||||
|----------|-----|--------------|
|
||||
| `PLAN_STATUS` | PENDING/PRODUCING/COMPLETED/REJECTED | plan_status |
|
||||
| `STEP_STATUS` | PENDING/ACTIVE/COMPLETED/REJECTED | step_status |
|
||||
| `FACTORY_TYPE` | TEXTILE/WASHING | factory_type |
|
||||
| `WAREHOUSE_TYPE` | RAW_FABRIC/FABRIC/FINISHED/YARN | warehouse_type |
|
||||
| `PAYMENT_STATUS` | PENDING/COMPLETED | payment_status |
|
||||
| `SHARE_LINK_STATUS` | PENDING/CLICKED/CONFIRMED/REJECTED/EXPIRED/CANCELLED | share_links.status |
|
||||
| `WASHING_PLAN_STATUS` | PENDING/IN_PROGRESS/COMPLETED | washing_plans.status |
|
||||
| `INVENTORY_RECORD_TYPE` | IN/OUT | yarn_stock_records.record_type |
|
||||
| `OUTBOUND_TYPE` | MANUAL/AUTO | product_outbound_records.outbound_type |
|
||||
|
||||
#### 使用规范
|
||||
|
||||
1. **新增配置项**:统一添加到 `src/config/app.ts`,不要在业务文件中硬编码
|
||||
2. **状态字符串**:使用 `PLAN_STATUS.PENDING` 代替 `'pending'`,避免拼写错误
|
||||
3. **localStorage**:使用 `STORAGE_KEYS.savedUsername` 代替 `'saved_username'`
|
||||
4. **缓存时间**:使用 `CACHE_CONFIG.planDataDuration` 代替 `10000`
|
||||
5. **向后兼容**:`constants.ts` 重新导出 `animationConfig`/`paginationConfig`/`progressThresholds`,旧代码无需修改
|
||||
|
||||
### 设计架构
|
||||
- **CSS变量体系**: 完整50-900色阶、6级阴影系统、毛玻璃/滚动条美化等自定义工具类
|
||||
- **动效规范**: 页面过渡(opacity+slide, 0.35s)、列表交错(stagger 0.08)、Spring弹簧交互
|
||||
@ -180,12 +231,13 @@ src/
|
||||
│ ├── usePerformance.ts # 性能检测(三档动画降级)
|
||||
│ ├── useNetworkStatus.ts # 网络状态监听
|
||||
│ └── useVirtualScroll.ts # 虚拟滚动
|
||||
├── config/app.ts # 应用全局配置中心(环境/缓存/分页/动画/主题色/存储键等)
|
||||
├── contexts/AuthContext.tsx # 认证上下文(login/register/logout)
|
||||
├── styles/index.css # 全局样式(CSS变量+工具类+移动端适配)
|
||||
├── supabase/ # Supabase客户端(自动生成,禁止修改)
|
||||
├── types/index.ts # 统一类型定义中心(30+接口)
|
||||
└── utils/ # 工具函数
|
||||
├── constants.ts # 状态映射、动画配置、分页配置
|
||||
├── constants.ts # 状态枚举、UI映射、步骤配置(从config/app导入并重新导出)
|
||||
├── helpers.ts # 日期格式化、数字解析、分组计算
|
||||
└── shareCrypto.ts # HMAC-SHA256签名加密
|
||||
```
|
||||
@ -298,17 +350,21 @@ import { InventoryRecordsModal } from '../../components/InventoryRecordsModal';
|
||||
### 2026-06-04 (v2.0.0) - 重大版本更新
|
||||
- **系统审计与质量保障**
|
||||
- 完成四维度系统审计(文件关联/内存安全/路由配置/权限授权),全部通过
|
||||
- 新增 Bug 快速排查检查单(12大类90+检查项)
|
||||
- 修复数据完整性问题(孤儿记录/状态不一致/过期链接)
|
||||
- 新增 Bug 快速排查检查单(12大类90+检查项),覆盖页面白屏、数据读写、状态同步、内存性能、样式UI、认证权限、分享链接、构建部署等全部问题类型
|
||||
- 新增数据库诊断常用 SQL(7条)和高频 Bug 速查索引(14条)
|
||||
- 修复数据完整性问题:清理 share_links 孤儿记录、修复计划状态不一致、标记过期链接
|
||||
- **文档体系完善**
|
||||
- 完善数据库31张表完整文档,按6大模块组织
|
||||
- 新增设计架构规范(CSS变量/动效/响应式/布局差异)
|
||||
- API 调用示例扩展(Realtime/分享链接加密)
|
||||
- 完善数据库31张表完整文档,按6大模块组织(用户权限/生产计划/库存管理/财务结算/分享协作/系统辅助)
|
||||
- 新增设计架构规范(CSS变量体系/动效规范/响应式设计/布局架构差异)
|
||||
- 新增多角色主题色系统完整文档
|
||||
- API 调用示例扩展(Realtime订阅/分享链接加密解密)
|
||||
- **测试体系建设**
|
||||
- 新增 E2E 全流程测试 `e2e/full-workflow.spec.ts`
|
||||
- 完成端到端业务流程模拟测试并验证数据一致性
|
||||
- 新增 E2E 全流程测试文件 `e2e/full-workflow.spec.ts`
|
||||
- 完成采购商下单→分享链接→纺织厂确认→生产流程→分批次入库的端到端模拟测试
|
||||
- 验证数据一致性:completed_quantity = SUM(inventory_records.quantity)
|
||||
- **代码质量**
|
||||
- 项目目录结构更新至最新(33组件/25页面/9 Hooks)
|
||||
- usePlanStatusSync 测试间隔标注(10秒测试用,生产环境需改回5分钟)
|
||||
- 项目目录结构文档更新至最新状态(33组件/25页面/9 Hooks)
|
||||
|
||||
### 2025-06-02 (v1.0.36)
|
||||
- **水洗厂功能完善**
|
||||
@ -576,15 +632,15 @@ VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||
|
||||
# 应用配置
|
||||
VITE_APP_NAME=昱森(Demo版)
|
||||
VITE_APP_VERSION=2.0.0
|
||||
VITE_APP_VERSION=2.1.0
|
||||
```
|
||||
|
||||
### 生产环境
|
||||
生产环境变量通过 CI/CD 流水线注入,请勿在代码中硬编码敏感信息。
|
||||
|
||||
## 数据库架构(2026-06-04 更新)
|
||||
## 数据架构(2026-06-04 更新)
|
||||
|
||||
### 概览
|
||||
### 数据库概览
|
||||
|
||||
共 **31张业务表**,按功能分为6大模块:
|
||||
|
||||
@ -597,79 +653,497 @@ VITE_APP_VERSION=2.0.0
|
||||
| 分享与协作 | 2 | share_links, notifications |
|
||||
| 系统辅助 | 7 | audit_logs, user_feedback, crash_reports, product_yarn_ratios, product_outbound_records, washing_plan_completions, washing_process_steps |
|
||||
|
||||
### 核心表结构
|
||||
### ER 关系图
|
||||
|
||||
**companies** - 公司表
|
||||
```
|
||||
auth.users (Supabase内置)
|
||||
│
|
||||
├── 1:1 ── profiles (用户配置, master_id支持子账号)
|
||||
│ │
|
||||
│ ├── N:1 ── companies (公司, role:purchaser/textile/washing)
|
||||
│ │ │
|
||||
│ │ ├── 1:N ── company_members (公司成员)
|
||||
│ │ │
|
||||
│ │ ├── 1:N ── warehouses (仓库)
|
||||
│ │ │ └── 1:N ── inventory_records / yarn_stock
|
||||
│ │ │
|
||||
│ │ ├── 1:N ── products (产品信息)
|
||||
│ │ │ ├── 1:N ── product_inventory_records
|
||||
│ │ │ ├── 1:N ── product_yarn_ratios
|
||||
│ │ │ └── 1:N ── product_price_history
|
||||
│ │ │
|
||||
│ │ └── N:M ── company_relationships (合作关系)
|
||||
│ │
|
||||
│ ├── 1:N ── production_plans (生产计划)
|
||||
│ │ ├── 1:N ── plan_factories (关联工厂)
|
||||
│ │ ├── 1:N ── yarn_ratios (纱线配比)
|
||||
│ │ ├── 1:N ── plan_process_steps (流程节点)
|
||||
│ │ ├── 1:N ── inventory_records (入库记录)
|
||||
│ │ ├── 1:N ── payments (结款)
|
||||
│ │ └── 1:N ── production_price_history
|
||||
│ │
|
||||
│ └── 1:N ── washing_plans (水洗计划)
|
||||
│ ├── 1:N ── washing_process_steps
|
||||
│ ├── 1:N ── washing_plan_completions
|
||||
│ └── 1:N ── finished_products
|
||||
```
|
||||
|
||||
### 表结构详细设计(31张表)
|
||||
|
||||
#### 一、用户与权限模块
|
||||
|
||||
**companies** - 公司表 (12条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| name | TEXT NOT NULL | 公司名称 |
|
||||
| role | app_role NOT NULL | purchaser/textile/washing |
|
||||
| address / contact_phone | TEXT | 地址/电话 |
|
||||
| address | TEXT | 公司地址 |
|
||||
| contact_phone | TEXT | 联系电话 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**profiles** - 用户配置表
|
||||
**profiles** - 用户配置表 (8条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | 关联 auth.users.id |
|
||||
| username | TEXT UNIQUE | 用户名 |
|
||||
| username | TEXT NOT NULL UNIQUE | 用户名 |
|
||||
| phone | TEXT | 手机号 |
|
||||
| company_id | UUID FK | 所属公司 |
|
||||
| is_master | BOOLEAN | 是否主账号 |
|
||||
| master_id | UUID FK | 主账号ID(子账号) |
|
||||
| display_name / image_url | TEXT | 显示名/头像 |
|
||||
| is_master | BOOLEAN NOT NULL | 是否主账号,默认true |
|
||||
| master_id | UUID FK | 主账号ID(子账号使用) |
|
||||
| display_name | TEXT | 显示名称 |
|
||||
| image_url | TEXT | 头像URL |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**production_plans** - 生产计划表
|
||||
**company_members** - 公司成员表 (3条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_code | TEXT UNIQUE | 计划编号 |
|
||||
| company_id | UUID FK NOT NULL | 公司ID |
|
||||
| user_id | UUID FK NOT NULL | 用户ID |
|
||||
| role | TEXT NOT NULL | 成员角色,默认'member' |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**company_relationships** - 公司合作关系表 (2条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| purchaser_company_id | UUID FK NOT NULL | 采购商公司ID |
|
||||
| factory_company_id | UUID FK NOT NULL | 工厂公司ID |
|
||||
| factory_type | factory_type NOT NULL | textile/washing |
|
||||
| status | TEXT NOT NULL | active/inactive,默认'active' |
|
||||
| created_at / updated_at | TIMESTAMPTZ | 时间戳 |
|
||||
|
||||
#### 二、生产计划模块
|
||||
|
||||
**production_plans** - 生产计划表 (3条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_code | TEXT NOT NULL UNIQUE | 计划编号 |
|
||||
| product_name | TEXT NOT NULL | 成品名称 |
|
||||
| color | TEXT NOT NULL | 颜色 |
|
||||
| fabric_code | TEXT NOT NULL | 坯布识别码 |
|
||||
| color_code | TEXT NOT NULL | 色号,默认'01' |
|
||||
| remark | TEXT | 备注 |
|
||||
| purchaser_id | UUID FK NOT NULL | 采购商公司ID |
|
||||
| target_quantity | INTEGER NOT NULL | 计划产量(米),默认0 |
|
||||
| completed_quantity | INTEGER NOT NULL | 已完成产量(米),默认0 |
|
||||
| yarn_usage_per_meter | NUMERIC | 每米纱用量(g/m),默认0 |
|
||||
| production_price | NUMERIC | 生产采购价(元/米),默认0 |
|
||||
| status | plan_status NOT NULL | pending/producing/completed |
|
||||
| start_time | TIMESTAMPTZ | 计划开始时间 |
|
||||
| created_by | UUID FK | 创建人 |
|
||||
| created_at / updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**plan_factories** - 计划工厂关联表 (3条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_id | UUID FK NOT NULL | 计划ID |
|
||||
| factory_id | UUID FK NOT NULL | 工厂公司ID |
|
||||
| factory_type | factory_type NOT NULL | textile/washing |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**yarn_ratios** - 纱线配比表 (2条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_id | UUID FK NOT NULL | 计划ID |
|
||||
| yarn_name | TEXT NOT NULL | 纱线名称 |
|
||||
| ratio | NUMERIC NOT NULL | 配比比例,默认1 |
|
||||
| amount_per_meter | NUMERIC NOT NULL | 每米用量(g/m) |
|
||||
| total_amount | NUMERIC NOT NULL | 总用纱量 |
|
||||
| company_id | UUID FK | 公司ID |
|
||||
| yarn_type | TEXT | 纱线类型,默认'all' |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**plan_process_steps** - 计划流程节点表 (15条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_id | UUID FK NOT NULL | 计划ID |
|
||||
| step_type | step_type NOT NULL | confirm/yarn_purchase/dyeing/machine_start/fabric_warehouse |
|
||||
| status | step_status NOT NULL | pending/active/completed/rejected |
|
||||
| timestamp | TIMESTAMPTZ | 确认时间戳 |
|
||||
| operator_id | UUID FK | 操作人 |
|
||||
| notes | TEXT | 备注 |
|
||||
| company_id | UUID FK | 公司ID |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**washing_plans** - 水洗计划表 (1条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_code | TEXT NOT NULL | 水洗计划编号 |
|
||||
| company_id | UUID FK NOT NULL | 采购商公司ID |
|
||||
| product_id | UUID FK NOT NULL | 产品ID |
|
||||
| washing_factory_id | UUID FK | 水洗厂公司ID |
|
||||
| planned_meters | NUMERIC NOT NULL | 计划水洗米数 |
|
||||
| washing_price | NUMERIC NOT NULL | 水洗单价(元/米) |
|
||||
| estimated_shrinkage_rate | NUMERIC NOT NULL | 预估缩水率(%) |
|
||||
| estimated_washed_meters | NUMERIC | 预估洗后米数(自动计算) |
|
||||
| estimated_total_cost | NUMERIC | 预估总费用(自动计算) |
|
||||
| actual_shrinkage_rate | NUMERIC | 实际缩水率(%) |
|
||||
| actual_washed_meters | NUMERIC | 实际洗后米数 |
|
||||
| actual_total_cost | NUMERIC | 实际总费用 |
|
||||
| washing_date | DATE | 水洗日期 |
|
||||
| status | TEXT NOT NULL | pending/in_progress/completed |
|
||||
| notes | TEXT | 备注 |
|
||||
| created_by | UUID FK | 创建人 |
|
||||
| created_at / updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
#### 三、库存管理模块
|
||||
|
||||
**products** - 产品信息表 (9条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| company_id | UUID FK NOT NULL | 所属公司 |
|
||||
| product_name | TEXT NOT NULL | 产品名称 |
|
||||
| weight | NUMERIC NOT NULL | 克重(g) |
|
||||
| color | TEXT NOT NULL | 颜色 |
|
||||
| fabric_code | TEXT NOT NULL | 坯布码 |
|
||||
| color_code | TEXT NOT NULL | 色号,默认'01' |
|
||||
| yarn_usage_per_meter | NUMERIC NOT NULL | 每米纱用量 |
|
||||
| yarn_types | TEXT[] | 纱线类型数组 |
|
||||
| yarn_ratios | NUMERIC[] | 纱线配比数组 |
|
||||
| raw_fabric_rolls | INTEGER NOT NULL | 坯布匹数 |
|
||||
| raw_fabric_meters | NUMERIC NOT NULL | 坯布米数 |
|
||||
| total_stock | NUMERIC NOT NULL | 总库存 |
|
||||
| production_price | NUMERIC | 生产价格 |
|
||||
| image_url | TEXT | 产品图片URL |
|
||||
| warp_weight | NUMERIC | 经线克重 |
|
||||
| weft_weight | NUMERIC | 纬线克重 |
|
||||
| total_weight | NUMERIC | 总克重 |
|
||||
| created_at / updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**warehouses** - 仓库表 (5条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| company_id | UUID FK NOT NULL | 所属公司 |
|
||||
| name | TEXT NOT NULL | 仓库名称 |
|
||||
| type | warehouse_type NOT NULL | raw_fabric/fabric/finished/yarn |
|
||||
| location | TEXT | 仓库位置 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**inventory_records** - 入库记录表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_id | UUID FK NOT NULL | 关联计划ID |
|
||||
| warehouse_id | UUID FK NOT NULL | 仓库ID |
|
||||
| quantity | NUMERIC NOT NULL | 入库米数 |
|
||||
| rolls | INTEGER | 入库匹数 |
|
||||
| warehouse_location | TEXT | 库位 |
|
||||
| operator_id | UUID FK | 操作人 |
|
||||
| company_id | UUID FK | 公司ID |
|
||||
| price_per_meter | NUMERIC | 单价 |
|
||||
| price_note | TEXT | 价格备注 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**product_inventory_records** - 产品出入库记录表 (4条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| product_id | UUID FK NOT NULL | 产品ID |
|
||||
| warehouse_id | UUID FK | 仓库ID |
|
||||
| batch_no | TEXT NOT NULL | 批号 |
|
||||
| rolls | INTEGER NOT NULL | 匹数 |
|
||||
| meters | NUMERIC NOT NULL | 米数 |
|
||||
| operator_id | UUID FK | 操作人 |
|
||||
| notes | TEXT | 备注 |
|
||||
| company_id | UUID FK | 公司ID |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**yarn_stock** - 纱线库存表 (4条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| company_id | UUID FK NOT NULL | 所属公司 |
|
||||
| warehouse_id | UUID FK | 仓库ID |
|
||||
| name | TEXT NOT NULL | 纱线名称 |
|
||||
| spec | TEXT | 规格 |
|
||||
| quantity | NUMERIC NOT NULL | 库存数量(kg) |
|
||||
| min_stock | NUMERIC NOT NULL | 最低库存预警(kg) |
|
||||
| unit | TEXT NOT NULL | 单位,默认'kg' |
|
||||
| updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**yarn_stock_records** - 纱线出入库记录表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| company_id | UUID FK NOT NULL | 公司ID |
|
||||
| yarn_stock_id | UUID FK | 纱线库存ID |
|
||||
| plan_id | UUID FK | 关联计划ID |
|
||||
| record_type | TEXT NOT NULL | in/out |
|
||||
| quantity | NUMERIC NOT NULL | 数量(kg) |
|
||||
| unit | TEXT NOT NULL | 单位,默认'kg' |
|
||||
| batch_no | TEXT | 批号 |
|
||||
| notes | TEXT | 备注 |
|
||||
| operator_id | UUID FK | 操作人 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**finished_products** - 成品表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| company_id | UUID FK NOT NULL | 所属公司 |
|
||||
| washing_plan_id | UUID FK | 水洗计划ID |
|
||||
| product_id | UUID FK | 产品ID |
|
||||
| product_name / color / fabric_code / color_code | TEXT | 产品信息 |
|
||||
| purchaser_id | UUID FK | 采购商公司ID |
|
||||
| target_quantity / completed_quantity | INTEGER | 计划/完成产量(米) |
|
||||
| yarn_usage_per_meter / production_price | NUMERIC | 纱用量/单价 |
|
||||
| status | plan_status | pending/producing/completed |
|
||||
| weight | NUMERIC | 克重 |
|
||||
| washed_meters | NUMERIC NOT NULL | 洗后米数 |
|
||||
| shrinkage_rate | NUMERIC NOT NULL | 缩水率 |
|
||||
| rolls | INTEGER | 匹数 |
|
||||
| stock_meters / stock_rolls | NUMERIC/INTEGER | 库存 |
|
||||
| warehouse_id / warehouse_location | UUID/TEXT | 仓库信息 |
|
||||
| washing_cost / unit_cost | NUMERIC | 成本 |
|
||||
| status | TEXT NOT NULL | in_stock/out_of_stock |
|
||||
| created_at / updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**products** - 产品信息表
|
||||
**finished_product_inventory_records** - 成品出入库记录表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| company_id | UUID FK | 所属公司 |
|
||||
| product_name / color / fabric_code / color_code | TEXT | 产品标识 |
|
||||
| weight / warp_weight / weft_weight / total_weight | NUMERIC | 克重信息 |
|
||||
| yarn_types (TEXT[]) / yarn_ratios (NUMERIC[]) | ARRAY | 纱线配置 |
|
||||
| production_price / image_url | NUMERIC/TEXT | 价格/图片 |
|
||||
| company_id | UUID FK NOT NULL | 公司ID |
|
||||
| finished_product_id | UUID FK NOT NULL | 成品ID |
|
||||
| record_type | TEXT NOT NULL | in/out |
|
||||
| rolls / meters | INTEGER/NUMERIC | 数量 |
|
||||
| washing_completion_id | UUID FK | 水洗完成记录ID |
|
||||
| related_order_id / related_order_type | UUID/TEXT | 关联订单 |
|
||||
| warehouse_id / warehouse_location | UUID/TEXT | 仓库信息 |
|
||||
| batch_no / notes | TEXT | 批号/备注 |
|
||||
| operator_id | UUID FK | 操作人 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**washing_plans** - 水洗计划表
|
||||
#### 四、财务结算模块
|
||||
|
||||
**payments** - 结款记录表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| company_id / product_id / washing_factory_id | UUID FK | 关联ID |
|
||||
| planned_meters / washing_price | NUMERIC | 计划米数/单价 |
|
||||
| estimated_shrinkage_rate / actual_shrinkage_rate | NUMERIC | 缩水率 |
|
||||
| status | TEXT | pending/in_progress/completed |
|
||||
| plan_id | UUID FK NOT NULL | 关联计划ID |
|
||||
| from_company_id | UUID FK NOT NULL | 付款方 |
|
||||
| to_company_id | UUID FK NOT NULL | 收款方 |
|
||||
| amount | NUMERIC NOT NULL | 结款金额 |
|
||||
| quantity | NUMERIC NOT NULL | 结款数量(米) |
|
||||
| price_per_meter | NUMERIC NOT NULL | 单价 |
|
||||
| status | payment_status NOT NULL | pending/completed |
|
||||
| paid_at | TIMESTAMPTZ | 结款时间 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**share_links** - 分享链接表
|
||||
**accounts_payable** - 应付账款表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| short_code | TEXT | 短码 |
|
||||
| plan_id | UUID FK | 计划ID |
|
||||
| factory_type | TEXT | textile/washing |
|
||||
| hide_sensitive | BOOLEAN | 隐藏敏感信息 |
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_id | UUID FK NOT NULL | 计划ID |
|
||||
| purchaser_id | UUID FK NOT NULL | 采购商ID |
|
||||
| textile_factory_id | UUID FK NOT NULL | 纺织厂ID |
|
||||
| total_amount / paid_amount / unpaid_amount | NUMERIC | 金额统计 |
|
||||
| total_quantity / paid_quantity / unpaid_quantity | NUMERIC | 数量统计 |
|
||||
| price_per_meter | NUMERIC NOT NULL | 单价 |
|
||||
| status | TEXT NOT NULL | unpaid/partial/paid |
|
||||
| created_at / updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**accounts_payable_items** - 应付账款明细表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| accounts_payable_id | UUID FK NOT NULL | 应付账款ID |
|
||||
| inventory_record_id | UUID FK | 入库记录ID |
|
||||
| quantity / rolls / price_per_meter / amount | NUMERIC/INTEGER | 明细数据 |
|
||||
| status | TEXT NOT NULL | unpaid/paid |
|
||||
| payment_id | UUID FK | 关联结款ID |
|
||||
| created_at / paid_at | TIMESTAMPTZ | 时间戳 |
|
||||
|
||||
**production_price_history** - 生产价格变更历史 (3条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| plan_id | UUID FK NOT NULL | 计划ID |
|
||||
| old_price / new_price / change_amount | NUMERIC NOT NULL | 价格变更 |
|
||||
| operator_id / operator_name | UUID/TEXT | 操作人 |
|
||||
| change_reason | TEXT | 变更原因 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**product_price_history** - 产品价格变更历史 (3条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| product_id | UUID FK NOT NULL | 产品ID |
|
||||
| company_id | UUID FK NOT NULL | 公司ID |
|
||||
| old_price / new_price / change_amount | NUMERIC NOT NULL | 价格变更 |
|
||||
| operator_id / operator_name | UUID/TEXT | 操作人 |
|
||||
| change_reason | TEXT | 变更原因 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
#### 五、分享与协作模块
|
||||
|
||||
**share_links** - 分享链接表 (2条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| short_code | TEXT NOT NULL | 短码 |
|
||||
| plan_id | UUID FK NOT NULL | 计划ID |
|
||||
| factory_type | TEXT NOT NULL | textile/washing |
|
||||
| created_by | UUID FK NOT NULL | 创建人 |
|
||||
| expires_at | TIMESTAMPTZ | 过期时间 |
|
||||
| click_count | INTEGER | 点击次数,默认0 |
|
||||
| hide_sensitive | BOOLEAN | 隐藏敏感信息,默认false |
|
||||
| used_at | TIMESTAMPTZ | 使用时间 |
|
||||
| status | TEXT | pending/clicked/confirmed/rejected/expired/cancelled |
|
||||
| status | TEXT NOT NULL | pending/clicked/confirmed/rejected/expired/cancelled |
|
||||
| clicked_at / responded_at | TIMESTAMPTZ | 时间戳 |
|
||||
| response_result | TEXT | confirmed/rejected |
|
||||
| reject_reason | TEXT | 拒绝原因 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
> 完整31张表的详细字段定义请参阅 `AGENTS.md` 数据架构章节。
|
||||
**notifications** - 通知表 (4条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| recipient_id | UUID FK NOT NULL | 接收人 |
|
||||
| sender_id / sender_company_id | UUID FK | 发送人/公司 |
|
||||
| plan_id | UUID FK | 关联计划 |
|
||||
| type | TEXT NOT NULL | 通知类型 |
|
||||
| title / content | TEXT NOT NULL | 标题/内容 |
|
||||
| is_read | BOOLEAN | 已读标记,默认false |
|
||||
| created_at / read_at | TIMESTAMPTZ | 时间戳 |
|
||||
|
||||
### 枚举类型
|
||||
#### 六、系统辅助模块
|
||||
|
||||
**audit_logs** - 审计日志表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| user_id / company_id | UUID FK | 用户/公司 |
|
||||
| action | TEXT NOT NULL | 操作类型 |
|
||||
| resource_type / resource_id | TEXT/UUID | 资源类型/ID |
|
||||
| details | JSONB | 详细信息 |
|
||||
| ip_address / user_agent | TEXT | 请求信息 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**user_feedback** - 用户反馈表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| type | TEXT NOT NULL | 反馈类型 |
|
||||
| content | TEXT NOT NULL | 反馈内容 |
|
||||
| contact | TEXT | 联系方式 |
|
||||
| user_id | UUID FK | 用户ID |
|
||||
| status | TEXT NOT NULL | pending/resolved |
|
||||
| created_at / updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**crash_reports** - 崩溃报告表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| error_message | TEXT NOT NULL | 错误信息 |
|
||||
| error_stack / component_stack | TEXT | 堆栈信息 |
|
||||
| user_description | TEXT | 用户描述 |
|
||||
| user_agent / url | TEXT | 环境信息 |
|
||||
| user_id | UUID FK | 用户ID |
|
||||
| status | TEXT NOT NULL | pending/resolved |
|
||||
| created_at / updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**product_yarn_ratios** - 产品纱线配比表 (13条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| product_id | UUID FK NOT NULL | 产品ID |
|
||||
| yarn_name | TEXT NOT NULL | 纱线名称 |
|
||||
| ratio | NUMERIC NOT NULL | 配比 |
|
||||
| yarn_type | TEXT | 纱线类型,默认'all' |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**product_outbound_records** - 产品出库记录表 (1条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| product_id | UUID FK NOT NULL | 产品ID |
|
||||
| company_id | UUID FK | 公司ID |
|
||||
| batch_no | TEXT NOT NULL | 批号 |
|
||||
| rolls / meters | INTEGER/NUMERIC | 数量 |
|
||||
| washing_plan_id | UUID FK | 水洗计划ID |
|
||||
| outbound_type | TEXT NOT NULL | manual/auto |
|
||||
| source_batch_id | UUID FK | 源批次ID |
|
||||
| recipient_company_id / recipient_company_name | UUID/TEXT | 接收方 |
|
||||
| notes / operator_id | TEXT/UUID | 备注/操作人 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**washing_plan_completions** - 水洗完成记录表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| company_id | UUID FK NOT NULL | 公司ID |
|
||||
| washing_plan_id | UUID FK NOT NULL | 水洗计划ID |
|
||||
| actual_washed_meters / actual_shrinkage_rate | NUMERIC NOT NULL | 实际数据 |
|
||||
| rolls | INTEGER NOT NULL | 匹数 |
|
||||
| washing_date | DATE NOT NULL | 水洗日期 |
|
||||
| actual_washing_cost / unit_cost | NUMERIC | 成本 |
|
||||
| warehouse_id / warehouse_location | UUID/TEXT | 仓库 |
|
||||
| auto_imported | BOOLEAN | 自动导入标记 |
|
||||
| finished_product_id | UUID FK | 成品ID |
|
||||
| completed_by / completed_at | UUID/TIMESTAMPTZ | 完成人/时间 |
|
||||
| created_at | TIMESTAMPTZ | now() |
|
||||
|
||||
**washing_process_steps** - 水洗流程节点表 (0条)
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | UUID PK | gen_random_uuid() |
|
||||
| washing_plan_id | UUID FK NOT NULL | 水洗计划ID |
|
||||
| company_id | UUID FK NOT NULL | 公司ID |
|
||||
| step_type | TEXT NOT NULL | 步骤类型 |
|
||||
| status | TEXT NOT NULL | pending/completed |
|
||||
| timestamp | TIMESTAMPTZ | 确认时间 |
|
||||
| operator_id | UUID FK | 操作人 |
|
||||
| notes | TEXT | 备注 |
|
||||
| created_at / updated_at | TIMESTAMPTZ | now() |
|
||||
|
||||
### 自定义枚举类型
|
||||
|
||||
```sql
|
||||
app_role: purchaser | textile | washing
|
||||
factory_type: textile | washing
|
||||
plan_status: pending | producing | completed
|
||||
step_type: confirm | yarn_purchase | dyeing | machine_start | fabric_warehouse
|
||||
step_status: pending | active | completed | rejected
|
||||
warehouse_type: raw_fabric | fabric | finished | yarn
|
||||
payment_status: pending | completed
|
||||
-- 应用角色(公司类型)
|
||||
CREATE TYPE app_role AS ENUM ('purchaser', 'textile', 'washing');
|
||||
|
||||
-- 工厂类型
|
||||
CREATE TYPE factory_type AS ENUM ('textile', 'washing');
|
||||
|
||||
-- 计划状态
|
||||
CREATE TYPE plan_status AS ENUM ('pending', 'producing', 'completed');
|
||||
|
||||
-- 流程节点类型
|
||||
CREATE TYPE step_type AS ENUM ('confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse');
|
||||
|
||||
-- 流程节点状态
|
||||
CREATE TYPE step_status AS ENUM ('pending', 'active', 'completed', 'rejected');
|
||||
|
||||
-- 仓库类型
|
||||
CREATE TYPE warehouse_type AS ENUM ('raw_fabric', 'fabric', 'finished', 'yarn');
|
||||
|
||||
-- 结款状态
|
||||
CREATE TYPE payment_status AS ENUM ('pending', 'completed');
|
||||
```
|
||||
|
||||
### RLS 策略
|
||||
|
||||
259
docs/DATA_INTEGRITY_GUIDE.md
Normal file
259
docs/DATA_INTEGRITY_GUIDE.md
Normal file
@ -0,0 +1,259 @@
|
||||
# 数据完整性保障方案 - 万分之一错误率目标
|
||||
|
||||
## 📊 质量目标
|
||||
|
||||
- **系统错误率**: ≤ 0.01% (万分之一)
|
||||
- **财务数据准确率**: 100%
|
||||
- **核心业务数据一致性**: 100%
|
||||
|
||||
## 🏗️ 四层防护体系
|
||||
|
||||
### 第一层:数据库强约束(最后一道防线)
|
||||
|
||||
**文件**: `migrations/20260604_200000_data_integrity_constraints.sql`
|
||||
|
||||
#### 1.1 触发器校验
|
||||
- ✅ 计划数量一致性验证 (`validate_plan_quantity_consistency`)
|
||||
- ✅ 入库记录正数验证 (`validate_inventory_record_positive`)
|
||||
- ✅ 付款金额一致性验证 (`validate_payment_amount`)
|
||||
- ✅ 应付账款汇总验证 (`validate_accounts_payable_totals`)
|
||||
|
||||
#### 1.2 CHECK 约束
|
||||
```sql
|
||||
-- 入库记录非负
|
||||
CHECK (quantity >= 0 AND rolls >= 0 AND price_per_meter >= 0)
|
||||
|
||||
-- 付款金额非负
|
||||
CHECK (amount >= 0 AND quantity >= 0 AND price_per_meter >= 0)
|
||||
|
||||
-- 应付账款逻辑约束
|
||||
CHECK (paid_amount <= total_amount AND unpaid_amount <= total_amount)
|
||||
```
|
||||
|
||||
#### 1.3 审计日志
|
||||
所有关键表的数据变更自动记录到 `data_audit_logs` 表,包含:
|
||||
- 变更前后的值
|
||||
- 操作人
|
||||
- 操作时间
|
||||
- 验证是否通过
|
||||
|
||||
### 第二层:前端预检校验(写入前拦截)
|
||||
|
||||
**文件**: `src/utils/dataValidation.ts`
|
||||
|
||||
#### 2.1 校验函数
|
||||
```typescript
|
||||
// 入库记录校验
|
||||
validateInventoryRecord({ quantity, rolls, price_per_meter })
|
||||
|
||||
// 付款记录校验
|
||||
validatePayment({ amount, quantity, price_per_meter })
|
||||
|
||||
// 计划完成数量校验
|
||||
validatePlanCompletion({ target_quantity, completed_quantity })
|
||||
```
|
||||
|
||||
#### 2.2 安全写入包装器
|
||||
```typescript
|
||||
// 双重校验 + 写入后验证
|
||||
await safeInsertInventoryRecord(record);
|
||||
await safeInsertPayment(payment);
|
||||
```
|
||||
|
||||
### 第三层:自动化监控(持续检测)
|
||||
|
||||
#### 3.1 Edge Function 定期检查
|
||||
**文件**: `functions/data-integrity-check/index.ts`
|
||||
|
||||
- ⏰ 建议每小时执行一次
|
||||
- 🔍 检查所有核心表的数据一致性
|
||||
- 📊 计算错误率并与阈值对比
|
||||
- 🚨 超标时生成告警
|
||||
|
||||
**部署命令**:
|
||||
```bash
|
||||
meoo-cli cloud deploy-function -n data-integrity-check
|
||||
```
|
||||
|
||||
#### 3.2 数据库视图实时监控
|
||||
```sql
|
||||
-- 计划数量一致性检查
|
||||
SELECT * FROM v_plan_quantity_check WHERE check_result = '✗ 不一致';
|
||||
|
||||
-- 付款金额检查
|
||||
SELECT * FROM v_payment_check WHERE check_result = '✗ 不一致';
|
||||
|
||||
-- 应付账款检查
|
||||
SELECT * FROM v_accounts_payable_check WHERE check_result = '✗ 不一致';
|
||||
|
||||
-- 错误率监控
|
||||
SELECT * FROM v_error_rate_monitor WHERE error_rate_per_10k > 1;
|
||||
```
|
||||
|
||||
### 第四层:可视化监控面板
|
||||
|
||||
**文件**: `src/components/DataIntegrityDashboard.tsx`
|
||||
|
||||
功能:
|
||||
- 📈 实时显示系统数据质量状态
|
||||
- 🎯 错误率趋势图(近7天)
|
||||
- ⚠️ 异常项目高亮显示
|
||||
- 📋 最近失败记录列表
|
||||
- 🔄 支持手动触发检查
|
||||
|
||||
## 🚀 实施步骤
|
||||
|
||||
### 步骤1:执行数据库迁移
|
||||
|
||||
```bash
|
||||
# 读取迁移脚本内容
|
||||
cat migrations/20260604_200000_data_integrity_constraints.sql
|
||||
|
||||
# 分批执行(避免单次SQL过长)
|
||||
meoo-cli cloud migrate --name "data_integrity_constraints" \
|
||||
--changes "添加数据完整性强约束、触发器、审计日志和监控视图" \
|
||||
--sql "$(cat migrations/20260604_200000_data_integrity_constraints.sql)"
|
||||
```
|
||||
|
||||
### 步骤2:部署 Edge Function
|
||||
|
||||
```bash
|
||||
# 部署数据完整性检查函数
|
||||
meoo-cli cloud deploy-function -n data-integrity-check
|
||||
|
||||
# 配置定时任务(在 Supabase Dashboard 中设置)
|
||||
# 建议:每小时执行一次
|
||||
```
|
||||
|
||||
### 步骤3:集成前端组件
|
||||
|
||||
在管理后台或系统设置页面添加监控面板:
|
||||
|
||||
```tsx
|
||||
import { DataIntegrityDashboard } from './components/DataIntegrityDashboard';
|
||||
|
||||
// 在路由中添加
|
||||
<Route path="/admin/data-integrity" element={<DataIntegrityDashboard />} />
|
||||
```
|
||||
|
||||
### 步骤4:替换现有写入操作
|
||||
|
||||
将现有的直接写入替换为安全写入:
|
||||
|
||||
```typescript
|
||||
// ❌ 旧方式
|
||||
await supabase.from('inventory_records').insert(record);
|
||||
|
||||
// ✅ 新方式
|
||||
import { safeInsertInventoryRecord } from '../utils/dataValidation';
|
||||
await safeInsertInventoryRecord(record);
|
||||
```
|
||||
|
||||
需要替换的文件:
|
||||
- `src/pages/textile/FabricWarehouse.tsx` - 坯布入库
|
||||
- `src/pages/textile/PlanOverview.tsx` - 流程确认
|
||||
- `src/pages/purchaser/WarehouseManage.tsx` - 产品入库
|
||||
- `src/pages/*/PaymentPending.tsx` - 付款操作
|
||||
|
||||
### 步骤5:配置告警通知(可选)
|
||||
|
||||
在 Edge Function 中集成告警渠道:
|
||||
- 📧 邮件通知
|
||||
- 💬 钉钉/企业微信
|
||||
- 📱 短信告警
|
||||
|
||||
## 📋 日常运维检查清单
|
||||
|
||||
### 每日检查
|
||||
- [ ] 查看数据完整性监控面板
|
||||
- [ ] 确认错误率 ≤ 1/万
|
||||
- [ ] 检查是否有新的失败记录
|
||||
|
||||
### 每周检查
|
||||
- [ ] 分析错误率趋势
|
||||
- [ ] 审查审计日志中的异常操作
|
||||
- [ ] 验证备份数据完整性
|
||||
|
||||
### 每月检查
|
||||
- [ ] 运行全量数据一致性检查
|
||||
- [ ] 评估是否需要调整阈值
|
||||
- [ ] 更新数据质量报告
|
||||
|
||||
## 🔧 故障处理流程
|
||||
|
||||
### 发现数据不一致
|
||||
|
||||
1. **立即定位**
|
||||
```sql
|
||||
-- 查找不一致的记录
|
||||
SELECT * FROM v_plan_quantity_check WHERE check_result = '✗ 不一致';
|
||||
```
|
||||
|
||||
2. **分析原因**
|
||||
```sql
|
||||
-- 查看审计日志
|
||||
SELECT * FROM data_audit_logs
|
||||
WHERE record_id = '{问题记录ID}'
|
||||
ORDER BY changed_at DESC;
|
||||
```
|
||||
|
||||
3. **修复数据**
|
||||
- 优先使用事务保证原子性
|
||||
- 修复后重新运行一致性检查
|
||||
- 记录修复过程到审计日志
|
||||
|
||||
4. **根因分析**
|
||||
- 是代码bug?→ 修复代码并添加测试
|
||||
- 是并发问题?→ 添加锁机制或使用事务
|
||||
- 是用户误操作?→ 加强前端校验和提示
|
||||
|
||||
### 错误率突然升高
|
||||
|
||||
1. 检查最近的代码部署
|
||||
2. 查看审计日志中的失败模式
|
||||
3. 检查数据库负载和性能
|
||||
4. 必要时回滚最近的变更
|
||||
|
||||
## 📊 监控指标定义
|
||||
|
||||
| 指标 | 计算公式 | 目标值 | 告警阈值 |
|
||||
|------|---------|--------|---------|
|
||||
| 系统错误率 | (不一致记录数 / 总记录数) × 10000 | ≤ 1/万 | > 1/万 |
|
||||
| 计划数量一致率 | 一致的计划数 / 总计划数 | 100% | < 99.99% |
|
||||
| 付款金额准确率 | 准确的付款数 / 总付款数 | 100% | < 99.99% |
|
||||
| 应付账款准确率 | 准确的账款数 / 总账款数 | 100% | < 99.99% |
|
||||
|
||||
## 🎯 持续改进
|
||||
|
||||
### 短期优化(1-2周)
|
||||
- [ ] 完成数据库约束部署
|
||||
- [ ] 集成前端校验工具
|
||||
- [ ] 部署 Edge Function
|
||||
- [ ] 添加监控面板
|
||||
|
||||
### 中期优化(1-2月)
|
||||
- [ ] 建立自动化测试覆盖
|
||||
- [ ] 实现数据修复工具
|
||||
- [ ] 完善告警通知机制
|
||||
- [ ] 培训运维人员
|
||||
|
||||
### 长期优化(3-6月)
|
||||
- [ ] 引入机器学习异常检测
|
||||
- [ ] 建立数据质量评分体系
|
||||
- [ ] 实现自动修复机制
|
||||
- [ ] 定期第三方审计
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如遇数据完整性问题,请按以下优先级处理:
|
||||
|
||||
1. **P0 - 财务数据错误**: 立即停止相关操作,联系技术负责人
|
||||
2. **P1 - 核心业务数据不一致**: 1小时内响应,4小时内修复
|
||||
3. **P2 - 非关键数据异常**: 24小时内处理
|
||||
4. **P3 - 监控告警**: 按日常运维流程处理
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-04
|
||||
**版本**: v1.0
|
||||
**维护者**: 技术团队
|
||||
143
e2e/README.md
Normal file
143
e2e/README.md
Normal file
@ -0,0 +1,143 @@
|
||||
# E2E 全流程模拟测试指南
|
||||
|
||||
## 测试文件说明
|
||||
|
||||
### 1. full-workflow-simulation.spec.ts
|
||||
完整的全流程模拟测试,覆盖以下场景:
|
||||
- **阶段一**:采购商创建计划(登录、产品信息、新建计划)
|
||||
- **阶段二**:分享链接与首次合作(计划总览、导入计划)
|
||||
- **阶段三**:纺织厂生产流程(登录、查看计划、确认流程节点)
|
||||
- **阶段四**:分批次入库(坯布仓库、入库操作)
|
||||
- **阶段五**:数据验证(计划完成状态、成品仓库、待结款)
|
||||
- **多角色协作**:双窗口实时同步测试
|
||||
- **边界情况**:未登录访问、无效 token、离线提示
|
||||
- **性能测试**:页面加载时间、资源加载错误检测
|
||||
|
||||
### 2. database-validation.sql
|
||||
数据库层面的 SQL 验证脚本,包含:
|
||||
- 环境准备(清理旧数据)
|
||||
- 完整业务流程 SQL 序列
|
||||
- 数据完整性验证查询
|
||||
- 综合验证报告
|
||||
|
||||
### 3. core-workflow.spec.ts
|
||||
核心业务流程测试(登录、注册、路由、响应式布局)
|
||||
|
||||
### 4. collaboration-workflow.spec.ts
|
||||
协作流程测试(采购商视角、纺织厂视角、数据同步)
|
||||
|
||||
## 运行测试
|
||||
|
||||
### 前置条件
|
||||
1. 安装依赖:`pnpm install`
|
||||
2. 安装浏览器:`npx playwright install chromium`
|
||||
3. 启动开发服务器:`pnpm run dev`
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npx playwright test
|
||||
|
||||
# 运行特定测试文件
|
||||
npx playwright test e2e/full-workflow-simulation.spec.ts
|
||||
|
||||
# 运行特定项目(浏览器)
|
||||
npx playwright test --project=chromium
|
||||
npx playwright test --project=firefox
|
||||
npx playwright test --project=webkit
|
||||
|
||||
# 运行特定测试(按名称匹配)
|
||||
npx playwright test -g "采购商登录"
|
||||
|
||||
# 调试模式
|
||||
npx playwright test --debug
|
||||
|
||||
# 生成 HTML 报告
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
### 数据库验证
|
||||
|
||||
使用 meoo-cli 执行 SQL 验证:
|
||||
|
||||
```bash
|
||||
# 验证计划基本信息
|
||||
meoo-cli cloud query --sql "SELECT plan_code, status, target_quantity, completed_quantity FROM production_plans WHERE plan_code = 'TEST-2026-001';"
|
||||
|
||||
# 验证流程节点完成情况
|
||||
meoo-cli cloud query --sql "SELECT COUNT(*) as total_steps, SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_steps FROM plan_process_steps WHERE plan_id IN (SELECT id FROM production_plans WHERE plan_code = 'TEST-2026-001');"
|
||||
|
||||
# 验证入库记录汇总
|
||||
meoo-cli cloud query --sql "SELECT COUNT(*) as batches, SUM(quantity) as total_meters FROM inventory_records WHERE plan_id IN (SELECT id FROM production_plans WHERE plan_code = 'TEST-2026-001');"
|
||||
|
||||
# 验证数据一致性
|
||||
meoo-cli cloud query --sql "SELECT p.completed_quantity, COALESCE(SUM(ir.quantity), 0) as inventory_total, CASE WHEN p.completed_quantity = COALESCE(SUM(ir.quantity), 0) THEN '✓ 一致' ELSE '✗ 不一致' END as check_result FROM production_plans p LEFT JOIN inventory_records ir ON ir.plan_id = p.id WHERE p.plan_code = 'TEST-2026-001' GROUP BY p.completed_quantity;"
|
||||
```
|
||||
|
||||
## 测试数据结构
|
||||
|
||||
### 测试用固定 ID
|
||||
- 采购商公司:`11111111-1111-1111-1111-111111111101`
|
||||
- 纺织厂公司:`11111111-1111-1111-1111-111111111102`
|
||||
- 采购商用户:`a0000001-0000-0000-0000-000000000001`
|
||||
- 纺织厂用户:`a0000002-0000-0000-0000-000000000002`
|
||||
- 坯布仓库:`22222222-2222-2222-2222-222222222202`
|
||||
|
||||
### 预期测试结果
|
||||
- 计划编号:`TEST-2026-001`
|
||||
- 计划状态:`completed`
|
||||
- 目标产量:`10000` 米
|
||||
- 完成产量:`10000` 米
|
||||
- 流程节点:`5/5` 已完成
|
||||
- 入库批次:`3` 批
|
||||
- 总入库量:`10000` 米
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 浏览器未安装
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
```
|
||||
|
||||
### Q: 开发服务器未启动
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
### Q: 测试超时
|
||||
检查网络连接和数据库服务状态:
|
||||
```bash
|
||||
meoo-cli cloud status
|
||||
```
|
||||
|
||||
### Q: 如何查看测试报告
|
||||
```bash
|
||||
npx playwright show-report
|
||||
```
|
||||
|
||||
## 测试覆盖率
|
||||
|
||||
| 测试类别 | 测试数量 | 覆盖场景 |
|
||||
|---------|---------|---------|
|
||||
| 全流程模拟 | 20 | 完整业务流程 |
|
||||
| 核心业务 | 9 | 登录、注册、路由 |
|
||||
| 协作流程 | 9 | 多角色协作 |
|
||||
| 边界情况 | 3 | 异常处理 |
|
||||
| 性能测试 | 2 | 加载时间、错误检测 |
|
||||
| **总计** | **43** | - |
|
||||
|
||||
## 持续集成
|
||||
|
||||
在 CI/CD 中运行测试:
|
||||
|
||||
```yaml
|
||||
- name: Run E2E Tests
|
||||
run: |
|
||||
npx playwright install --with-deps chromium
|
||||
npx playwright test --project=chromium
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
- **2026-06-04**: 创建全流程模拟测试文件和数据库验证脚本
|
||||
344
e2e/database-validation.sql
Normal file
344
e2e/database-validation.sql
Normal file
@ -0,0 +1,344 @@
|
||||
-- ============================================================================
|
||||
-- 全流程模拟测试 - 数据库验证脚本
|
||||
-- ============================================================================
|
||||
-- 用途:验证采购商下单 → 分享链接 → 纺织厂确认 → 生产流程 → 分批次入库
|
||||
-- 的完整业务流程数据一致性
|
||||
--
|
||||
-- 执行方式:通过 meoo-cli cloud query --sql "..." 逐条执行
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 阶段零:环境准备(清理旧数据,模拟首次合作)
|
||||
-- ============================================================================
|
||||
|
||||
-- 0.1 清理旧的合作关系(模拟首次合作场景)
|
||||
DELETE FROM company_relationships
|
||||
WHERE purchaser_company_id = '11111111-1111-1111-1111-111111111101'
|
||||
AND factory_company_id = '11111111-1111-1111-1111-111111111102';
|
||||
|
||||
-- 0.2 清理旧的测试计划(避免重复)
|
||||
DELETE FROM production_plans WHERE plan_code = 'TEST-2026-001';
|
||||
|
||||
-- 0.3 清理旧的测试产品
|
||||
DELETE FROM products WHERE fabric_code = 'QM-XW-280-01'
|
||||
AND company_id = '11111111-1111-1111-1111-111111111101';
|
||||
|
||||
-- ============================================================================
|
||||
-- 阶段一:采购商创建产品和生产计划
|
||||
-- ============================================================================
|
||||
|
||||
-- 1.1 创建产品信息
|
||||
INSERT INTO products (
|
||||
company_id, product_name, weight, color, fabric_code, color_code,
|
||||
yarn_usage_per_meter, yarn_types, yarn_ratios, raw_fabric_rolls,
|
||||
raw_fabric_meters, total_stock, production_price, warp_weight,
|
||||
weft_weight, total_weight
|
||||
) VALUES (
|
||||
'11111111-1111-1111-1111-111111111101', -- 采购商公司ID
|
||||
'全棉斜纹布',
|
||||
280,
|
||||
'藏青',
|
||||
'QM-XW-280-01',
|
||||
'01',
|
||||
350,
|
||||
ARRAY['精梳棉纱','涤纶纱'],
|
||||
ARRAY[0.7,0.3],
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
18.5,
|
||||
160,
|
||||
120,
|
||||
280
|
||||
);
|
||||
|
||||
-- 1.2 获取刚创建的产品ID(用于后续关联)
|
||||
-- SELECT id FROM products WHERE fabric_code = 'QM-XW-280-01'
|
||||
-- AND company_id = '11111111-1111-1111-1111-111111111101';
|
||||
|
||||
-- 1.3 创建生产计划
|
||||
INSERT INTO production_plans (
|
||||
plan_code, product_name, color, fabric_code, color_code,
|
||||
remark, purchaser_id, target_quantity, completed_quantity,
|
||||
yarn_usage_per_meter, production_price, status, start_time, created_by
|
||||
) VALUES (
|
||||
'TEST-2026-001',
|
||||
'全棉斜纹布',
|
||||
'藏青',
|
||||
'QM-XW-280-01',
|
||||
'01',
|
||||
'模拟测试订单',
|
||||
'11111111-1111-1111-1111-111111111101', -- 采购商公司ID
|
||||
10000, -- 目标产量10000米
|
||||
0, -- 初始完成量为0
|
||||
350, -- 每米纱用量
|
||||
18.5, -- 生产采购价
|
||||
'pending',
|
||||
NOW(),
|
||||
'a0000001-0000-0000-0000-000000000001' -- 采购商用户ID
|
||||
);
|
||||
|
||||
-- 1.4 获取刚创建的计划ID(用于后续关联)
|
||||
-- SELECT id FROM production_plans WHERE plan_code = 'TEST-2026-001';
|
||||
|
||||
-- 1.5 创建纱线配比(假设计划ID为 {plan_id})
|
||||
-- INSERT INTO yarn_ratios (plan_id, yarn_name, ratio, amount_per_meter, total_amount, company_id)
|
||||
-- VALUES ('{plan_id}', '精梳棉纱', 0.7, 245, 2450000, '11111111-1111-1111-1111-111111111101'),
|
||||
-- ('{plan_id}', '涤纶纱', 0.3, 105, 1050000, '11111111-1111-1111-1111-111111111101');
|
||||
|
||||
-- 1.6 创建流程节点(5个标准节点)
|
||||
-- INSERT INTO plan_process_steps (plan_id, step_type, status, company_id)
|
||||
-- VALUES ('{plan_id}', 'confirm', 'pending', '11111111-1111-1111-1111-111111111101'),
|
||||
-- ('{plan_id}', 'yarn_purchase', 'pending', '11111111-1111-1111-1111-111111111101'),
|
||||
-- ('{plan_id}', 'dyeing', 'pending', '11111111-1111-1111-1111-111111111101'),
|
||||
-- ('{plan_id}', 'machine_start', 'pending', '11111111-1111-1111-1111-111111111101'),
|
||||
-- ('{plan_id}', 'fabric_warehouse', 'pending', '11111111-1111-1111-1111-111111111101');
|
||||
|
||||
-- ============================================================================
|
||||
-- 阶段二:生成分享链接(首次合作)
|
||||
-- ============================================================================
|
||||
|
||||
-- 2.1 生成分享链接
|
||||
-- INSERT INTO share_links (short_code, plan_id, factory_type, created_by, expires_at, status)
|
||||
-- VALUES ('TEST-SIM-001', '{plan_id}', 'textile', 'a0000001-0000-0000-0000-000000000001',
|
||||
-- NOW() + INTERVAL '30 minutes', 'pending');
|
||||
|
||||
-- ============================================================================
|
||||
-- 阶段三:纺织厂点击链接并确认导入
|
||||
-- ============================================================================
|
||||
|
||||
-- 3.1 纺织厂点击链接(更新状态为 clicked)
|
||||
-- UPDATE share_links SET status = 'clicked', clicked_at = NOW(), click_count = 1
|
||||
-- WHERE short_code = 'TEST-SIM-001';
|
||||
|
||||
-- 3.2 纺织厂确认导入 - 创建工厂关联
|
||||
-- INSERT INTO plan_factories (plan_id, factory_id, factory_type)
|
||||
-- VALUES ('{plan_id}', '11111111-1111-1111-1111-111111111102', 'textile');
|
||||
|
||||
-- 3.3 更新分享链接状态为 confirmed
|
||||
-- UPDATE share_links SET status = 'confirmed', responded_at = NOW(),
|
||||
-- response_result = 'confirmed', used_at = NOW()
|
||||
-- WHERE short_code = 'TEST-SIM-001';
|
||||
|
||||
-- 3.4 建立合作关系
|
||||
-- INSERT INTO company_relationships (purchaser_company_id, factory_company_id, factory_type, status)
|
||||
-- VALUES ('11111111-1111-1111-1111-111111111101', '11111111-1111-1111-1111-111111111102', 'textile', 'active');
|
||||
|
||||
-- 3.5 更新计划状态为 producing
|
||||
-- UPDATE production_plans SET status = 'producing' WHERE id = '{plan_id}';
|
||||
|
||||
-- ============================================================================
|
||||
-- 阶段四:纺织厂确认生产流程节点
|
||||
-- ============================================================================
|
||||
|
||||
-- 4.1 确认所有流程节点(5个节点全部完成)
|
||||
-- UPDATE plan_process_steps SET status = 'completed', timestamp = NOW(),
|
||||
-- operator_id = 'a0000002-0000-0000-0000-000000000002' -- 纺织厂用户ID
|
||||
-- WHERE plan_id = '{plan_id}';
|
||||
|
||||
-- ============================================================================
|
||||
-- 阶段五:分批次坯布入库(3批)
|
||||
-- ============================================================================
|
||||
|
||||
-- 5.1 第1批入库:3000米,30匹
|
||||
-- INSERT INTO inventory_records (plan_id, warehouse_id, quantity, rolls, company_id, operator_id, price_per_meter)
|
||||
-- VALUES ('{plan_id}', '22222222-2222-2222-2222-222222222202', 3000, 30,
|
||||
-- '11111111-1111-1111-1111-111111111102', 'a0000002-0000-0000-0000-000000000002', 18.5);
|
||||
|
||||
-- 5.2 第2批入库:4000米,40匹
|
||||
-- INSERT INTO inventory_records (plan_id, warehouse_id, quantity, rolls, company_id, operator_id, price_per_meter)
|
||||
-- VALUES ('{plan_id}', '22222222-2222-2222-2222-222222222202', 4000, 40,
|
||||
-- '11111111-1111-1111-1111-111111111102', 'a0000002-0000-0000-0000-000000000002', 18.5);
|
||||
|
||||
-- 5.3 第3批入库:3000米,28匹
|
||||
-- INSERT INTO inventory_records (plan_id, warehouse_id, quantity, rolls, company_id, operator_id, price_per_meter)
|
||||
-- VALUES ('{plan_id}', '22222222-2222-2222-2222-222222222202', 3000, 28,
|
||||
-- '11111111-1111-1111-1111-111111111102', 'a0000002-0000-0000-0000-000000000002', 18.5);
|
||||
|
||||
-- 5.4 更新计划完成状态
|
||||
-- UPDATE production_plans SET completed_quantity = 10000, status = 'completed'
|
||||
-- WHERE id = '{plan_id}';
|
||||
|
||||
-- ============================================================================
|
||||
-- 阶段六:数据完整性验证查询
|
||||
-- ============================================================================
|
||||
|
||||
-- 6.1 验证计划基本信息
|
||||
SELECT
|
||||
p.plan_code,
|
||||
p.status,
|
||||
p.target_quantity,
|
||||
p.completed_quantity,
|
||||
p.product_name,
|
||||
p.color,
|
||||
p.fabric_code,
|
||||
c.name as purchaser_name
|
||||
FROM production_plans p
|
||||
LEFT JOIN companies c ON c.id = p.purchaser_id
|
||||
WHERE p.plan_code = 'TEST-2026-001';
|
||||
|
||||
-- 预期结果:
|
||||
-- plan_code: TEST-2026-001
|
||||
-- status: completed
|
||||
-- target_quantity: 10000
|
||||
-- completed_quantity: 10000
|
||||
-- product_name: 全棉斜纹布
|
||||
-- color: 藏青
|
||||
-- fabric_code: QM-XW-280-01
|
||||
|
||||
-- 6.2 验证流程节点完成情况
|
||||
SELECT
|
||||
p.plan_code,
|
||||
COUNT(*) as total_steps,
|
||||
SUM(CASE WHEN ps.status = 'completed' THEN 1 ELSE 0 END) as completed_steps,
|
||||
ARRAY_AGG(ps.step_type || ':' || ps.status) as step_details
|
||||
FROM production_plans p
|
||||
LEFT JOIN plan_process_steps ps ON ps.plan_id = p.id
|
||||
WHERE p.plan_code = 'TEST-2026-001'
|
||||
GROUP BY p.plan_code;
|
||||
|
||||
-- 预期结果:
|
||||
-- total_steps: 5
|
||||
-- completed_steps: 5
|
||||
-- step_details: {confirm:completed, yarn_purchase:completed, dyeing:completed, machine_start:completed, fabric_warehouse:completed}
|
||||
|
||||
-- 6.3 验证入库记录汇总
|
||||
SELECT
|
||||
p.plan_code,
|
||||
COUNT(ir.id) as inventory_batches,
|
||||
COALESCE(SUM(ir.quantity), 0) as total_meters,
|
||||
COALESCE(SUM(ir.rolls), 0) as total_rolls,
|
||||
AVG(ir.price_per_meter) as avg_price
|
||||
FROM production_plans p
|
||||
LEFT JOIN inventory_records ir ON ir.plan_id = p.id
|
||||
WHERE p.plan_code = 'TEST-2026-001'
|
||||
GROUP BY p.plan_code;
|
||||
|
||||
-- 预期结果:
|
||||
-- inventory_batches: 3
|
||||
-- total_meters: 10000
|
||||
-- total_rolls: 98
|
||||
-- avg_price: 18.5
|
||||
|
||||
-- 6.4 验证数据一致性(completed_quantity = SUM(inventory_records.quantity))
|
||||
SELECT
|
||||
p.plan_code,
|
||||
p.completed_quantity,
|
||||
COALESCE(SUM(ir.quantity), 0) as inventory_total,
|
||||
CASE
|
||||
WHEN p.completed_quantity = COALESCE(SUM(ir.quantity), 0) THEN '✓ 一致'
|
||||
ELSE '✗ 不一致'
|
||||
END as consistency_check
|
||||
FROM production_plans p
|
||||
LEFT JOIN inventory_records ir ON ir.plan_id = p.id
|
||||
WHERE p.plan_code = 'TEST-2026-001'
|
||||
GROUP BY p.plan_code, p.completed_quantity;
|
||||
|
||||
-- 预期结果:consistency_check = '✓ 一致'
|
||||
|
||||
-- 6.5 验证分享链接状态
|
||||
SELECT
|
||||
sl.short_code,
|
||||
sl.status,
|
||||
sl.factory_type,
|
||||
sl.click_count,
|
||||
sl.used_at IS NOT NULL as is_used,
|
||||
sl.expires_at > NOW() as is_valid,
|
||||
c.name as creator_company
|
||||
FROM share_links sl
|
||||
LEFT JOIN profiles pr ON pr.id = sl.created_by
|
||||
LEFT JOIN companies c ON c.id = pr.company_id
|
||||
WHERE sl.short_code = 'TEST-SIM-001';
|
||||
|
||||
-- 预期结果:
|
||||
-- status: confirmed
|
||||
-- is_used: true
|
||||
-- is_valid: false (已使用)
|
||||
|
||||
-- 6.6 验证合作关系建立
|
||||
SELECT
|
||||
cr.status,
|
||||
cr.factory_type,
|
||||
pc.name as purchaser_company,
|
||||
fc.name as factory_company,
|
||||
cr.created_at
|
||||
FROM company_relationships cr
|
||||
LEFT JOIN companies pc ON pc.id = cr.purchaser_company_id
|
||||
LEFT JOIN companies fc ON fc.id = cr.factory_company_id
|
||||
WHERE cr.purchaser_company_id = '11111111-1111-1111-1111-111111111101'
|
||||
AND cr.factory_company_id = '11111111-1111-1111-1111-111111111102';
|
||||
|
||||
-- 预期结果:
|
||||
-- status: active
|
||||
-- factory_type: textile
|
||||
|
||||
-- 6.7 验证工厂关联
|
||||
SELECT
|
||||
pf.factory_type,
|
||||
c.name as factory_name,
|
||||
pf.created_at
|
||||
FROM plan_factories pf
|
||||
LEFT JOIN companies c ON c.id = pf.factory_id
|
||||
LEFT JOIN production_plans p ON p.id = pf.plan_id
|
||||
WHERE p.plan_code = 'TEST-2026-001';
|
||||
|
||||
-- 预期结果:
|
||||
-- factory_type: textile
|
||||
-- factory_name: 示例纺织厂
|
||||
|
||||
-- 6.8 综合验证报告
|
||||
SELECT
|
||||
'=== 全流程模拟测试验证报告 ===' as section,
|
||||
'' as detail
|
||||
UNION ALL
|
||||
SELECT
|
||||
'计划编号: ' || p.plan_code,
|
||||
'状态: ' || p.status
|
||||
FROM production_plans p WHERE p.plan_code = 'TEST-2026-001'
|
||||
UNION ALL
|
||||
SELECT
|
||||
'目标产量: ' || p.target_quantity || ' 米',
|
||||
'完成产量: ' || p.completed_quantity || ' 米'
|
||||
FROM production_plans p WHERE p.plan_code = 'TEST-2026-001'
|
||||
UNION ALL
|
||||
SELECT
|
||||
'入库批次: ' || COUNT(ir.id),
|
||||
'总入库量: ' || COALESCE(SUM(ir.quantity), 0) || ' 米'
|
||||
FROM production_plans p
|
||||
LEFT JOIN inventory_records ir ON ir.plan_id = p.id
|
||||
WHERE p.plan_code = 'TEST-2026-001'
|
||||
UNION ALL
|
||||
SELECT
|
||||
'流程节点: ' || COUNT(ps.id),
|
||||
'已完成: ' || SUM(CASE WHEN ps.status = 'completed' THEN 1 ELSE 0 END)
|
||||
FROM production_plans p
|
||||
LEFT JOIN plan_process_steps ps ON ps.plan_id = p.id
|
||||
WHERE p.plan_code = 'TEST-2026-001'
|
||||
UNION ALL
|
||||
SELECT
|
||||
'数据一致性: ' ||
|
||||
CASE
|
||||
WHEN p.completed_quantity = COALESCE((SELECT SUM(quantity) FROM inventory_records WHERE plan_id = p.id), 0)
|
||||
THEN '✓ 通过'
|
||||
ELSE '✗ 失败'
|
||||
END,
|
||||
''
|
||||
FROM production_plans p WHERE p.plan_code = 'TEST-2026-001';
|
||||
|
||||
-- ============================================================================
|
||||
-- 阶段七:清理测试数据(可选)
|
||||
-- ============================================================================
|
||||
|
||||
-- 7.1 清理测试计划及相关数据
|
||||
-- DELETE FROM inventory_records WHERE plan_id IN (SELECT id FROM production_plans WHERE plan_code = 'TEST-2026-001');
|
||||
-- DELETE FROM plan_process_steps WHERE plan_id IN (SELECT id FROM production_plans WHERE plan_code = 'TEST-2026-001');
|
||||
-- DELETE FROM yarn_ratios WHERE plan_id IN (SELECT id FROM production_plans WHERE plan_code = 'TEST-2026-001');
|
||||
-- DELETE FROM plan_factories WHERE plan_id IN (SELECT id FROM production_plans WHERE plan_code = 'TEST-2026-001');
|
||||
-- DELETE FROM share_links WHERE short_code = 'TEST-SIM-001';
|
||||
-- DELETE FROM production_plans WHERE plan_code = 'TEST-2026-001';
|
||||
-- DELETE FROM products WHERE fabric_code = 'QM-XW-280-01' AND company_id = '11111111-1111-1111-1111-111111111101';
|
||||
-- DELETE FROM company_relationships WHERE purchaser_company_id = '11111111-1111-1111-1111-111111111101' AND factory_company_id = '11111111-1111-1111-1111-111111111102';
|
||||
|
||||
-- ============================================================================
|
||||
-- 结束
|
||||
-- ============================================================================
|
||||
457
e2e/full-workflow-simulation.spec.ts
Normal file
457
e2e/full-workflow-simulation.spec.ts
Normal file
@ -0,0 +1,457 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* 全流程模拟测试:采购商下单 → 分享链接 → 纺织厂确认 → 生产流程 → 分批次入库
|
||||
*
|
||||
* 测试场景:
|
||||
* 1. 采购商创建产品和生产计划
|
||||
* 2. 生成分享链接给首次合作的纺织厂
|
||||
* 3. 纺织厂点击链接并确认导入
|
||||
* 4. 纺织厂逐步确认生产流程节点
|
||||
* 5. 分批次坯布入库(3批)
|
||||
* 6. 验证数据完整性和一致性
|
||||
*
|
||||
* 前置条件:
|
||||
* - 示例布行 (11111111-1111-1111-1111-111111111101) 和示例纺织厂 (11111111-1111-1111-1111-111111111102) 已存在
|
||||
* - 两家公司之间无合作关系(首次合作场景)
|
||||
* - 坯布仓库 (22222222-2222-2222-2222-222222222202) 已存在
|
||||
*/
|
||||
|
||||
// 测试用固定 ID
|
||||
const PURCHASER_COMPANY_ID = '11111111-1111-1111-1111-111111111101';
|
||||
const TEXTILE_COMPANY_ID = '11111111-1111-1111-1111-111111111102';
|
||||
const PURCHASER_USER_ID = 'a0000001-0000-0000-0000-000000000001';
|
||||
const TEXTILE_USER_ID = 'a0000002-0000-0000-0000-000000000002';
|
||||
const FABRIC_WAREHOUSE_ID = '22222222-2222-2222-2222-222222222202';
|
||||
|
||||
// 辅助函数:登录
|
||||
async function loginAs(page: Page, username: string, password: string) {
|
||||
await page.goto('/login');
|
||||
await page.fill('input[name="username"]', username);
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.click('button[type="submit"]');
|
||||
// 等待登录完成并跳转
|
||||
await page.waitForURL(/.*(purchaser|textile|washing)/, { timeout: 10000 });
|
||||
}
|
||||
|
||||
// 辅助函数:等待页面加载完成
|
||||
async function waitForPageLoad(page: Page) {
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
test.describe('全流程模拟:采购商下单到纺织厂入库', () => {
|
||||
|
||||
test.describe('阶段一:采购商创建计划', () => {
|
||||
|
||||
test('1.1 采购商登录并进入工作台', async ({ page }) => {
|
||||
await loginAs(page, 'purchaser', 'purchaser123');
|
||||
|
||||
// 验证跳转到采购商工作台
|
||||
await expect(page).toHaveURL(/.*purchaser/);
|
||||
await expect(page.locator('text=首页')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 验证统计卡片显示
|
||||
const statCards = page.locator('[class*="stat"], [class*="card"]');
|
||||
expect(await statCards.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('1.2 创建产品信息', async ({ page }) => {
|
||||
await loginAs(page, 'purchaser', 'purchaser123');
|
||||
await page.goto('/purchaser/warehouse');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证产品信息页面加载
|
||||
await expect(page.locator('text=产品信息')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 检查是否有产品列表或空状态提示
|
||||
const hasProducts = await page.locator('[class*="product"], [class*="list"]').count() > 0;
|
||||
const hasEmptyState = await page.locator('text=暂无').count() > 0;
|
||||
expect(hasProducts || hasEmptyState).toBeTruthy();
|
||||
});
|
||||
|
||||
test('1.3 新建纺织计划', async ({ page }) => {
|
||||
await loginAs(page, 'purchaser', 'purchaser123');
|
||||
await page.goto('/purchaser/plans/new');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证新建计划页面加载
|
||||
await expect(page.locator('text=新建纺织计划')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 验证表单元素存在
|
||||
const formElements = [
|
||||
'input[name="productName"], input[placeholder*="产品"]',
|
||||
'input[name="color"], input[placeholder*="颜色"]',
|
||||
'input[name="targetQuantity"], input[placeholder*="数量"]'
|
||||
];
|
||||
|
||||
for (const selector of formElements) {
|
||||
const element = page.locator(selector).first();
|
||||
if (await element.count() > 0) {
|
||||
await expect(element).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('阶段二:分享链接与首次合作', () => {
|
||||
|
||||
test('2.1 进入计划总览并分享', async ({ page }) => {
|
||||
await loginAs(page, 'purchaser', 'purchaser123');
|
||||
await page.goto('/purchaser/plans');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证计划总览页面加载
|
||||
await expect(page.locator('text=纺织计划总览')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 检查是否有计划列表
|
||||
const planCards = page.locator('[class*="plan"], [class*="card"]');
|
||||
const planCount = await planCards.count();
|
||||
console.log(`找到 ${planCount} 个计划卡片`);
|
||||
});
|
||||
|
||||
test('2.2 纺织厂通过链接导入计划', async ({ page }) => {
|
||||
// 模拟纺织厂访问导入页面
|
||||
await page.goto('/import?token=test-token');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证导入页面加载
|
||||
await expect(page).toHaveURL(/.*import/);
|
||||
|
||||
// 检查页面是否显示导入相关信息
|
||||
const hasImportContent = await page.locator('text=导入, text=计划, text=确认').count() > 0;
|
||||
expect(hasImportContent).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('阶段三:纺织厂生产流程', () => {
|
||||
|
||||
test('3.1 纺织厂登录并查看计划', async ({ page }) => {
|
||||
await loginAs(page, 'textile', 'textile123');
|
||||
|
||||
// 验证跳转到纺织厂工作台
|
||||
await expect(page).toHaveURL(/.*textile/);
|
||||
await expect(page.locator('text=首页')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('3.2 查看计划总览和生产进度', async ({ page }) => {
|
||||
await loginAs(page, 'textile', 'textile123');
|
||||
await page.goto('/textile/plans');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证计划总览页面
|
||||
await expect(page.locator('text=接单与生产进度')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 检查是否有计划列表或空状态
|
||||
const hasPlans = await page.locator('[class*="plan"], [class*="card"]').count() > 0;
|
||||
const hasEmptyState = await page.locator('text=暂无, text=没有').count() > 0;
|
||||
expect(hasPlans || hasEmptyState).toBeTruthy();
|
||||
});
|
||||
|
||||
test('3.3 确认生产流程节点', async ({ page }) => {
|
||||
await loginAs(page, 'textile', 'textile123');
|
||||
await page.goto('/textile/plans');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证流程节点组件存在
|
||||
const processFlow = page.locator('[data-testid="process-flow"], [class*="process"], [class*="flow"]');
|
||||
const flowCount = await processFlow.count();
|
||||
console.log(`找到 ${flowCount} 个流程组件`);
|
||||
|
||||
// 如果有流程组件,验证其可见性
|
||||
if (flowCount > 0) {
|
||||
await expect(processFlow.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('阶段四:分批次入库', () => {
|
||||
|
||||
test('4.1 纺织厂进入坯布仓库', async ({ page }) => {
|
||||
await loginAs(page, 'textile', 'textile123');
|
||||
await page.goto('/textile/fabric-warehouse');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证坯布仓库页面
|
||||
await expect(page.locator('text=已生产坯布')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 检查是否有库存列表或空状态
|
||||
const hasInventory = await page.locator('[class*="inventory"], [class*="list"], [class*="table"]').count() > 0;
|
||||
const hasEmptyState = await page.locator('text=暂无, text=空').count() > 0;
|
||||
expect(hasInventory || hasEmptyState).toBeTruthy();
|
||||
});
|
||||
|
||||
test('4.2 执行入库操作', async ({ page }) => {
|
||||
await loginAs(page, 'textile', 'textile123');
|
||||
await page.goto('/textile/fabric-warehouse');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证入库表单或按钮存在
|
||||
const inboundButton = page.locator('button:has-text("入库"), button:has-text("添加")');
|
||||
const buttonCount = await inboundButton.count();
|
||||
console.log(`找到 ${buttonCount} 个入库相关按钮`);
|
||||
|
||||
if (buttonCount > 0) {
|
||||
await expect(inboundButton.first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('阶段五:数据验证', () => {
|
||||
|
||||
test('5.1 采购商验证计划完成状态', async ({ page }) => {
|
||||
await loginAs(page, 'purchaser', 'purchaser123');
|
||||
await page.goto('/purchaser/plans');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证计划列表中存在已完成计划(如果有)
|
||||
const completedBadge = page.locator('text=已完成, text=completed');
|
||||
const completedCount = await completedBadge.count();
|
||||
console.log(`找到 ${completedCount} 个已完成标记`);
|
||||
|
||||
// 页面应该正常加载
|
||||
await expect(page.locator('body')).toBeVisible();
|
||||
});
|
||||
|
||||
test('5.2 采购商验证成品仓库', async ({ page }) => {
|
||||
await loginAs(page, 'purchaser', 'purchaser123');
|
||||
await page.goto('/purchaser/finished-warehouse');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证成品仓库页面加载
|
||||
await expect(page.locator('text=成品仓库')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('5.3 纺织厂验证待结款', async ({ page }) => {
|
||||
await loginAs(page, 'textile', 'textile123');
|
||||
await page.goto('/textile/payments');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 验证待结款页面
|
||||
await expect(page.locator('text=待结款')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('多角色协作实时同步测试', () => {
|
||||
|
||||
test('采购商和纺织厂双窗口实时同步', async ({ browser }) => {
|
||||
// 创建两个独立的浏览器上下文
|
||||
const purchaserContext = await browser.newContext();
|
||||
const textileContext = await browser.newContext();
|
||||
|
||||
const purchaserPage = await purchaserContext.newPage();
|
||||
const textilePage = await textileContext.newPage();
|
||||
|
||||
try {
|
||||
// 采购商登录
|
||||
await loginAs(purchaserPage, 'purchaser', 'purchaser123');
|
||||
await purchaserPage.goto('/purchaser/plans');
|
||||
await waitForPageLoad(purchaserPage);
|
||||
|
||||
// 纺织厂登录
|
||||
await loginAs(textilePage, 'textile', 'textile123');
|
||||
await textilePage.goto('/textile/plans');
|
||||
await waitForPageLoad(textilePage);
|
||||
|
||||
// 验证两个页面都正常加载
|
||||
await expect(purchaserPage.locator('body')).toBeVisible();
|
||||
await expect(textilePage.locator('body')).toBeVisible();
|
||||
|
||||
console.log('双窗口实时同步测试完成');
|
||||
|
||||
} finally {
|
||||
await purchaserContext.close();
|
||||
await textileContext.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('数据库层面全流程验证(SQL 模拟)', () => {
|
||||
/**
|
||||
* 以下为本次模拟测试的 SQL 执行记录,可作为后续自动化测试的参考
|
||||
*
|
||||
* 完整流程 SQL 序列:
|
||||
*
|
||||
* -- 1. 清理旧的合作关系(模拟首次合作)
|
||||
* DELETE FROM company_relationships
|
||||
* WHERE purchaser_company_id = '${PURCHASER_COMPANY_ID}'
|
||||
* AND factory_company_id = '${TEXTILE_COMPANY_ID}';
|
||||
*
|
||||
* -- 2. 创建产品
|
||||
* INSERT INTO products (company_id, product_name, weight, color, fabric_code, color_code,
|
||||
* yarn_usage_per_meter, yarn_types, yarn_ratios, raw_fabric_rolls, raw_fabric_meters,
|
||||
* total_stock, production_price, warp_weight, weft_weight, total_weight)
|
||||
* VALUES ('${PURCHASER_COMPANY_ID}', '全棉斜纹布', 280, '藏青', 'QM-XW-280-01', '01',
|
||||
* 350, ARRAY['精梳棉纱','涤纶纱'], ARRAY[0.7,0.3], 0, 0, 0, 18.5, 160, 120, 280);
|
||||
*
|
||||
* -- 3. 创建生产计划
|
||||
* INSERT INTO production_plans (plan_code, product_name, color, fabric_code, color_code,
|
||||
* remark, purchaser_id, target_quantity, completed_quantity, yarn_usage_per_meter,
|
||||
* production_price, status, start_time, created_by)
|
||||
* VALUES ('TEST-2026-001', '全棉斜纹布', '藏青', 'QM-XW-280-01', '01',
|
||||
* '模拟测试订单', '${PURCHASER_COMPANY_ID}', 10000, 0, 350, 18.5, 'pending', NOW(),
|
||||
* '${PURCHASER_USER_ID}');
|
||||
*
|
||||
* -- 4. 创建纱线配比
|
||||
* INSERT INTO yarn_ratios (plan_id, yarn_name, ratio, amount_per_meter, total_amount, company_id)
|
||||
* VALUES ('{plan_id}', '精梳棉纱', 0.7, 245, 2450000, '${PURCHASER_COMPANY_ID}'),
|
||||
* ('{plan_id}', '涤纶纱', 0.3, 105, 1050000, '${PURCHASER_COMPANY_ID}');
|
||||
*
|
||||
* -- 5. 创建流程节点
|
||||
* INSERT INTO plan_process_steps (plan_id, step_type, status, company_id)
|
||||
* VALUES ('{plan_id}', 'confirm', 'pending', '${PURCHASER_COMPANY_ID}'),
|
||||
* ('{plan_id}', 'yarn_purchase', 'pending', '${PURCHASER_COMPANY_ID}'),
|
||||
* ('{plan_id}', 'dyeing', 'pending', '${PURCHASER_COMPANY_ID}'),
|
||||
* ('{plan_id}', 'machine_start', 'pending', '${PURCHASER_COMPANY_ID}'),
|
||||
* ('{plan_id}', 'fabric_warehouse', 'pending', '${PURCHASER_COMPANY_ID}');
|
||||
*
|
||||
* -- 6. 生成分享链接
|
||||
* INSERT INTO share_links (short_code, plan_id, factory_type, created_by, expires_at, status)
|
||||
* VALUES ('TEST-SIM-001', '{plan_id}', 'textile', '${PURCHASER_USER_ID}',
|
||||
* NOW() + INTERVAL '30 minutes', 'pending');
|
||||
*
|
||||
* -- 7. 纺织厂点击链接
|
||||
* UPDATE share_links SET status = 'clicked', clicked_at = NOW(), click_count = 1
|
||||
* WHERE short_code = 'TEST-SIM-001';
|
||||
*
|
||||
* -- 8. 纺织厂确认导入(创建关联 + 更新链接 + 建立合作 + 更新计划状态)
|
||||
* INSERT INTO plan_factories (plan_id, factory_id, factory_type)
|
||||
* VALUES ('{plan_id}', '${TEXTILE_COMPANY_ID}', 'textile');
|
||||
*
|
||||
* UPDATE share_links SET status = 'confirmed', responded_at = NOW(),
|
||||
* response_result = 'confirmed', used_at = NOW()
|
||||
* WHERE short_code = 'TEST-SIM-001';
|
||||
*
|
||||
* INSERT INTO company_relationships (purchaser_company_id, factory_company_id, factory_type, status)
|
||||
* VALUES ('${PURCHASER_COMPANY_ID}', '${TEXTILE_COMPANY_ID}', 'textile', 'active');
|
||||
*
|
||||
* UPDATE production_plans SET status = 'producing' WHERE id = '{plan_id}';
|
||||
*
|
||||
* -- 9. 确认所有流程节点
|
||||
* UPDATE plan_process_steps SET status = 'completed', timestamp = NOW(),
|
||||
* operator_id = '${TEXTILE_USER_ID}'
|
||||
* WHERE plan_id = '{plan_id}';
|
||||
*
|
||||
* -- 10. 分3批入库
|
||||
* INSERT INTO inventory_records (plan_id, warehouse_id, quantity, rolls, company_id, operator_id, price_per_meter)
|
||||
* VALUES ('{plan_id}', '${FABRIC_WAREHOUSE_ID}', 3000, 30, '${TEXTILE_COMPANY_ID}', '${TEXTILE_USER_ID}', 18.5),
|
||||
* ('{plan_id}', '${FABRIC_WAREHOUSE_ID}', 4000, 40, '${TEXTILE_COMPANY_ID}', '${TEXTILE_USER_ID}', 18.5),
|
||||
* ('{plan_id}', '${FABRIC_WAREHOUSE_ID}', 3000, 28, '${TEXTILE_COMPANY_ID}', '${TEXTILE_USER_ID}', 18.5);
|
||||
*
|
||||
* -- 11. 更新计划完成状态
|
||||
* UPDATE production_plans SET completed_quantity = 10000, status = 'completed'
|
||||
* WHERE id = '{plan_id}';
|
||||
*
|
||||
* -- 12. 验证查询
|
||||
* SELECT p.plan_code, p.status, p.target_quantity, p.completed_quantity,
|
||||
* (SELECT COUNT(*) FROM plan_process_steps WHERE plan_id = p.id AND status = 'completed') as completed_steps,
|
||||
* (SELECT COUNT(*) FROM inventory_records WHERE plan_id = p.id) as inventory_batches,
|
||||
* (SELECT COALESCE(SUM(quantity),0) FROM inventory_records WHERE plan_id = p.id) as total_meters
|
||||
* FROM production_plans p WHERE p.id = '{plan_id}';
|
||||
*/
|
||||
|
||||
test('验证测试数据结构完整性', async () => {
|
||||
// 此测试作为文档占位符,实际 SQL 验证通过 meoo-cli cloud query 执行
|
||||
// 预期结果:
|
||||
// - plan_code: TEST-2026-001
|
||||
// - status: completed
|
||||
// - target_quantity: 10000
|
||||
// - completed_quantity: 10000
|
||||
// - completed_steps: 5
|
||||
// - inventory_batches: 3
|
||||
// - total_meters: 10000
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('边界情况和异常处理', () => {
|
||||
|
||||
test('未登录用户访问受保护页面', async ({ page }) => {
|
||||
// 直接访问受保护页面
|
||||
await page.goto('/purchaser/plans');
|
||||
|
||||
// 应该被重定向到登录页
|
||||
await page.waitForURL(/.*login/, { timeout: 5000 });
|
||||
await expect(page).toHaveURL(/.*login/);
|
||||
});
|
||||
|
||||
test('无效 token 导入计划', async ({ page }) => {
|
||||
await page.goto('/import?token=invalid-token');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 应该显示错误提示或重定向
|
||||
const hasError = await page.locator('text=错误, text=无效, text=失效, text=过期').count() > 0;
|
||||
const isRedirected = await page.url().includes('login') || await page.url().includes('error');
|
||||
|
||||
expect(hasError || isRedirected).toBeTruthy();
|
||||
});
|
||||
|
||||
test('网络断开时的离线提示', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await waitForPageLoad(page);
|
||||
|
||||
// 模拟离线
|
||||
await page.context().setOffline(true);
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// 检查是否有离线提示
|
||||
const hasOfflineNotice = await page.locator('text=离线, text=网络, text=断网').count() > 0;
|
||||
console.log(`离线提示检测: ${hasOfflineNotice ? '有' : '无'}`);
|
||||
|
||||
// 恢复网络
|
||||
await page.context().setOffline(false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('性能测试', () => {
|
||||
|
||||
test('页面加载时间', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const loadTime = Date.now() - startTime;
|
||||
|
||||
console.log(`登录页加载时间: ${loadTime}ms`);
|
||||
|
||||
// 验证页面在合理时间内加载(5秒内)
|
||||
expect(loadTime).toBeLessThan(5000);
|
||||
|
||||
// 验证关键元素渲染
|
||||
await expect(page.locator('h1, [class*="title"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('资源加载无严重错误', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') {
|
||||
errors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
page.on('pageerror', (error) => {
|
||||
errors.push(error.message);
|
||||
});
|
||||
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 过滤预期的错误(如 API 调用、favicon 等)
|
||||
const unexpectedErrors = errors.filter(e =>
|
||||
!e.includes('supabase') &&
|
||||
!e.includes('api') &&
|
||||
!e.includes('favicon') &&
|
||||
!e.includes('404') &&
|
||||
!e.includes('Failed to fetch')
|
||||
);
|
||||
|
||||
console.log(`检测到 ${unexpectedErrors.length} 个非预期错误`);
|
||||
if (unexpectedErrors.length > 0) {
|
||||
console.log('错误详情:', unexpectedErrors.slice(0, 3));
|
||||
}
|
||||
|
||||
// 允许少量非关键错误
|
||||
expect(unexpectedErrors.length).toBeLessThan(5);
|
||||
});
|
||||
});
|
||||
154
functions/data-integrity-check/index.ts
Normal file
154
functions/data-integrity-check/index.ts
Normal file
@ -0,0 +1,154 @@
|
||||
/**
|
||||
* 数据完整性定期检查 Edge Function
|
||||
*
|
||||
* 功能:
|
||||
* 1. 定期运行数据一致性检查
|
||||
* 2. 检测错误率是否超过阈值(万分之一)
|
||||
* 3. 发送告警通知
|
||||
* 4. 生成检查报告
|
||||
*
|
||||
* 触发方式:
|
||||
* - 定时任务(建议每小时执行一次)
|
||||
* - 手动调用
|
||||
*/
|
||||
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
interface CheckResult {
|
||||
check_type: string;
|
||||
total_records: number;
|
||||
inconsistent_records: number;
|
||||
error_rate: number;
|
||||
status: 'PASS' | 'FAIL';
|
||||
}
|
||||
|
||||
interface IntegrityReport {
|
||||
timestamp: string;
|
||||
checks: CheckResult[];
|
||||
overall_error_rate: number;
|
||||
overall_status: 'PASS' | 'FAIL';
|
||||
target_threshold: number; // 万分比阈值
|
||||
alerts: string[];
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// 处理 CORS 预检请求
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建 Supabase 客户端
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
console.log('开始数据完整性检查...');
|
||||
|
||||
// 运行数据完整性检查
|
||||
const { data: checkResults, error: checkError } = await supabase.rpc(
|
||||
'run_data_integrity_check'
|
||||
);
|
||||
|
||||
if (checkError) {
|
||||
throw new Error(`数据检查失败: ${checkError.message}`);
|
||||
}
|
||||
|
||||
// 构建报告
|
||||
const report: IntegrityReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: checkResults || [],
|
||||
overall_error_rate: 0,
|
||||
overall_status: 'PASS',
|
||||
target_threshold: 1, // 万分之一
|
||||
alerts: [],
|
||||
};
|
||||
|
||||
// 计算总体错误率
|
||||
let totalRecords = 0;
|
||||
let totalInconsistent = 0;
|
||||
|
||||
for (const check of report.checks) {
|
||||
totalRecords += check.total_records;
|
||||
totalInconsistent += check.inconsistent_records;
|
||||
|
||||
// 检查单项是否超标
|
||||
if (check.error_rate > report.target_threshold) {
|
||||
report.alerts.push(
|
||||
`⚠️ ${check.check_type} 错误率 ${check.error_rate.toFixed(2)}/万 超过阈值 ${report.target_threshold}/万`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
report.overall_error_rate = totalRecords > 0
|
||||
? (totalInconsistent / totalRecords) * 10000
|
||||
: 0;
|
||||
|
||||
report.overall_status = report.overall_error_rate <= report.target_threshold
|
||||
? 'PASS'
|
||||
: 'FAIL';
|
||||
|
||||
// 如果总体错误率超标,添加告警
|
||||
if (report.overall_status === 'FAIL') {
|
||||
report.alerts.unshift(
|
||||
`🚨 系统错误率 ${report.overall_error_rate.toFixed(2)}/万 超过目标阈值 ${report.target_threshold}/万`
|
||||
);
|
||||
}
|
||||
|
||||
// 记录检查结果到审计日志
|
||||
await supabase.from('data_audit_logs').insert({
|
||||
table_name: 'system_integrity_check',
|
||||
record_id: '00000000-0000-0000-0000-000000000000', // 系统检查使用固定ID
|
||||
operation: 'INSERT',
|
||||
new_values: report,
|
||||
validation_passed: report.overall_status === 'PASS',
|
||||
error_message: report.overall_status === 'FAIL'
|
||||
? report.alerts.join('; ')
|
||||
: null,
|
||||
});
|
||||
|
||||
// 如果有告警,可以发送通知(这里预留接口)
|
||||
if (report.alerts.length > 0) {
|
||||
console.warn('数据完整性告警:', report.alerts);
|
||||
|
||||
// TODO: 集成邮件/短信/钉钉等告警通知
|
||||
// await sendAlertNotification(report);
|
||||
}
|
||||
|
||||
// 返回报告
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
report,
|
||||
message: report.overall_status === 'PASS'
|
||||
? '✓ 数据完整性检查通过'
|
||||
: '✗ 数据完整性检查发现异常',
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: report.overall_status === 'PASS' ? 200 : 500,
|
||||
}
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('数据完整性检查异常:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
}),
|
||||
{
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
102
migrations/20260604_143720_create_data_validation_functions.sql
Normal file
102
migrations/20260604_143720_create_data_validation_functions.sql
Normal file
@ -0,0 +1,102 @@
|
||||
-- ============================================
|
||||
-- 此文件由 Meoo Cloud 自动生成,请勿修改
|
||||
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
|
||||
-- ============================================
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION validate_plan_quantity_consistency()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_inventory_total NUMERIC;
|
||||
v_tolerance NUMERIC := 0.01;
|
||||
BEGIN
|
||||
IF OLD.completed_quantity IS DISTINCT FROM NEW.completed_quantity THEN
|
||||
SELECT COALESCE(SUM(quantity), 0) INTO v_inventory_total
|
||||
FROM inventory_records
|
||||
WHERE plan_id = NEW.id;
|
||||
|
||||
IF ABS(NEW.completed_quantity - v_inventory_total) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'数据完整性错误:计划 % 的完成数量(%)与入库记录总和(%)不一致,差异: %',
|
||||
NEW.plan_code, NEW.completed_quantity, v_inventory_total,
|
||||
NEW.completed_quantity - v_inventory_total;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION validate_inventory_record_positive()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.quantity < 0 THEN
|
||||
RAISE EXCEPTION '入库数量不能为负数: %', NEW.quantity;
|
||||
END IF;
|
||||
|
||||
IF NEW.rolls < 0 THEN
|
||||
RAISE EXCEPTION '入库匹数不能为负数: %', NEW.rolls;
|
||||
END IF;
|
||||
|
||||
IF NEW.price_per_meter < 0 THEN
|
||||
RAISE EXCEPTION '单价不能为负数: %', NEW.price_per_meter;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION validate_payment_amount()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_expected_amount NUMERIC;
|
||||
v_tolerance NUMERIC := 0.01;
|
||||
BEGIN
|
||||
v_expected_amount := NEW.quantity * NEW.price_per_meter;
|
||||
|
||||
IF ABS(NEW.amount - v_expected_amount) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'付款金额不一致:期望 %(数量 % × 单价 %),实际 %',
|
||||
v_expected_amount, NEW.quantity, NEW.price_per_meter, NEW.amount;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION validate_accounts_payable_totals()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_items_total NUMERIC;
|
||||
v_items_paid NUMERIC;
|
||||
v_tolerance NUMERIC := 0.01;
|
||||
BEGIN
|
||||
SELECT
|
||||
COALESCE(SUM(amount), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END), 0)
|
||||
INTO v_items_total, v_items_paid
|
||||
FROM accounts_payable_items
|
||||
WHERE accounts_payable_id = NEW.id;
|
||||
|
||||
IF ABS(NEW.total_amount - v_items_total) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'应付账款总金额不一致:表头 %, 明细合计 %',
|
||||
NEW.total_amount, v_items_total;
|
||||
END IF;
|
||||
|
||||
IF ABS(NEW.paid_amount - v_items_paid) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'应付账款已付金额不一致:表头 %, 明细合计 %',
|
||||
NEW.paid_amount, v_items_paid;
|
||||
END IF;
|
||||
|
||||
IF ABS(NEW.unpaid_amount - (v_items_total - v_items_paid)) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'应付账款未付金额不一致:表头 %, 计算值 %',
|
||||
NEW.unpaid_amount, (v_items_total - v_items_paid);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
31
migrations/20260604_143731_create_validation_triggers.sql
Normal file
31
migrations/20260604_143731_create_validation_triggers.sql
Normal file
@ -0,0 +1,31 @@
|
||||
-- ============================================
|
||||
-- 此文件由 Meoo Cloud 自动生成,请勿修改
|
||||
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
|
||||
-- ============================================
|
||||
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_validate_plan_quantity ON production_plans;
|
||||
CREATE TRIGGER trg_validate_plan_quantity
|
||||
BEFORE UPDATE ON production_plans
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.completed_quantity IS DISTINCT FROM NEW.completed_quantity)
|
||||
EXECUTE FUNCTION validate_plan_quantity_consistency();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_validate_inventory_positive ON inventory_records;
|
||||
CREATE TRIGGER trg_validate_inventory_positive
|
||||
BEFORE INSERT OR UPDATE ON inventory_records
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_inventory_record_positive();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_validate_payment ON payments;
|
||||
CREATE TRIGGER trg_validate_payment
|
||||
BEFORE INSERT OR UPDATE ON payments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_payment_amount();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_validate_accounts_payable ON accounts_payable;
|
||||
CREATE TRIGGER trg_validate_accounts_payable
|
||||
BEFORE INSERT OR UPDATE ON accounts_payable
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_accounts_payable_totals();
|
||||
|
||||
31
migrations/20260604_143738_create_audit_logs_table.sql
Normal file
31
migrations/20260604_143738_create_audit_logs_table.sql
Normal file
@ -0,0 +1,31 @@
|
||||
-- ============================================
|
||||
-- 此文件由 Meoo Cloud 自动生成,请勿修改
|
||||
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
|
||||
-- ============================================
|
||||
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
table_name TEXT NOT NULL,
|
||||
record_id UUID NOT NULL,
|
||||
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
changed_by UUID REFERENCES auth.users(id),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
validation_passed BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record ON data_audit_logs(table_name, record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_changed_at ON data_audit_logs(changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_validation ON data_audit_logs(validation_passed) WHERE validation_passed = FALSE;
|
||||
|
||||
ALTER TABLE data_audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY admin_select_audit_logs ON data_audit_logs
|
||||
FOR SELECT USING (true);
|
||||
|
||||
CREATE POLICY system_insert_audit_logs ON data_audit_logs
|
||||
FOR INSERT WITH CHECK (true);
|
||||
|
||||
75
migrations/20260604_143746_create_audit_trigger_function.sql
Normal file
75
migrations/20260604_143746_create_audit_trigger_function.sql
Normal file
@ -0,0 +1,75 @@
|
||||
-- ============================================
|
||||
-- 此文件由 Meoo Cloud 自动生成,请勿修改
|
||||
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
|
||||
-- ============================================
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION log_data_audit()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_old_values JSONB;
|
||||
v_new_values JSONB;
|
||||
v_operation TEXT;
|
||||
BEGIN
|
||||
v_operation := TG_OP;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_old_values := to_jsonb(OLD);
|
||||
v_new_values := NULL;
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
v_old_values := to_jsonb(OLD);
|
||||
v_new_values := to_jsonb(NEW);
|
||||
ELSE
|
||||
v_old_values := NULL;
|
||||
v_new_values := to_jsonb(NEW);
|
||||
END IF;
|
||||
|
||||
INSERT INTO data_audit_logs (
|
||||
table_name, record_id, operation, old_values, new_values, changed_by
|
||||
) VALUES (
|
||||
TG_TABLE_NAME,
|
||||
COALESCE(NEW.id, OLD.id),
|
||||
v_operation,
|
||||
v_old_values,
|
||||
v_new_values,
|
||||
auth.uid()
|
||||
);
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_production_plans ON production_plans;
|
||||
CREATE TRIGGER trg_audit_production_plans
|
||||
AFTER INSERT OR UPDATE OR DELETE ON production_plans
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_inventory_records ON inventory_records;
|
||||
CREATE TRIGGER trg_audit_inventory_records
|
||||
AFTER INSERT OR UPDATE OR DELETE ON inventory_records
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_payments ON payments;
|
||||
CREATE TRIGGER trg_audit_payments
|
||||
AFTER INSERT OR UPDATE OR DELETE ON payments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_accounts_payable ON accounts_payable;
|
||||
CREATE TRIGGER trg_audit_accounts_payable
|
||||
AFTER INSERT OR UPDATE OR DELETE ON accounts_payable
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_audit_accounts_payable_items ON accounts_payable_items;
|
||||
CREATE TRIGGER trg_audit_accounts_payable_items
|
||||
AFTER INSERT OR UPDATE OR DELETE ON accounts_payable_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
114
migrations/20260604_143758_create_integrity_check_views.sql
Normal file
114
migrations/20260604_143758_create_integrity_check_views.sql
Normal file
@ -0,0 +1,114 @@
|
||||
-- ============================================
|
||||
-- 此文件由 Meoo Cloud 自动生成,请勿修改
|
||||
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
|
||||
-- ============================================
|
||||
|
||||
|
||||
CREATE OR REPLACE VIEW v_plan_quantity_check AS
|
||||
SELECT
|
||||
p.id as plan_id,
|
||||
p.plan_code,
|
||||
p.status,
|
||||
p.completed_quantity,
|
||||
COALESCE(SUM(ir.quantity), 0) as inventory_total,
|
||||
p.completed_quantity - COALESCE(SUM(ir.quantity), 0) as difference,
|
||||
CASE
|
||||
WHEN ABS(p.completed_quantity - COALESCE(SUM(ir.quantity), 0)) <= 0.01 THEN '✓ 一致'
|
||||
ELSE '✗ 不一致'
|
||||
END as check_result
|
||||
FROM production_plans p
|
||||
LEFT JOIN inventory_records ir ON ir.plan_id = p.id
|
||||
GROUP BY p.id, p.plan_code, p.status, p.completed_quantity;
|
||||
|
||||
CREATE OR REPLACE VIEW v_payment_check AS
|
||||
SELECT
|
||||
id as payment_id,
|
||||
plan_id,
|
||||
quantity,
|
||||
price_per_meter,
|
||||
amount,
|
||||
quantity * price_per_meter as expected_amount,
|
||||
amount - (quantity * price_per_meter) as difference,
|
||||
CASE
|
||||
WHEN ABS(amount - (quantity * price_per_meter)) <= 0.01 THEN '✓ 一致'
|
||||
ELSE '✗ 不一致'
|
||||
END as check_result
|
||||
FROM payments;
|
||||
|
||||
CREATE OR REPLACE VIEW v_accounts_payable_check AS
|
||||
SELECT
|
||||
ap.id as ap_id,
|
||||
ap.plan_id,
|
||||
ap.total_amount,
|
||||
ap.paid_amount,
|
||||
ap.unpaid_amount,
|
||||
COALESCE(SUM(api.amount), 0) as items_total,
|
||||
COALESCE(SUM(CASE WHEN api.status = 'paid' THEN api.amount ELSE 0 END), 0) as items_paid,
|
||||
ap.total_amount - COALESCE(SUM(api.amount), 0) as total_diff,
|
||||
ap.paid_amount - COALESCE(SUM(CASE WHEN api.status = 'paid' THEN api.amount ELSE 0 END), 0) as paid_diff,
|
||||
CASE
|
||||
WHEN ABS(ap.total_amount - COALESCE(SUM(api.amount), 0)) <= 0.01
|
||||
AND ABS(ap.paid_amount - COALESCE(SUM(CASE WHEN api.status = 'paid' THEN api.amount ELSE 0 END), 0)) <= 0.01
|
||||
THEN '✓ 一致'
|
||||
ELSE '✗ 不一致'
|
||||
END as check_result
|
||||
FROM accounts_payable ap
|
||||
LEFT JOIN accounts_payable_items api ON api.accounts_payable_id = ap.id
|
||||
GROUP BY ap.id, ap.plan_id, ap.total_amount, ap.paid_amount, ap.unpaid_amount;
|
||||
|
||||
CREATE OR REPLACE FUNCTION run_data_integrity_check()
|
||||
RETURNS TABLE (
|
||||
check_type TEXT,
|
||||
total_records BIGINT,
|
||||
inconsistent_records BIGINT,
|
||||
error_rate NUMERIC,
|
||||
status TEXT
|
||||
) LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'plan_quantity'::TEXT,
|
||||
COUNT(*)::BIGINT,
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::BIGINT,
|
||||
ROUND(
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 100, 4
|
||||
),
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END) = 0 THEN 'PASS'
|
||||
ELSE 'FAIL'
|
||||
END
|
||||
FROM v_plan_quantity_check;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'payment_amount'::TEXT,
|
||||
COUNT(*)::BIGINT,
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::BIGINT,
|
||||
ROUND(
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 100, 4
|
||||
),
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END) = 0 THEN 'PASS'
|
||||
ELSE 'FAIL'
|
||||
END
|
||||
FROM v_payment_check;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'accounts_payable'::TEXT,
|
||||
COUNT(*)::BIGINT,
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::BIGINT,
|
||||
ROUND(
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 100, 4
|
||||
),
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END) = 0 THEN 'PASS'
|
||||
ELSE 'FAIL'
|
||||
END
|
||||
FROM v_accounts_payable_check;
|
||||
END;
|
||||
$$;
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
-- ============================================
|
||||
-- 此文件由 Meoo Cloud 自动生成,请勿修改
|
||||
-- This file is auto-generated by Meoo Cloud, DO NOT MODIFY
|
||||
-- ============================================
|
||||
|
||||
|
||||
ALTER TABLE inventory_records
|
||||
DROP CONSTRAINT IF EXISTS chk_inventory_positive;
|
||||
ALTER TABLE inventory_records
|
||||
ADD CONSTRAINT chk_inventory_positive
|
||||
CHECK (quantity >= 0 AND rolls >= 0 AND (price_per_meter IS NULL OR price_per_meter >= 0));
|
||||
|
||||
ALTER TABLE payments
|
||||
DROP CONSTRAINT IF EXISTS chk_payment_positive;
|
||||
ALTER TABLE payments
|
||||
ADD CONSTRAINT chk_payment_positive
|
||||
CHECK (amount >= 0 AND quantity >= 0 AND price_per_meter >= 0);
|
||||
|
||||
ALTER TABLE accounts_payable
|
||||
DROP CONSTRAINT IF EXISTS chk_ap_amounts;
|
||||
ALTER TABLE accounts_payable
|
||||
ADD CONSTRAINT chk_ap_amounts
|
||||
CHECK (
|
||||
total_amount >= 0 AND
|
||||
paid_amount >= 0 AND
|
||||
unpaid_amount >= 0 AND
|
||||
paid_amount <= total_amount AND
|
||||
unpaid_amount <= total_amount
|
||||
);
|
||||
|
||||
CREATE OR REPLACE VIEW v_error_rate_monitor AS
|
||||
SELECT
|
||||
DATE_TRUNC('day', changed_at) as check_date,
|
||||
table_name,
|
||||
COUNT(*) as total_operations,
|
||||
SUM(CASE WHEN validation_passed = FALSE THEN 1 ELSE 0 END) as failed_operations,
|
||||
ROUND(
|
||||
SUM(CASE WHEN validation_passed = FALSE THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 10000, 2
|
||||
) as error_rate_per_10k,
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN validation_passed = FALSE THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 10000 <= 1 THEN '✓ 达标 (≤1/万)'
|
||||
ELSE '✗ 超标 (>1/万)'
|
||||
END as target_status
|
||||
FROM data_audit_logs
|
||||
WHERE changed_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY DATE_TRUNC('day', changed_at), table_name
|
||||
ORDER BY check_date DESC, table_name;
|
||||
|
||||
444
migrations/20260604_200000_data_integrity_constraints.sql
Normal file
444
migrations/20260604_200000_data_integrity_constraints.sql
Normal file
@ -0,0 +1,444 @@
|
||||
-- ============================================================================
|
||||
-- 数据完整性强约束 - 确保财务与核心数据100%正确
|
||||
-- ============================================================================
|
||||
-- 目标:系统错误率 ≤ 0.01% (万分之一)
|
||||
-- 范围:production_plans, inventory_records, payments, accounts_payable
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. 创建数据校验函数
|
||||
-- ============================================================================
|
||||
|
||||
-- 1.1 验证计划数量一致性(completed_quantity = SUM(inventory_records.quantity))
|
||||
CREATE OR REPLACE FUNCTION validate_plan_quantity_consistency()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_inventory_total NUMERIC;
|
||||
v_tolerance NUMERIC := 0.01; -- 允许0.01米的浮点误差
|
||||
BEGIN
|
||||
-- 仅当 completed_quantity 被更新时触发
|
||||
IF OLD.completed_quantity IS DISTINCT FROM NEW.completed_quantity THEN
|
||||
SELECT COALESCE(SUM(quantity), 0) INTO v_inventory_total
|
||||
FROM inventory_records
|
||||
WHERE plan_id = NEW.id;
|
||||
|
||||
IF ABS(NEW.completed_quantity - v_inventory_total) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'数据完整性错误:计划 % 的完成数量(%)与入库记录总和(%)不一致,差异: %',
|
||||
NEW.plan_code, NEW.completed_quantity, v_inventory_total,
|
||||
NEW.completed_quantity - v_inventory_total;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 1.2 验证入库记录不能为负数
|
||||
CREATE OR REPLACE FUNCTION validate_inventory_record_positive()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF NEW.quantity < 0 THEN
|
||||
RAISE EXCEPTION '入库数量不能为负数: %', NEW.quantity;
|
||||
END IF;
|
||||
|
||||
IF NEW.rolls < 0 THEN
|
||||
RAISE EXCEPTION '入库匹数不能为负数: %', NEW.rolls;
|
||||
END IF;
|
||||
|
||||
IF NEW.price_per_meter < 0 THEN
|
||||
RAISE EXCEPTION '单价不能为负数: %', NEW.price_per_meter;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 1.3 验证付款金额一致性
|
||||
CREATE OR REPLACE FUNCTION validate_payment_amount()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_expected_amount NUMERIC;
|
||||
v_tolerance NUMERIC := 0.01;
|
||||
BEGIN
|
||||
-- 计算预期金额 = 数量 × 单价
|
||||
v_expected_amount := NEW.quantity * NEW.price_per_meter;
|
||||
|
||||
IF ABS(NEW.amount - v_expected_amount) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'付款金额不一致:期望 %(数量 % × 单价 %),实际 %',
|
||||
v_expected_amount, NEW.quantity, NEW.price_per_meter, NEW.amount;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- 1.4 验证应付账款汇总一致性
|
||||
CREATE OR REPLACE FUNCTION validate_accounts_payable_totals()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_items_total NUMERIC;
|
||||
v_items_paid NUMERIC;
|
||||
v_tolerance NUMERIC := 0.01;
|
||||
BEGIN
|
||||
-- 从明细表重新计算
|
||||
SELECT
|
||||
COALESCE(SUM(amount), 0),
|
||||
COALESCE(SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END), 0)
|
||||
INTO v_items_total, v_items_paid
|
||||
FROM accounts_payable_items
|
||||
WHERE accounts_payable_id = NEW.id;
|
||||
|
||||
-- 验证总金额
|
||||
IF ABS(NEW.total_amount - v_items_total) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'应付账款总金额不一致:表头 %, 明细合计 %',
|
||||
NEW.total_amount, v_items_total;
|
||||
END IF;
|
||||
|
||||
-- 验证已付金额
|
||||
IF ABS(NEW.paid_amount - v_items_paid) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'应付账款已付金额不一致:表头 %, 明细合计 %',
|
||||
NEW.paid_amount, v_items_paid;
|
||||
END IF;
|
||||
|
||||
-- 验证未付金额
|
||||
IF ABS(NEW.unpaid_amount - (v_items_total - v_items_paid)) > v_tolerance THEN
|
||||
RAISE EXCEPTION
|
||||
'应付账款未付金额不一致:表头 %, 计算值 %',
|
||||
NEW.unpaid_amount, (v_items_total - v_items_paid);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. 创建触发器
|
||||
-- ============================================================================
|
||||
|
||||
-- 2.1 计划数量一致性触发器
|
||||
DROP TRIGGER IF EXISTS trg_validate_plan_quantity ON production_plans;
|
||||
CREATE TRIGGER trg_validate_plan_quantity
|
||||
BEFORE UPDATE ON production_plans
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.completed_quantity IS DISTINCT FROM NEW.completed_quantity)
|
||||
EXECUTE FUNCTION validate_plan_quantity_consistency();
|
||||
|
||||
-- 2.2 入库记录正数验证触发器
|
||||
DROP TRIGGER IF EXISTS trg_validate_inventory_positive ON inventory_records;
|
||||
CREATE TRIGGER trg_validate_inventory_positive
|
||||
BEFORE INSERT OR UPDATE ON inventory_records
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_inventory_record_positive();
|
||||
|
||||
-- 2.3 付款金额验证触发器
|
||||
DROP TRIGGER IF EXISTS trg_validate_payment ON payments;
|
||||
CREATE TRIGGER trg_validate_payment
|
||||
BEFORE INSERT OR UPDATE ON payments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_payment_amount();
|
||||
|
||||
-- 2.4 应付账款汇总验证触发器
|
||||
DROP TRIGGER IF EXISTS trg_validate_accounts_payable ON accounts_payable;
|
||||
CREATE TRIGGER trg_validate_accounts_payable
|
||||
BEFORE INSERT OR UPDATE ON accounts_payable
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_accounts_payable_totals();
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. 创建审计日志表(记录所有关键数据变更)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS data_audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
table_name TEXT NOT NULL,
|
||||
record_id UUID NOT NULL,
|
||||
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
|
||||
old_values JSONB,
|
||||
new_values JSONB,
|
||||
changed_by UUID REFERENCES auth.users(id),
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
validation_passed BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
error_message TEXT
|
||||
);
|
||||
|
||||
-- 创建索引加速查询
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_table_record ON data_audit_logs(table_name, record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_changed_at ON data_audit_logs(changed_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_logs_validation ON data_audit_logs(validation_passed) WHERE validation_passed = FALSE;
|
||||
|
||||
-- 启用 RLS
|
||||
ALTER TABLE data_audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- 审计日志只读策略(仅管理员可查看)
|
||||
CREATE POLICY admin_select_audit_logs ON data_audit_logs
|
||||
FOR SELECT USING (true); -- 暂时开放,后续可改为角色检查
|
||||
|
||||
CREATE POLICY system_insert_audit_logs ON data_audit_logs
|
||||
FOR INSERT WITH CHECK (true); -- 系统自动写入
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. 创建审计触发器函数
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION log_data_audit()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_old_values JSONB;
|
||||
v_new_values JSONB;
|
||||
v_operation TEXT;
|
||||
BEGIN
|
||||
v_operation := TG_OP;
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
v_old_values := to_jsonb(OLD);
|
||||
v_new_values := NULL;
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
v_old_values := to_jsonb(OLD);
|
||||
v_new_values := to_jsonb(NEW);
|
||||
ELSE -- INSERT
|
||||
v_old_values := NULL;
|
||||
v_new_values := to_jsonb(NEW);
|
||||
END IF;
|
||||
|
||||
INSERT INTO data_audit_logs (
|
||||
table_name, record_id, operation, old_values, new_values, changed_by
|
||||
) VALUES (
|
||||
TG_TABLE_NAME,
|
||||
COALESCE(NEW.id, OLD.id),
|
||||
v_operation,
|
||||
v_old_values,
|
||||
v_new_values,
|
||||
auth.uid()
|
||||
);
|
||||
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. 为核心表添加审计触发器
|
||||
-- ============================================================================
|
||||
|
||||
-- 5.1 production_plans 审计
|
||||
DROP TRIGGER IF EXISTS trg_audit_production_plans ON production_plans;
|
||||
CREATE TRIGGER trg_audit_production_plans
|
||||
AFTER INSERT OR UPDATE OR DELETE ON production_plans
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
-- 5.2 inventory_records 审计
|
||||
DROP TRIGGER IF EXISTS trg_audit_inventory_records ON inventory_records;
|
||||
CREATE TRIGGER trg_audit_inventory_records
|
||||
AFTER INSERT OR UPDATE OR DELETE ON inventory_records
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
-- 5.3 payments 审计
|
||||
DROP TRIGGER IF EXISTS trg_audit_payments ON payments;
|
||||
CREATE TRIGGER trg_audit_payments
|
||||
AFTER INSERT OR UPDATE OR DELETE ON payments
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
-- 5.4 accounts_payable 审计
|
||||
DROP TRIGGER IF EXISTS trg_audit_accounts_payable ON accounts_payable;
|
||||
CREATE TRIGGER trg_audit_accounts_payable
|
||||
AFTER INSERT OR UPDATE OR DELETE ON accounts_payable
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
-- 5.5 accounts_payable_items 审计
|
||||
DROP TRIGGER IF EXISTS trg_audit_accounts_payable_items ON accounts_payable_items;
|
||||
CREATE TRIGGER trg_audit_accounts_payable_items
|
||||
AFTER INSERT OR UPDATE OR DELETE ON accounts_payable_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION log_data_audit();
|
||||
|
||||
-- ============================================================================
|
||||
-- 6. 创建数据一致性检查视图
|
||||
-- ============================================================================
|
||||
|
||||
-- 6.1 计划数量一致性检查视图
|
||||
CREATE OR REPLACE VIEW v_plan_quantity_check AS
|
||||
SELECT
|
||||
p.id as plan_id,
|
||||
p.plan_code,
|
||||
p.status,
|
||||
p.completed_quantity,
|
||||
COALESCE(SUM(ir.quantity), 0) as inventory_total,
|
||||
p.completed_quantity - COALESCE(SUM(ir.quantity), 0) as difference,
|
||||
CASE
|
||||
WHEN ABS(p.completed_quantity - COALESCE(SUM(ir.quantity), 0)) <= 0.01 THEN '✓ 一致'
|
||||
ELSE '✗ 不一致'
|
||||
END as check_result
|
||||
FROM production_plans p
|
||||
LEFT JOIN inventory_records ir ON ir.plan_id = p.id
|
||||
GROUP BY p.id, p.plan_code, p.status, p.completed_quantity;
|
||||
|
||||
-- 6.2 付款金额一致性检查视图
|
||||
CREATE OR REPLACE VIEW v_payment_check AS
|
||||
SELECT
|
||||
id as payment_id,
|
||||
plan_id,
|
||||
quantity,
|
||||
price_per_meter,
|
||||
amount,
|
||||
quantity * price_per_meter as expected_amount,
|
||||
amount - (quantity * price_per_meter) as difference,
|
||||
CASE
|
||||
WHEN ABS(amount - (quantity * price_per_meter)) <= 0.01 THEN '✓ 一致'
|
||||
ELSE '✗ 不一致'
|
||||
END as check_result
|
||||
FROM payments;
|
||||
|
||||
-- 6.3 应付账款汇总一致性检查视图
|
||||
CREATE OR REPLACE VIEW v_accounts_payable_check AS
|
||||
SELECT
|
||||
ap.id as ap_id,
|
||||
ap.plan_id,
|
||||
ap.total_amount,
|
||||
ap.paid_amount,
|
||||
ap.unpaid_amount,
|
||||
COALESCE(SUM(api.amount), 0) as items_total,
|
||||
COALESCE(SUM(CASE WHEN api.status = 'paid' THEN api.amount ELSE 0 END), 0) as items_paid,
|
||||
ap.total_amount - COALESCE(SUM(api.amount), 0) as total_diff,
|
||||
ap.paid_amount - COALESCE(SUM(CASE WHEN api.status = 'paid' THEN api.amount ELSE 0 END), 0) as paid_diff,
|
||||
CASE
|
||||
WHEN ABS(ap.total_amount - COALESCE(SUM(api.amount), 0)) <= 0.01
|
||||
AND ABS(ap.paid_amount - COALESCE(SUM(CASE WHEN api.status = 'paid' THEN api.amount ELSE 0 END), 0)) <= 0.01
|
||||
THEN '✓ 一致'
|
||||
ELSE '✗ 不一致'
|
||||
END as check_result
|
||||
FROM accounts_payable ap
|
||||
LEFT JOIN accounts_payable_items api ON api.accounts_payable_id = ap.id
|
||||
GROUP BY ap.id, ap.plan_id, ap.total_amount, ap.paid_amount, ap.unpaid_amount;
|
||||
|
||||
-- ============================================================================
|
||||
-- 7. 创建定期一致性检查函数(可由 Edge Function 调用)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION run_data_integrity_check()
|
||||
RETURNS TABLE (
|
||||
check_type TEXT,
|
||||
total_records BIGINT,
|
||||
inconsistent_records BIGINT,
|
||||
error_rate NUMERIC,
|
||||
status TEXT
|
||||
) LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- 检查计划数量一致性
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'plan_quantity'::TEXT,
|
||||
COUNT(*)::BIGINT,
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::BIGINT,
|
||||
ROUND(
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 100, 4
|
||||
),
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END) = 0 THEN 'PASS'
|
||||
ELSE 'FAIL'
|
||||
END
|
||||
FROM v_plan_quantity_check;
|
||||
|
||||
-- 检查付款金额一致性
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'payment_amount'::TEXT,
|
||||
COUNT(*)::BIGINT,
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::BIGINT,
|
||||
ROUND(
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 100, 4
|
||||
),
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END) = 0 THEN 'PASS'
|
||||
ELSE 'FAIL'
|
||||
END
|
||||
FROM v_payment_check;
|
||||
|
||||
-- 检查应付账款汇总一致性
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
'accounts_payable'::TEXT,
|
||||
COUNT(*)::BIGINT,
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::BIGINT,
|
||||
ROUND(
|
||||
SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 100, 4
|
||||
),
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN check_result = '✗ 不一致' THEN 1 ELSE 0 END) = 0 THEN 'PASS'
|
||||
ELSE 'FAIL'
|
||||
END
|
||||
FROM v_accounts_payable_check;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 8. 添加 CHECK 约束(作为最后一道防线)
|
||||
-- ============================================================================
|
||||
|
||||
-- 8.1 入库记录非负约束
|
||||
ALTER TABLE inventory_records
|
||||
DROP CONSTRAINT IF EXISTS chk_inventory_positive;
|
||||
ALTER TABLE inventory_records
|
||||
ADD CONSTRAINT chk_inventory_positive
|
||||
CHECK (quantity >= 0 AND rolls >= 0 AND (price_per_meter IS NULL OR price_per_meter >= 0));
|
||||
|
||||
-- 8.2 付款金额非负约束
|
||||
ALTER TABLE payments
|
||||
DROP CONSTRAINT IF EXISTS chk_payment_positive;
|
||||
ALTER TABLE payments
|
||||
ADD CONSTRAINT chk_payment_positive
|
||||
CHECK (amount >= 0 AND quantity >= 0 AND price_per_meter >= 0);
|
||||
|
||||
-- 8.3 应付账款金额逻辑约束
|
||||
ALTER TABLE accounts_payable
|
||||
DROP CONSTRAINT IF EXISTS chk_ap_amounts;
|
||||
ALTER TABLE accounts_payable
|
||||
ADD CONSTRAINT chk_ap_amounts
|
||||
CHECK (
|
||||
total_amount >= 0 AND
|
||||
paid_amount >= 0 AND
|
||||
unpaid_amount >= 0 AND
|
||||
paid_amount <= total_amount AND
|
||||
unpaid_amount <= total_amount
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 9. 创建错误率监控视图
|
||||
-- ============================================================================
|
||||
|
||||
CREATE OR REPLACE VIEW v_error_rate_monitor AS
|
||||
SELECT
|
||||
DATE_TRUNC('day', changed_at) as check_date,
|
||||
table_name,
|
||||
COUNT(*) as total_operations,
|
||||
SUM(CASE WHEN validation_passed = FALSE THEN 1 ELSE 0 END) as failed_operations,
|
||||
ROUND(
|
||||
SUM(CASE WHEN validation_passed = FALSE THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 10000, 2
|
||||
) as error_rate_per_10k,
|
||||
CASE
|
||||
WHEN SUM(CASE WHEN validation_passed = FALSE THEN 1 ELSE 0 END)::NUMERIC /
|
||||
NULLIF(COUNT(*), 0) * 10000 <= 1 THEN '✓ 达标 (≤1/万)'
|
||||
ELSE '✗ 超标 (>1/万)'
|
||||
END as target_status
|
||||
FROM data_audit_logs
|
||||
WHERE changed_at >= NOW() - INTERVAL '30 days'
|
||||
GROUP BY DATE_TRUNC('day', changed_at), table_name
|
||||
ORDER BY check_date DESC, table_name;
|
||||
|
||||
-- ============================================================================
|
||||
-- 结束
|
||||
-- ============================================================================
|
||||
@ -31,6 +31,7 @@ import { DemoDataSharing } from './pages/DemoDataSharing';
|
||||
import { DemoDisclaimerModal } from './components/DemoDisclaimerModal';
|
||||
import { NetworkStatusBar } from './components/NetworkStatusBar';
|
||||
import { DemoWatermark } from './components/DemoWatermark';
|
||||
import { DataIntegrityDashboard } from './components/DataIntegrityDashboard';
|
||||
import { PurchaserLayout } from './components/layout/PurchaserLayout';
|
||||
import { TextileLayout } from './components/layout/TextileLayout';
|
||||
import './styles/index.css';
|
||||
@ -129,6 +130,9 @@ function AppRoutes() {
|
||||
{/* 数据共享演示路由 */}
|
||||
<Route path="/demo/data-sharing" element={<AnimatedRoute pageName="数据共享演示"><DemoDataSharing /></AnimatedRoute>} />
|
||||
|
||||
{/* 数据完整性监控面板(管理员功能) */}
|
||||
<Route path="/admin/data-integrity" element={<AnimatedRoute pageName="数据完整性监控"><DataIntegrityDashboard /></AnimatedRoute>} />
|
||||
|
||||
{/* 默认路由 */}
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
|
||||
252
src/components/AccountProfileCard.tsx
Normal file
252
src/components/AccountProfileCard.tsx
Normal file
@ -0,0 +1,252 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Camera,
|
||||
Users,
|
||||
BookOpen,
|
||||
MessageSquare,
|
||||
Info,
|
||||
LogOut,
|
||||
Building2,
|
||||
Factory,
|
||||
Droplets
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { supabase } from '../supabase/client';
|
||||
|
||||
export type AccountRole = 'purchaser' | 'textile' | 'washing';
|
||||
|
||||
interface AccountProfileCardProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
role: AccountRole;
|
||||
onShowUserManual: () => void;
|
||||
onShowFeedback: () => void;
|
||||
onShowAbout: () => void;
|
||||
}
|
||||
|
||||
const roleThemeConfig = {
|
||||
purchaser: {
|
||||
headerGradient: 'from-amber-500 to-orange-500',
|
||||
avatarGradient: 'from-amber-500 to-orange-500',
|
||||
badgeBg: 'bg-amber-100',
|
||||
badgeText: 'text-amber-700',
|
||||
menuIconBg: 'bg-amber-100',
|
||||
menuIconText: 'text-amber-600',
|
||||
defaultIcon: Building2,
|
||||
defaultCompanyName: '采购商'
|
||||
},
|
||||
textile: {
|
||||
headerGradient: 'from-emerald-500 to-green-600',
|
||||
avatarGradient: 'from-emerald-500 to-green-600',
|
||||
badgeBg: 'bg-emerald-100',
|
||||
badgeText: 'text-emerald-700',
|
||||
menuIconBg: 'bg-emerald-100',
|
||||
menuIconText: 'text-emerald-600',
|
||||
defaultIcon: Factory,
|
||||
defaultCompanyName: '纺织厂'
|
||||
},
|
||||
washing: {
|
||||
headerGradient: 'from-violet-400 to-fuchsia-400',
|
||||
avatarGradient: 'from-violet-400 to-fuchsia-400',
|
||||
badgeBg: 'bg-violet-100',
|
||||
badgeText: 'text-violet-600',
|
||||
menuIconBg: 'bg-violet-100',
|
||||
menuIconText: 'text-violet-600',
|
||||
defaultIcon: Droplets,
|
||||
defaultCompanyName: '水洗厂'
|
||||
}
|
||||
};
|
||||
|
||||
export function AccountProfileCard({
|
||||
isOpen,
|
||||
onClose,
|
||||
role,
|
||||
onShowUserManual,
|
||||
onShowFeedback,
|
||||
onShowAbout
|
||||
}: AccountProfileCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const theme = roleThemeConfig[role];
|
||||
const DefaultIcon = theme.defaultIcon;
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from('avatars')
|
||||
.upload(fileName, arrayBuffer, { contentType: file.type, upsert: true });
|
||||
|
||||
if (uploadError) {
|
||||
alert('头像上传失败: ' + uploadError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage.from('avatars').getPublicUrl(fileName);
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({ image_url: publicUrl } as any)
|
||||
.eq('id', user.id);
|
||||
|
||||
if (updateError) {
|
||||
alert('头像更新失败: ' + updateError.message);
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
alert('头像上传出错,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'members',
|
||||
icon: Users,
|
||||
label: '账号管理',
|
||||
description: '管理团队成员和账户设置',
|
||||
onClick: () => { onClose(); navigate('/members'); }
|
||||
},
|
||||
{
|
||||
id: 'manual',
|
||||
icon: BookOpen,
|
||||
label: '用户手册',
|
||||
description: '查看详细使用指南',
|
||||
onClick: () => { onClose(); onShowUserManual(); }
|
||||
},
|
||||
{
|
||||
id: 'feedback',
|
||||
icon: MessageSquare,
|
||||
label: '用户反馈',
|
||||
description: '提交问题或建议',
|
||||
onClick: () => { onClose(); onShowFeedback(); }
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
icon: Info,
|
||||
label: '关于',
|
||||
description: '版本信息与版权',
|
||||
onClick: () => { onClose(); onShowAbout(); }
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="bg-white rounded-2xl w-full max-w-sm shadow-2xl overflow-hidden"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<div className={`bg-gradient-to-r ${theme.headerGradient} p-6 text-white`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">账户信息</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-white/20 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 账户详情 */}
|
||||
<div className="p-6 space-y-4">
|
||||
{/* 用户信息 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label
|
||||
className={`w-16 h-16 bg-gradient-to-br ${theme.avatarGradient} rounded-xl flex items-center justify-center shadow-lg cursor-pointer hover:opacity-90 transition-opacity overflow-hidden relative group`}
|
||||
>
|
||||
{(auth.user as any)?.image_url ? (
|
||||
<img
|
||||
src={(auth.user as any).image_url}
|
||||
alt="头像"
|
||||
className="w-full h-full object-cover relative z-10"
|
||||
/>
|
||||
) : (
|
||||
<DefaultIcon className="w-8 h-8 text-white relative z-10" />
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/30 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
<Camera className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-900 text-lg truncate max-w-[200px]">
|
||||
{(auth.user as any)?.display_name || auth.user?.username || '用户'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{auth.company?.name || theme.defaultCompanyName}
|
||||
</p>
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${theme.badgeBg} ${theme.badgeText} px-2 py-0.5 rounded-full mt-1`}>
|
||||
{auth.user?.is_master ? '主账号' : '子账号'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 功能菜单 */}
|
||||
<div className="space-y-2 pt-4 border-t border-slate-200">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl hover:bg-slate-50 transition-colors text-left"
|
||||
>
|
||||
<div className={`w-10 h-10 ${theme.menuIconBg} rounded-lg flex items-center justify-center`}>
|
||||
<item.icon className={`w-5 h-5 ${theme.menuIconText}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900">{item.label}</p>
|
||||
<p className="text-xs text-slate-500">{item.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 退出登录 */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full py-3 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl transition-colors flex items-center justify-center gap-2 font-medium"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
311
src/components/DataIntegrityDashboard.tsx
Normal file
311
src/components/DataIntegrityDashboard.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Shield,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
RefreshCw,
|
||||
Activity,
|
||||
Database,
|
||||
FileText,
|
||||
TrendingUp,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
runFullDataIntegrityCheck,
|
||||
getErrorRateStats,
|
||||
getFailedValidations,
|
||||
type DataIntegrityReport
|
||||
} from '../utils/dataValidation';
|
||||
|
||||
/**
|
||||
* 数据完整性监控面板
|
||||
* 实时显示系统数据质量状态
|
||||
*/
|
||||
export function DataIntegrityDashboard() {
|
||||
const [report, setReport] = useState<DataIntegrityReport | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastCheck, setLastCheck] = useState<string>('');
|
||||
const [errorRateHistory, setErrorRateHistory] = useState<any[]>([]);
|
||||
const [failedRecords, setFailedRecords] = useState<any[]>([]);
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 运行完整性检查
|
||||
const checkReport = await runFullDataIntegrityCheck();
|
||||
setReport(checkReport);
|
||||
setLastCheck(new Date().toLocaleString('zh-CN'));
|
||||
|
||||
// 加载错误率历史
|
||||
const history = await getErrorRateStats(7);
|
||||
setErrorRateHistory(history || []);
|
||||
|
||||
// 加载失败记录
|
||||
const failed = await getFailedValidations(10);
|
||||
setFailedRecords(failed || []);
|
||||
|
||||
} catch (err: any) {
|
||||
setError(err.message || '加载数据失败');
|
||||
console.error('加载数据完整性报告失败:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
// 每5分钟自动刷新
|
||||
const interval = setInterval(loadData, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 状态图标
|
||||
const StatusIcon = ({ status }: { status: 'PASS' | 'FAIL' }) => {
|
||||
if (status === 'PASS') {
|
||||
return <CheckCircle className="w-6 h-6 text-emerald-500" />;
|
||||
}
|
||||
return <XCircle className="w-6 h-6 text-red-500" />;
|
||||
};
|
||||
|
||||
// 错误率颜色
|
||||
const getErrorRateColor = (rate: number) => {
|
||||
if (rate <= 1) return 'text-emerald-600 bg-emerald-50';
|
||||
if (rate <= 5) return 'text-amber-600 bg-amber-50';
|
||||
return 'text-red-600 bg-red-50';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-blue-600" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">数据完整性监控</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
目标:错误率 ≤ 0.01% (万分之一) | 财务数据 100% 准确
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? '检查中...' : '立即检查'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3"
|
||||
>
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-900">加载失败</p>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 总体状态卡片 */}
|
||||
{report && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`p-6 rounded-xl border-2 ${
|
||||
report.overallStatus === 'PASS'
|
||||
? 'bg-emerald-50 border-emerald-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<StatusIcon status={report.overallStatus} />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">
|
||||
{report.overallStatus === 'PASS' ? '✓ 系统数据完整性达标' : '✗ 发现数据异常'}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
最后检查时间: {lastCheck}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-3xl font-bold text-gray-900">
|
||||
{(report.errorRate ?? 0).toFixed(2)}
|
||||
<span className="text-lg text-gray-600">/万</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">当前错误率</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 告警信息 */}
|
||||
{report.checks.planQuantity.status === 'FAIL' ||
|
||||
report.checks.paymentAmount.status === 'FAIL' ||
|
||||
report.checks.accountsPayable.status === 'FAIL' ? (
|
||||
<div className="mt-4 p-3 bg-white/50 rounded-lg">
|
||||
<p className="text-sm font-medium text-gray-900 mb-2">异常项目:</p>
|
||||
<ul className="space-y-1">
|
||||
{report.checks.planQuantity.status === 'FAIL' && (
|
||||
<li className="text-sm text-red-700 flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" />
|
||||
计划数量一致性: {report.checks.planQuantity.inconsistentRecords} 条不一致
|
||||
</li>
|
||||
)}
|
||||
{report.checks.paymentAmount.status === 'FAIL' && (
|
||||
<li className="text-sm text-red-700 flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" />
|
||||
付款金额一致性: {report.checks.paymentAmount.inconsistentRecords} 条不一致
|
||||
</li>
|
||||
)}
|
||||
{report.checks.accountsPayable.status === 'FAIL' && (
|
||||
<li className="text-sm text-red-700 flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" />
|
||||
应付账款汇总: {report.checks.accountsPayable.inconsistentRecords} 条不一致
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 详细检查项 */}
|
||||
{report && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{[
|
||||
{ key: 'planQuantity', label: '计划数量一致性', icon: Database },
|
||||
{ key: 'paymentAmount', label: '付款金额校验', icon: FileText },
|
||||
{ key: 'accountsPayable', label: '应付账款汇总', icon: TrendingUp },
|
||||
].map((item) => {
|
||||
const check = report.checks[item.key as keyof typeof report.checks];
|
||||
return (
|
||||
<motion.div
|
||||
key={item.key}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 bg-white rounded-lg border border-gray-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<item.icon className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="font-medium text-gray-900">{item.label}</h3>
|
||||
</div>
|
||||
<StatusIcon status={check.status} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">总记录数</span>
|
||||
<span className="font-medium">{check.totalRecords}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">不一致记录</span>
|
||||
<span className={`font-medium ${check.inconsistentRecords > 0 ? 'text-red-600' : 'text-emerald-600'}`}>
|
||||
{check.inconsistentRecords}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">错误率</span>
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${getErrorRateColor(check.errorRate)}`}>
|
||||
{(check.errorRate ?? 0).toFixed(2)}/万
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误率趋势 */}
|
||||
{errorRateHistory.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-6 bg-white rounded-lg border border-gray-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Activity className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="font-medium text-gray-900">近7天错误率趋势</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{errorRateHistory.slice(0, 7).map((record, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-2 hover:bg-gray-50 rounded">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{new Date(record.check_date).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-500">{record.table_name}</span>
|
||||
<span className={`text-sm font-medium ${
|
||||
(record.error_rate_per_10k ?? 0) <= 1 ? 'text-emerald-600' : 'text-red-600'
|
||||
}`}>
|
||||
{(record.error_rate_per_10k ?? 0).toFixed(2)}/万
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({record.failed_operations ?? 0}/{record.total_operations ?? 0})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 最近失败记录 */}
|
||||
{failedRecords.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="p-6 bg-white rounded-lg border border-gray-200 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
<h3 className="font-medium text-gray-900">最近验证失败记录</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-2 font-medium text-gray-700">时间</th>
|
||||
<th className="text-left p-2 font-medium text-gray-700">表名</th>
|
||||
<th className="text-left p-2 font-medium text-gray-700">操作</th>
|
||||
<th className="text-left p-2 font-medium text-gray-700">错误信息</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{failedRecords.map((record) => (
|
||||
<tr key={record.id} className="border-t border-gray-100 hover:bg-gray-50">
|
||||
<td className="p-2 text-gray-600">
|
||||
{new Date(record.changed_at).toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td className="p-2 text-gray-900 font-mono text-xs">{record.table_name}</td>
|
||||
<td className="p-2">
|
||||
<span className="px-2 py-0.5 bg-gray-100 rounded text-xs">{record.operation}</span>
|
||||
</td>
|
||||
<td className="p-2 text-red-600 text-xs max-w-md truncate">
|
||||
{record.error_message || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
236
src/components/ImageUploader.tsx
Normal file
236
src/components/ImageUploader.tsx
Normal file
@ -0,0 +1,236 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Camera, X, Upload, ImageIcon } from 'lucide-react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
|
||||
export type UploadBucket = 'avatars' | 'product_images';
|
||||
|
||||
interface ImageUploaderProps {
|
||||
/** 当前图片 URL */
|
||||
value?: string | null;
|
||||
/** 图片变更回调 */
|
||||
onChange: (url: string | null) => void;
|
||||
/** 存储桶名称 */
|
||||
bucket?: UploadBucket;
|
||||
/** 上传路径前缀(仅 product_images 使用) */
|
||||
pathPrefix?: string;
|
||||
/** 是否圆形裁剪(头像模式) */
|
||||
circular?: boolean;
|
||||
/** 容器尺寸(px) */
|
||||
size?: number;
|
||||
/** 占位图标 */
|
||||
placeholderIcon?: React.ReactNode;
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 最大文件大小(MB) */
|
||||
maxFileSize?: number;
|
||||
}
|
||||
|
||||
export function ImageUploader({
|
||||
value,
|
||||
onChange,
|
||||
bucket = 'product_images',
|
||||
pathPrefix = 'products',
|
||||
circular = false,
|
||||
size = 80,
|
||||
placeholderIcon,
|
||||
disabled = false,
|
||||
className = '',
|
||||
maxFileSize = 5
|
||||
}: ImageUploaderProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(value || null);
|
||||
|
||||
// 同步外部 value 变化
|
||||
React.useEffect(() => {
|
||||
setPreviewUrl(value || null);
|
||||
}, [value]);
|
||||
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
alert('请选择图片文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证文件大小
|
||||
if (file.size > maxFileSize * 1024 * 1024) {
|
||||
alert(`图片大小不能超过 ${maxFileSize}MB`);
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// 读取文件为 base64
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
const base64 = (event.target?.result as string).split(',')[1];
|
||||
|
||||
// 生成唯一文件名
|
||||
const fileExt = file.name.split('.').pop();
|
||||
const fileName = bucket === 'avatars'
|
||||
? `${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`
|
||||
: `${pathPrefix}/${Date.now()}_${Math.random().toString(36).substring(2, 9)}.${fileExt}`;
|
||||
|
||||
// 上传图片到 Supabase Storage
|
||||
const { error } = await supabase.storage
|
||||
.from(bucket)
|
||||
.upload(fileName, decode(base64), {
|
||||
contentType: file.type,
|
||||
upsert: true
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('上传失败:', error);
|
||||
alert('图片上传失败: ' + error.message);
|
||||
setUploading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取图片的公开 URL
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from(bucket)
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
setPreviewUrl(publicUrl);
|
||||
onChange(publicUrl);
|
||||
setUploading(false);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} catch (error) {
|
||||
console.error('上传错误:', error);
|
||||
alert('图片上传失败');
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setPreviewUrl(null);
|
||||
onChange(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const triggerFileSelect = () => {
|
||||
if (!disabled && !uploading) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const borderRadius = circular ? 'rounded-full' : 'rounded-xl';
|
||||
const containerStyle = { width: size, height: size };
|
||||
|
||||
return (
|
||||
<div className={`relative inline-block ${className}`}>
|
||||
<label
|
||||
className={`${borderRadius} flex items-center justify-center overflow-hidden relative group cursor-pointer transition-all ${disabled || uploading ? 'opacity-50 cursor-not-allowed' : 'hover:shadow-lg'}`}
|
||||
style={containerStyle}
|
||||
>
|
||||
{/* 背景色 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-100 to-gray-200" />
|
||||
|
||||
{/* 图片预览 */}
|
||||
{previewUrl ? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="预览"
|
||||
className={`w-full h-full object-cover relative z-10 ${circular ? 'rounded-full' : ''}`}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative z-10 flex flex-col items-center justify-center text-gray-400">
|
||||
{placeholderIcon || <ImageIcon className="w-8 h-8" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传遮罩层 */}
|
||||
{!disabled && !uploading && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
{previewUrl ? (
|
||||
<Camera className="w-6 h-6 text-white" />
|
||||
) : (
|
||||
<Upload className="w-6 h-6 text-white" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传中状态 */}
|
||||
{uploading && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-30">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
className="w-6 h-6 border-2 border-white border-t-transparent rounded-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={disabled || uploading}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* 删除按钮 */}
|
||||
{previewUrl && !disabled && !uploading && (
|
||||
<button
|
||||
onClick={handleRemove}
|
||||
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-md transition-colors z-30"
|
||||
title="删除图片"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 头像上传专用组件(简化版)
|
||||
*/
|
||||
interface AvatarUploaderProps {
|
||||
value?: string | null;
|
||||
onChange: (url: string | null) => void;
|
||||
size?: number;
|
||||
placeholderIcon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AvatarUploader({
|
||||
value,
|
||||
onChange,
|
||||
size = 64,
|
||||
placeholderIcon,
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: AvatarUploaderProps) {
|
||||
return (
|
||||
<ImageUploader
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
bucket="avatars"
|
||||
circular
|
||||
size={size}
|
||||
placeholderIcon={placeholderIcon}
|
||||
disabled={disabled}
|
||||
className={className}
|
||||
maxFileSize={5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
138
src/components/PaymentCard.tsx
Normal file
138
src/components/PaymentCard.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { CheckCircle, DollarSign } from 'lucide-react';
|
||||
|
||||
export type PaymentTheme = 'emerald' | 'rose' | 'amber' | 'violet';
|
||||
|
||||
interface PaymentCardProps {
|
||||
id: string;
|
||||
planCode: string;
|
||||
quantity: number;
|
||||
pricePerMeter: number;
|
||||
amount: number;
|
||||
theme?: PaymentTheme;
|
||||
confirming?: boolean;
|
||||
onConfirm: (id: string) => void;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const themeConfig = {
|
||||
emerald: {
|
||||
amountColor: 'text-emerald-600',
|
||||
buttonBg: 'bg-green-500 hover:bg-green-600'
|
||||
},
|
||||
rose: {
|
||||
amountColor: 'text-rose-600',
|
||||
buttonBg: 'bg-green-500 hover:bg-green-600'
|
||||
},
|
||||
amber: {
|
||||
amountColor: 'text-amber-600',
|
||||
buttonBg: 'bg-amber-500 hover:bg-amber-600'
|
||||
},
|
||||
violet: {
|
||||
amountColor: 'text-violet-600',
|
||||
buttonBg: 'bg-violet-500 hover:bg-violet-600'
|
||||
}
|
||||
};
|
||||
|
||||
export function PaymentCard({
|
||||
id,
|
||||
planCode,
|
||||
quantity,
|
||||
pricePerMeter,
|
||||
amount,
|
||||
theme = 'emerald',
|
||||
confirming = false,
|
||||
onConfirm,
|
||||
index = 0
|
||||
}: PaymentCardProps) {
|
||||
const config = themeConfig[theme];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 15 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
delay: Math.min(index * 0.03, 0.2),
|
||||
duration: 0.25,
|
||||
ease: [0.25, 0.46, 0.45, 0.94]
|
||||
}}
|
||||
className="bg-white rounded-xl shadow-sm p-4 sm:p-5 border border-gray-200"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-[10px] sm:text-xs text-gray-400">{planCode || '-'}</span>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-gray-500">
|
||||
已完成: {quantity}米 × ¥{pricePerMeter}/米
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className={`text-lg sm:text-xl md:text-2xl font-bold ${config.amountColor}`}>
|
||||
¥{amount.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 sm:mt-4 pt-3 sm:pt-4 border-t border-gray-100 flex justify-end">
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.97 }}
|
||||
onClick={() => onConfirm(id)}
|
||||
disabled={confirming}
|
||||
className={`flex items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-1.5 sm:py-2 ${config.buttonBg} text-white rounded-lg transition-colors disabled:opacity-50 text-xs sm:text-sm will-change-transform`}
|
||||
>
|
||||
<CheckCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||
{confirming ? '确认中...' : '确认结款'}
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 待结款总额摘要卡片
|
||||
*/
|
||||
interface PaymentSummaryCardProps {
|
||||
totalAmount: number;
|
||||
theme?: PaymentTheme;
|
||||
}
|
||||
|
||||
const summaryThemeConfig = {
|
||||
emerald: {
|
||||
gradient: 'from-emerald-500 to-green-600',
|
||||
textColor: 'text-emerald-100'
|
||||
},
|
||||
rose: {
|
||||
gradient: 'from-rose-500 to-pink-500',
|
||||
textColor: 'text-rose-100'
|
||||
},
|
||||
amber: {
|
||||
gradient: 'from-amber-500 to-orange-500',
|
||||
textColor: 'text-amber-100'
|
||||
},
|
||||
violet: {
|
||||
gradient: 'from-violet-400 to-fuchsia-400',
|
||||
textColor: 'text-violet-100'
|
||||
}
|
||||
};
|
||||
|
||||
export function PaymentSummaryCard({ totalAmount, theme = 'emerald' }: PaymentSummaryCardProps) {
|
||||
const config = summaryThemeConfig[theme];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`bg-gradient-to-r ${config.gradient} rounded-xl p-4 sm:p-5 md:p-6 text-white mb-4 sm:mb-5 md:mb-6`}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<DollarSign className="w-6 h-6 sm:w-7 sm:h-7 md:w-8 md:h-8" />
|
||||
<div>
|
||||
<p className={`${config.textColor} text-xs sm:text-sm`}>待结款总额</p>
|
||||
<p className="text-2xl sm:text-3xl md:text-4xl font-bold">¥{totalAmount.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@ -11,11 +11,11 @@ interface VersionInfo {
|
||||
}
|
||||
|
||||
// 当前版本
|
||||
const CURRENT_VERSION = '2.0.0';
|
||||
const CURRENT_VERSION = '2.1.0';
|
||||
|
||||
// 模拟版本更新数据(实际应从服务器获取)
|
||||
const LATEST_VERSION: VersionInfo = {
|
||||
version: '2.0.0',
|
||||
version: '2.1.0',
|
||||
date: '2026-06-04',
|
||||
changes: [
|
||||
'重大版本升级:全流程模拟测试通过',
|
||||
|
||||
128
src/components/layout/DashboardLayout.tsx
Normal file
128
src/components/layout/DashboardLayout.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { LogOut, HelpCircle, Factory, Droplets, Building2 } from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import { NotificationCenter } from '../NotificationCenter';
|
||||
import { HelpCenter, HelpButton } from '../HelpCenter';
|
||||
|
||||
export type DashboardRole = 'purchaser' | 'textile' | 'washing';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
role: DashboardRole;
|
||||
children: React.ReactNode;
|
||||
showHelp?: boolean;
|
||||
onHelpClick?: () => void;
|
||||
}
|
||||
|
||||
// 角色主题配置
|
||||
const roleThemeConfig = {
|
||||
purchaser: {
|
||||
gradient: 'from-amber-400 to-orange-500',
|
||||
bgGradient: 'from-slate-50 via-amber-50/30 to-orange-50/30',
|
||||
icon: Building2,
|
||||
label: '采购商',
|
||||
notificationTheme: 'amber' as const
|
||||
},
|
||||
textile: {
|
||||
gradient: 'from-emerald-400 to-teal-400',
|
||||
bgGradient: 'from-slate-50 via-emerald-50/30 to-green-50/30',
|
||||
icon: Factory,
|
||||
label: '纺织厂',
|
||||
notificationTheme: 'emerald' as const
|
||||
},
|
||||
washing: {
|
||||
gradient: 'from-violet-400 to-fuchsia-400',
|
||||
bgGradient: 'from-slate-50 via-violet-50/40 to-fuchsia-50/30',
|
||||
icon: Droplets,
|
||||
label: '水洗厂',
|
||||
notificationTheme: 'violet' as const
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一的 Dashboard 布局组件
|
||||
* 提供一致的 Header、通知中心、帮助按钮等通用功能
|
||||
*/
|
||||
export function DashboardLayout({
|
||||
role,
|
||||
children,
|
||||
showHelp = true,
|
||||
onHelpClick
|
||||
}: DashboardLayoutProps) {
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
const theme = roleThemeConfig[role];
|
||||
const RoleIcon = theme.icon;
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-gradient-to-br ${theme.bgGradient}`}>
|
||||
{/* Header */}
|
||||
<header className="bg-white/80 backdrop-blur-xl border-b border-slate-200/50 sticky top-0 z-10">
|
||||
<div className="max-w-7xl mx-auto px-3 sm:px-4 md:px-8 py-3 sm:py-4 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<button
|
||||
className={`w-8 h-8 sm:w-10 sm:h-10 bg-gradient-to-br ${theme.gradient} rounded-xl flex items-center justify-center shadow-lg cursor-pointer overflow-hidden active:scale-95 transition-transform`}
|
||||
>
|
||||
{(auth.user as any)?.image_url ? (
|
||||
<img
|
||||
src={(auth.user as any).image_url}
|
||||
alt="头像"
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<RoleIcon className="w-4 h-4 sm:w-5 sm:h-5 text-white" />
|
||||
)}
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-sm sm:text-lg font-bold text-slate-900 flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="sm:hidden truncate max-w-[80px]">
|
||||
{(auth.user as any)?.display_name || auth.user?.username || '用户'}
|
||||
</span>
|
||||
<span className="hidden sm:inline">
|
||||
{auth.company?.name || theme.label}工作台
|
||||
</span>
|
||||
<span className={`text-[10px] sm:text-xs bg-gradient-to-r ${theme.gradient} text-white px-1.5 sm:px-2 py-0.5 rounded-full font-medium whitespace-nowrap`}>
|
||||
限时免费
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500 hidden sm:block truncate max-w-[150px]">
|
||||
{(auth.user as any)?.display_name || auth.user?.username || '用户'},欢迎回来
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showHelp && (
|
||||
<HelpButton onClick={onHelpClick || (() => {})} />
|
||||
)}
|
||||
<NotificationCenter theme={theme.notificationTheme} />
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 sm:px-3 sm:py-1.5 text-slate-600 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="w-5 h-5 sm:hidden" />
|
||||
<span className="hidden sm:inline text-xs sm:text-sm">退出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="p-3 sm:p-4 md:p-8 max-w-7xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
src/components/layout/WashingLayout.tsx
Normal file
235
src/components/layout/WashingLayout.tsx
Normal file
@ -0,0 +1,235 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
FileText,
|
||||
Warehouse,
|
||||
CreditCard,
|
||||
LogOut,
|
||||
Home,
|
||||
ChevronRight,
|
||||
Droplets,
|
||||
Menu,
|
||||
X,
|
||||
Package,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
|
||||
interface NavItem {
|
||||
id: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'home', icon: Home, label: '首页', path: '/washing' },
|
||||
{ id: 'plans', icon: FileText, label: '计划总览', path: '/washing/plans' },
|
||||
{ id: 'pending', icon: Package, label: '待处理坯布', path: '/washing/pending' },
|
||||
{ id: 'completed', icon: CheckCircle, label: '已完成坯布', path: '/washing/completed' },
|
||||
{ id: 'finished', icon: Warehouse, label: '成品仓库', path: '/washing/finished-warehouse' },
|
||||
{ id: 'payment', icon: CreditCard, label: '待结款', path: '/washing/payments' }
|
||||
];
|
||||
|
||||
export function WashingLayout() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { auth, logout } = useAuth();
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
// 从 localStorage 读取侧边栏状态,默认展开
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem('washing_sidebar_collapsed');
|
||||
return saved ? JSON.parse(saved) : false;
|
||||
});
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
// 保存侧边栏状态到 localStorage
|
||||
const toggleSidebar = () => {
|
||||
const newState = !sidebarCollapsed;
|
||||
setSidebarCollapsed(newState);
|
||||
localStorage.setItem('washing_sidebar_collapsed', JSON.stringify(newState));
|
||||
};
|
||||
|
||||
const isActive = (path: string) => {
|
||||
const currentPath = location.pathname;
|
||||
|
||||
// 首页精确匹配
|
||||
if (path === '/washing') {
|
||||
return currentPath === '/washing';
|
||||
}
|
||||
|
||||
// 精确匹配当前路径
|
||||
if (currentPath === path) return true;
|
||||
|
||||
// 对于 /washing/plans 路径,不应该匹配子路径
|
||||
if (path === '/washing/plans') {
|
||||
return currentPath === '/washing/plans';
|
||||
}
|
||||
|
||||
// 对于其他路径,匹配子路径
|
||||
if (currentPath.startsWith(path + '/')) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
{/* 桌面端侧边栏 - 固定不动,无入场动画 */}
|
||||
{!isMobile && (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900 text-white z-50 flex flex-col ${
|
||||
sidebarCollapsed ? 'w-20' : 'w-64'
|
||||
}`}
|
||||
>
|
||||
{/* Logo区域 */}
|
||||
<div className="h-16 flex items-center px-4 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-400 to-fuchsia-400 flex items-center justify-center shadow-lg">
|
||||
<Droplets className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<div>
|
||||
<h1 className="font-bold text-lg leading-tight">水洗厂工作台</h1>
|
||||
<p className="text-xs text-slate-400">{auth.company?.name || '未登录'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 导航菜单 */}
|
||||
<nav className="flex-1 py-4 px-3 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.path);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => navigate(item.path)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-3 rounded-xl transition-colors duration-200 group ${
|
||||
active
|
||||
? 'bg-gradient-to-r from-violet-500/20 to-fuchsia-500/20 text-violet-400 border border-violet-500/30'
|
||||
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||
active ? 'bg-violet-500/20' : 'bg-slate-700/50 group-hover:bg-slate-600'
|
||||
}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium text-sm">{item.label}</div>
|
||||
</div>
|
||||
)}
|
||||
{!sidebarCollapsed && active && (
|
||||
<ChevronRight className="w-4 h-4 text-violet-400" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 底部操作区 */}
|
||||
<div className="p-3 border-t border-slate-700/50 space-y-2">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-slate-400 hover:bg-slate-700/50 hover:text-white transition-colors"
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-lg bg-slate-700/50 flex items-center justify-center ${
|
||||
sidebarCollapsed ? 'rotate-180' : ''
|
||||
}`}>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</div>
|
||||
{!sidebarCollapsed && <span className="text-sm">收起菜单</span>}
|
||||
</button>
|
||||
|
||||
<motion.button
|
||||
onClick={handleLogout}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full flex items-center gap-3 px-3 py-3 rounded-xl text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center">
|
||||
<LogOut className="w-5 h-5" />
|
||||
</div>
|
||||
{!sidebarCollapsed && <span className="font-medium text-sm">退出登录</span>}
|
||||
</motion.button>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 移动端顶部导航栏 */}
|
||||
{isMobile && (
|
||||
<header className="bg-white/80 backdrop-blur-xl border-b border-slate-200/50 sticky top-0 z-40">
|
||||
<div className="px-4 py-3 flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-violet-400 to-fuchsia-400 flex items-center justify-center">
|
||||
<Droplets className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<span className="font-bold text-slate-900">水洗厂</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
{mobileMenuOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 移动端菜单 */}
|
||||
{mobileMenuOpen && (
|
||||
<nav className="border-t border-slate-200 bg-white">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.path);
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
navigate(item.path);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 ${
|
||||
active
|
||||
? 'bg-violet-50 text-violet-600'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium text-sm">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span className="font-medium text-sm">退出登录</span>
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
|
||||
{/* 主内容区 - 移动端无侧边栏,桌面端有侧边栏 */}
|
||||
<main className={`${
|
||||
!isMobile && (sidebarCollapsed ? 'ml-20' : 'ml-64')
|
||||
}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/components/ui/DeleteConfirmModal.tsx
Normal file
76
src/components/ui/DeleteConfirmModal.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import React from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title = '确认删除',
|
||||
message,
|
||||
confirmLabel = '删除',
|
||||
cancelLabel = '取消',
|
||||
isLoading = false,
|
||||
}: DeleteConfirmModalProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="bg-white rounded-xl shadow-xl max-w-sm w-full p-6"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">{message}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-gray-100">
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-lg hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? '处理中...' : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
38
src/components/ui/EmptyState.tsx
Normal file
38
src/components/ui/EmptyState.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon, Inbox } from 'lucide-react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: LucideIcon;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
icon: Icon = Inbox,
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
className = '',
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={`flex flex-col items-center justify-center py-12 px-4 text-center ${className}`}>
|
||||
<div className="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mb-4">
|
||||
<Icon className="w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-1">{title}</h3>
|
||||
{description && <p className="text-sm text-gray-500 max-w-xs mb-4">{description}</p>}
|
||||
{actionLabel && onAction && (
|
||||
<button
|
||||
onClick={onAction}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/ui/LoadingSpinner.tsx
Normal file
35
src/components/ui/LoadingSpinner.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
text?: string;
|
||||
overlay?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4 border-2',
|
||||
md: 'w-8 h-8 border-2',
|
||||
lg: 'w-12 h-12 border-3',
|
||||
};
|
||||
|
||||
export function LoadingSpinner({ size = 'md', text, overlay, className = '' }: LoadingSpinnerProps) {
|
||||
const spinner = (
|
||||
<div className={`flex flex-col items-center justify-center gap-3 ${className}`}>
|
||||
<div
|
||||
className={`${sizeClasses[size]} border-current border-t-transparent rounded-full animate-spin`}
|
||||
/>
|
||||
{text && <span className="text-sm text-gray-500">{text}</span>}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (overlay) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80 backdrop-blur-sm">
|
||||
{spinner}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return spinner;
|
||||
}
|
||||
33
src/components/ui/PageHeader.tsx
Normal file
33
src/components/ui/PageHeader.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
backTo?: string;
|
||||
onBack?: () => void;
|
||||
actions?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, backTo, onBack, actions, className = '' }: PageHeaderProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between mb-6 ${className}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{(backTo || onBack) && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">{title}</h1>
|
||||
{subtitle && <p className="text-sm text-gray-500 mt-0.5">{subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/ui/StatCard.tsx
Normal file
37
src/components/ui/StatCard.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
interface StatCardProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string | number;
|
||||
trend?: string;
|
||||
themeColor?: 'amber' | 'emerald' | 'violet' | 'blue';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const themeConfig = {
|
||||
amber: { bg: 'bg-amber-50', icon: 'text-amber-600', border: 'border-amber-200' },
|
||||
emerald: { bg: 'bg-emerald-50', icon: 'text-emerald-600', border: 'border-emerald-200' },
|
||||
violet: { bg: 'bg-violet-50', icon: 'text-violet-600', border: 'border-violet-200' },
|
||||
blue: { bg: 'bg-blue-50', icon: 'text-blue-600', border: 'border-blue-200' },
|
||||
};
|
||||
|
||||
export function StatCard({ icon: Icon, label, value, trend, themeColor = 'blue', className = '' }: StatCardProps) {
|
||||
const theme = themeConfig[themeColor];
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border ${theme.border} ${theme.bg} p-4 ${className}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg bg-white/60`}>
|
||||
<Icon className={`w-5 h-5 ${theme.icon}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-gray-500 truncate">{label}</p>
|
||||
<p className="text-xl font-bold text-gray-900 truncate">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
{trend && <p className="mt-2 text-xs text-gray-500">{trend}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
254
src/config/app.ts
Normal file
254
src/config/app.ts
Normal file
@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 应用全局配置中心
|
||||
* 所有可配置项集中管理,消除散落的 hardcode
|
||||
*/
|
||||
|
||||
// ==================== 环境配置 ====================
|
||||
|
||||
// webpack DefinePlugin 注入的环境变量声明
|
||||
declare const __APP_ENV__: string | undefined;
|
||||
|
||||
/** 当前运行环境 */
|
||||
export const APP_ENV = (typeof __APP_ENV__ !== 'undefined' ? __APP_ENV__ : 'development') as 'development' | 'production';
|
||||
|
||||
/** 是否为开发环境 */
|
||||
export const IS_DEV = APP_ENV === 'development';
|
||||
|
||||
/** 是否为生产环境 */
|
||||
export const IS_PROD = APP_ENV === 'production';
|
||||
|
||||
// ==================== 应用信息 ====================
|
||||
|
||||
export const APP_CONFIG = {
|
||||
/** 应用名称 */
|
||||
name: '昱森',
|
||||
/** Slogan */
|
||||
slogan: '以AI为梭 织天地经纬',
|
||||
/** 定位描述 */
|
||||
description: '纺织行业全流程协作管理平台',
|
||||
/** 版本号 */
|
||||
version: '2.1.0',
|
||||
/** 默认密码(仅 Demo 环境) */
|
||||
defaultDemoPassword: '123456',
|
||||
/** 免密登录有效期(毫秒),默认30天 */
|
||||
autoLoginExpiryMs: 30 * 24 * 60 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
// ==================== 演示账号配置 ====================
|
||||
|
||||
export interface DemoAccount {
|
||||
username: string;
|
||||
label: string;
|
||||
color: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 演示账号列表
|
||||
* 仅在非生产环境或显式启用时显示
|
||||
*/
|
||||
export const DEMO_ACCOUNTS: DemoAccount[] = [
|
||||
{ username: 'purchaser', label: '采购商(布行)', color: 'from-blue-500 to-blue-600', icon: 'Building2', enabled: true },
|
||||
{ username: 'textile', label: '纺织厂', color: 'from-emerald-500 to-green-600', icon: 'Factory', enabled: true },
|
||||
{ username: 'washing', label: '水洗厂', color: 'from-violet-400 to-fuchsia-400', icon: 'Droplets', enabled: true },
|
||||
];
|
||||
|
||||
/** 是否显示演示账号入口 */
|
||||
export const SHOW_DEMO_ACCOUNTS = true;
|
||||
|
||||
// ==================== 缓存配置 ====================
|
||||
|
||||
export const CACHE_CONFIG = {
|
||||
/** 计划数据缓存时长(毫秒) */
|
||||
planDataDuration: 10_000,
|
||||
/** 库存数据缓存时长(毫秒) */
|
||||
inventoryDataDuration: 5_000,
|
||||
/** Dashboard 数据缓存时长(毫秒) */
|
||||
dashboardDataDuration: 10_000,
|
||||
} as const;
|
||||
|
||||
// ==================== 分页配置 ====================
|
||||
|
||||
export const PAGINATION_CONFIG = {
|
||||
/** 默认每页条数 */
|
||||
defaultPageSize: 20,
|
||||
/** 入库记录最大加载数 */
|
||||
maxInventoryRecords: 100,
|
||||
/** 价格历史最大加载数 */
|
||||
maxPriceHistory: 50,
|
||||
} as const;
|
||||
|
||||
// ==================== 同步与轮询配置 ====================
|
||||
|
||||
export const SYNC_CONFIG = {
|
||||
/** 计划状态同步间隔(毫秒)- 测试用10秒,生产环境应改为 5 * 60 * 1000 */
|
||||
planStatusSyncInterval: IS_DEV ? 10_000 : 5 * 60_000,
|
||||
/** Realtime 重连延迟(毫秒) */
|
||||
realtimeReconnectDelay: 3_000,
|
||||
} as const;
|
||||
|
||||
// ==================== 动画配置 ====================
|
||||
|
||||
export const ANIMATION_CONFIG = {
|
||||
/** 标准过渡 */
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
ease: [0.4, 0, 0.2, 1] as number[],
|
||||
},
|
||||
/** 快速过渡 */
|
||||
fastTransition: {
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1] as number[],
|
||||
},
|
||||
/** 慢速过渡 */
|
||||
slowTransition: {
|
||||
duration: 0.4,
|
||||
ease: [0.4, 0, 0.2, 1] as number[],
|
||||
},
|
||||
/** 弹簧动画 */
|
||||
spring: {
|
||||
type: 'spring' as const,
|
||||
stiffness: 200,
|
||||
damping: 25,
|
||||
mass: 0.8,
|
||||
},
|
||||
/** 页面过渡 */
|
||||
pageTransition: {
|
||||
duration: 0.35,
|
||||
ease: [0.25, 0.46, 0.45, 0.94] as number[],
|
||||
},
|
||||
/** 列表交错延迟 */
|
||||
staggerDelay: 0.08,
|
||||
} as const;
|
||||
|
||||
// ==================== 进度阈值 ====================
|
||||
|
||||
export const PROGRESS_THRESHOLDS = {
|
||||
/** 计划完成阈值(百分比) */
|
||||
complete: 97,
|
||||
/** 低进度警告阈值 */
|
||||
lowWarning: 30,
|
||||
/** 中进度提示阈值 */
|
||||
mediumNotice: 60,
|
||||
} as const;
|
||||
|
||||
// ==================== 角色主题色配置 ====================
|
||||
|
||||
export type RoleTheme = 'amber' | 'emerald' | 'violet' | 'blue';
|
||||
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
gradient: string;
|
||||
button: string;
|
||||
buttonHover: string;
|
||||
text: string;
|
||||
bg: string;
|
||||
bgHover: string;
|
||||
border: string;
|
||||
ring: string;
|
||||
notificationBg: string;
|
||||
notificationIcon: string;
|
||||
}
|
||||
|
||||
export const ROLE_THEMES: Record<string, ThemeColors> = {
|
||||
purchaser: {
|
||||
primary: 'amber-400',
|
||||
gradient: 'from-amber-400 to-orange-500',
|
||||
button: 'bg-amber-400',
|
||||
buttonHover: 'hover:bg-amber-500',
|
||||
text: 'text-amber-600',
|
||||
bg: 'bg-amber-50',
|
||||
bgHover: 'hover:bg-amber-100',
|
||||
border: 'border-amber-200',
|
||||
ring: 'focus:ring-amber-400',
|
||||
notificationBg: 'bg-amber-100',
|
||||
notificationIcon: 'text-amber-700',
|
||||
},
|
||||
textile: {
|
||||
primary: 'emerald-400',
|
||||
gradient: 'from-emerald-400 to-teal-400',
|
||||
button: 'bg-emerald-400',
|
||||
buttonHover: 'hover:bg-emerald-500',
|
||||
text: 'text-emerald-600',
|
||||
bg: 'bg-emerald-50',
|
||||
bgHover: 'hover:bg-emerald-100',
|
||||
border: 'border-emerald-200',
|
||||
ring: 'focus:ring-emerald-400',
|
||||
notificationBg: 'bg-emerald-100',
|
||||
notificationIcon: 'text-emerald-700',
|
||||
},
|
||||
washing: {
|
||||
primary: 'violet-400',
|
||||
gradient: 'from-violet-400 to-fuchsia-400',
|
||||
button: 'bg-violet-400',
|
||||
buttonHover: 'hover:bg-violet-500',
|
||||
text: 'text-violet-600',
|
||||
bg: 'bg-violet-50',
|
||||
bgHover: 'hover:bg-violet-100',
|
||||
border: 'border-violet-200',
|
||||
ring: 'focus:ring-violet-400',
|
||||
notificationBg: 'bg-violet-100',
|
||||
notificationIcon: 'text-violet-500',
|
||||
},
|
||||
system: {
|
||||
primary: 'blue-500',
|
||||
gradient: 'from-blue-500 to-blue-600',
|
||||
button: 'bg-blue-500',
|
||||
buttonHover: 'hover:bg-blue-600',
|
||||
text: 'text-blue-600',
|
||||
bg: 'bg-blue-50',
|
||||
bgHover: 'hover:bg-blue-100',
|
||||
border: 'border-blue-200',
|
||||
ring: 'focus:ring-blue-500',
|
||||
notificationBg: 'bg-blue-100',
|
||||
notificationIcon: 'text-blue-700',
|
||||
},
|
||||
};
|
||||
|
||||
/** 根据角色获取主题色 */
|
||||
export function getRoleTheme(role: string): ThemeColors {
|
||||
return ROLE_THEMES[role] || ROLE_THEMES.system;
|
||||
}
|
||||
|
||||
// ==================== 文件上传配置 ====================
|
||||
|
||||
export const UPLOAD_CONFIG = {
|
||||
/** 产品图片最大大小(字节)- 5MB */
|
||||
maxImageSize: 5 * 1024 * 1024,
|
||||
/** 允许的图片类型 */
|
||||
allowedImageTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
||||
/** 头像存储桶 */
|
||||
avatarBucket: 'avatars',
|
||||
/** 产品图片存储桶 */
|
||||
productImageBucket: 'product_images',
|
||||
} as const;
|
||||
|
||||
// ==================== 分享链接配置 ====================
|
||||
|
||||
export const SHARE_CONFIG = {
|
||||
/** 链接有效期(毫秒)- 30分钟 */
|
||||
linkExpiryMs: 30 * 60 * 1000,
|
||||
/** 短码长度 */
|
||||
shortCodeLength: 8,
|
||||
} as const;
|
||||
|
||||
// ==================== LocalStorage Key 常量 ====================
|
||||
|
||||
export const STORAGE_KEYS = {
|
||||
savedUsername: 'saved_username',
|
||||
savedPassword: 'saved_password',
|
||||
autoLogin: 'auto_login',
|
||||
autoLoginExpiry: 'auto_login_expiry',
|
||||
textileRejectedPlans: 'textile_rejected_plans',
|
||||
viewedRejectedModals: 'viewed_rejected_modals',
|
||||
onboardingCompleted: 'onboarding_completed',
|
||||
themePreference: 'theme',
|
||||
performanceTier: 'performance_tier',
|
||||
demoDisclaimerShown: 'demo_disclaimer_shown',
|
||||
appVersion: 'app_version',
|
||||
updateModalShown: 'update_modal_shown',
|
||||
purchaserSidebarCollapsed: 'purchaser_sidebar_collapsed',
|
||||
textileSidebarCollapsed: 'textile_sidebar_collapsed',
|
||||
washingSidebarCollapsed: 'washing_sidebar_collapsed',
|
||||
} as const;
|
||||
81
src/contexts/PlanDataContext.tsx
Normal file
81
src/contexts/PlanDataContext.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { Company, PlanFactory, YarnRatio } from '../types';
|
||||
|
||||
interface PlanDataContextValue {
|
||||
operatorNames: Record<string, string>;
|
||||
planFactories: PlanFactory[];
|
||||
yarnRatios: Record<string, YarnRatio[]>;
|
||||
factories: Company[];
|
||||
getFactoryName: (factoryId: string) => string;
|
||||
getOperatorName: (operatorId: string) => string;
|
||||
getYarnRatiosForPlan: (planId: string) => YarnRatio[];
|
||||
}
|
||||
|
||||
const PlanDataContext = createContext<PlanDataContextValue | null>(null);
|
||||
|
||||
interface PlanDataProviderProps {
|
||||
children: React.ReactNode;
|
||||
operatorNames: Record<string, string>;
|
||||
planFactories: PlanFactory[];
|
||||
yarnRatios: Record<string, YarnRatio[]>;
|
||||
factories: Company[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 计划数据上下文 Provider
|
||||
* 消除 PlanOverview → PlanGroup → PlanCard 的 Prop Drilling
|
||||
*/
|
||||
export function PlanDataProvider({
|
||||
children,
|
||||
operatorNames,
|
||||
planFactories,
|
||||
yarnRatios,
|
||||
factories
|
||||
}: PlanDataProviderProps) {
|
||||
// 使用 useMemo 缓存计算函数,避免不必要的重渲染
|
||||
const value = useMemo<PlanDataContextValue>(() => ({
|
||||
operatorNames,
|
||||
planFactories,
|
||||
yarnRatios,
|
||||
factories,
|
||||
|
||||
getFactoryName: (factoryId: string) => {
|
||||
const factory = factories.find(f => f.id === factoryId);
|
||||
return factory?.name || '未知工厂';
|
||||
},
|
||||
|
||||
getOperatorName: (operatorId: string) => {
|
||||
return operatorNames[operatorId] || '未知操作人';
|
||||
},
|
||||
|
||||
getYarnRatiosForPlan: (planId: string) => {
|
||||
return yarnRatios[planId] || [];
|
||||
}
|
||||
}), [operatorNames, planFactories, yarnRatios, factories]);
|
||||
|
||||
return (
|
||||
<PlanDataContext.Provider value={value}>
|
||||
{children}
|
||||
</PlanDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用计划数据上下文的 Hook
|
||||
* @throws 如果在 PlanDataProvider 外部使用会抛出错误
|
||||
*/
|
||||
export function usePlanData(): PlanDataContextValue {
|
||||
const context = useContext(PlanDataContext);
|
||||
if (!context) {
|
||||
throw new Error('usePlanData must be used within a PlanDataProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可选的计划数据上下文 Hook
|
||||
* 在 Provider 外部使用时返回 null 而不是抛出错误
|
||||
*/
|
||||
export function usePlanDataOptional(): PlanDataContextValue | null {
|
||||
return useContext(PlanDataContext);
|
||||
}
|
||||
226
src/hooks/useDashboardData.ts
Normal file
226
src/hooks/useDashboardData.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import type { ProductionPlan, WashingPlan } from '../types';
|
||||
|
||||
export type DashboardRole = 'purchaser' | 'textile' | 'washing';
|
||||
|
||||
interface DashboardStats {
|
||||
all: number;
|
||||
producing: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
interface UseDashboardDataOptions {
|
||||
role: DashboardRole;
|
||||
companyId: string | null;
|
||||
}
|
||||
|
||||
interface DashboardDataState<T> {
|
||||
plans: T[];
|
||||
stats: DashboardStats;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
rejectedPlanIds: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 Dashboard 数据获取 Hook
|
||||
* 根据角色自动选择正确的数据源和过滤逻辑
|
||||
*/
|
||||
export function useDashboardData<T = ProductionPlan | WashingPlan>({
|
||||
role,
|
||||
companyId
|
||||
}: UseDashboardDataOptions) {
|
||||
const [state, setState] = useState<DashboardDataState<T>>({
|
||||
plans: [],
|
||||
stats: { all: 0, producing: 0, completed: 0 },
|
||||
loading: true,
|
||||
error: null,
|
||||
rejectedPlanIds: new Set()
|
||||
});
|
||||
|
||||
// 缓存引用,避免重复请求
|
||||
const cacheRef = useRef<{
|
||||
data: DashboardDataState<T> | null;
|
||||
timestamp: number;
|
||||
}>({ data: null, timestamp: 0 });
|
||||
|
||||
const CACHE_DURATION = 5000; // 5秒缓存
|
||||
|
||||
// 计算统计数据
|
||||
const calculateStats = useCallback((plans: T[], roleType: DashboardRole): DashboardStats => {
|
||||
const all = plans.length;
|
||||
let producing = 0;
|
||||
let completed = 0;
|
||||
|
||||
if (roleType === 'washing') {
|
||||
// 水洗厂使用 processing 状态
|
||||
producing = (plans as WashingPlan[]).filter(p => p.status === 'processing').length;
|
||||
completed = (plans as WashingPlan[]).filter(p => p.status === 'completed').length;
|
||||
} else {
|
||||
// 采购商和纺织厂使用 producing 状态
|
||||
producing = (plans as ProductionPlan[]).filter(p => p.status === 'producing').length;
|
||||
completed = (plans as ProductionPlan[]).filter(p => p.status === 'completed').length;
|
||||
}
|
||||
|
||||
return { all, producing, completed };
|
||||
}, []);
|
||||
|
||||
// 获取采购商数据
|
||||
const fetchPurchaserData = useCallback(async () => {
|
||||
if (!companyId) return;
|
||||
|
||||
const { data: planData, error } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.eq('purchaser_id', companyId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// 获取流程步骤,检测 rejected 状态
|
||||
const planIds = (planData || []).map(p => p.id);
|
||||
const { data: stepsData } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('plan_id, status')
|
||||
.in('plan_id', planIds);
|
||||
|
||||
const rejectedIds = new Set<string>();
|
||||
(stepsData || []).forEach(step => {
|
||||
if (step.status === 'rejected') {
|
||||
rejectedIds.add(step.plan_id);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredPlans = (planData || []).filter(p => !rejectedIds.has(p.id)) as T[];
|
||||
const stats = calculateStats(filteredPlans, 'purchaser');
|
||||
|
||||
return { plans: filteredPlans, stats, rejectedPlanIds: rejectedIds };
|
||||
}, [companyId, calculateStats]);
|
||||
|
||||
// 获取纺织厂数据
|
||||
const fetchTextileData = useCallback(async () => {
|
||||
if (!companyId) return;
|
||||
|
||||
// 先获取关联的计划ID
|
||||
const { data: planFactories } = await supabase
|
||||
.from('plan_factories')
|
||||
.select('plan_id')
|
||||
.eq('factory_id', companyId)
|
||||
.eq('factory_type', 'textile');
|
||||
|
||||
if (!planFactories || planFactories.length === 0) {
|
||||
return { plans: [] as T[], stats: { all: 0, producing: 0, completed: 0 }, rejectedPlanIds: new Set<string>() };
|
||||
}
|
||||
|
||||
const planIds = planFactories.map(pf => pf.plan_id);
|
||||
const { data: planData, error } = await supabase
|
||||
.from('production_plans')
|
||||
.select('*')
|
||||
.in('id', planIds)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// 获取流程步骤,检测 rejected 状态
|
||||
const { data: stepsData } = await supabase
|
||||
.from('plan_process_steps')
|
||||
.select('plan_id, status')
|
||||
.in('plan_id', planIds);
|
||||
|
||||
const rejectedIds = new Set<string>();
|
||||
(stepsData || []).forEach(step => {
|
||||
if (step.status === 'rejected') {
|
||||
rejectedIds.add(step.plan_id);
|
||||
}
|
||||
});
|
||||
|
||||
const filteredPlans = (planData || []).filter(p => !rejectedIds.has(p.id)) as T[];
|
||||
const stats = calculateStats(filteredPlans, 'textile');
|
||||
|
||||
return { plans: filteredPlans, stats, rejectedPlanIds: rejectedIds };
|
||||
}, [companyId, calculateStats]);
|
||||
|
||||
// 获取水洗厂数据
|
||||
const fetchWashingData = useCallback(async () => {
|
||||
if (!companyId) return;
|
||||
|
||||
const { data: washingPlans, error } = await supabase
|
||||
.from('washing_plans')
|
||||
.select('*')
|
||||
.eq('washing_factory_id', companyId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const plans = (washingPlans || []) as T[];
|
||||
const stats = calculateStats(plans, 'washing');
|
||||
|
||||
return { plans, stats, rejectedPlanIds: new Set<string>() };
|
||||
}, [companyId, calculateStats]);
|
||||
|
||||
// 主数据获取函数
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!companyId) {
|
||||
setState(prev => ({ ...prev, loading: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查缓存
|
||||
const now = Date.now();
|
||||
if (cacheRef.current.data && (now - cacheRef.current.timestamp) < CACHE_DURATION) {
|
||||
setState(cacheRef.current.data);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
let result: DashboardDataState<T> | undefined;
|
||||
|
||||
switch (role) {
|
||||
case 'purchaser':
|
||||
result = await fetchPurchaserData() as DashboardDataState<T> | undefined;
|
||||
break;
|
||||
case 'textile':
|
||||
result = await fetchTextileData() as DashboardDataState<T> | undefined;
|
||||
break;
|
||||
case 'washing':
|
||||
result = await fetchWashingData() as DashboardDataState<T> | undefined;
|
||||
break;
|
||||
}
|
||||
|
||||
if (result) {
|
||||
const newState = {
|
||||
plans: result.plans,
|
||||
stats: result.stats,
|
||||
loading: false,
|
||||
error: null,
|
||||
rejectedPlanIds: result.rejectedPlanIds
|
||||
};
|
||||
setState(newState);
|
||||
cacheRef.current = { data: newState, timestamp: now };
|
||||
}
|
||||
} catch (err) {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: err instanceof Error ? err : new Error('Unknown error')
|
||||
}));
|
||||
}
|
||||
}, [companyId, role, fetchPurchaserData, fetchTextileData, fetchWashingData]);
|
||||
|
||||
// 初始加载和 companyId 变化时重新获取
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// 提供手动刷新方法
|
||||
const refresh = useCallback(() => {
|
||||
cacheRef.current = { data: null, timestamp: 0 };
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
98
src/hooks/useErrorHandler.ts
Normal file
98
src/hooks/useErrorHandler.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export type ErrorSeverity = 'info' | 'warning' | 'error' | 'critical';
|
||||
|
||||
interface ErrorHandlerOptions {
|
||||
onError?: (error: Error, severity: ErrorSeverity) => void;
|
||||
showToast?: boolean;
|
||||
logToConsole?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的错误处理 Hook
|
||||
* 提供一致的错误处理、日志记录和用户反馈机制
|
||||
*/
|
||||
export function useErrorHandler(options: ErrorHandlerOptions = {}) {
|
||||
const {
|
||||
onError,
|
||||
showToast = true,
|
||||
logToConsole = true
|
||||
} = options;
|
||||
|
||||
const handleError = useCallback((
|
||||
error: unknown,
|
||||
message?: string,
|
||||
severity: ErrorSeverity = 'error'
|
||||
) => {
|
||||
// 标准化错误对象
|
||||
const err = error instanceof Error
|
||||
? error
|
||||
: new Error(message || String(error));
|
||||
|
||||
// 控制台日志
|
||||
if (logToConsole) {
|
||||
const logMethod = severity === 'critical' ? console.error
|
||||
: severity === 'error' ? console.error
|
||||
: severity === 'warning' ? console.warn
|
||||
: console.log;
|
||||
|
||||
logMethod(`[${severity.toUpperCase()}] ${message || err.message}`, err);
|
||||
}
|
||||
|
||||
// 自定义错误回调
|
||||
if (onError) {
|
||||
onError(err, severity);
|
||||
}
|
||||
|
||||
// Toast 提示(如果可用)
|
||||
if (showToast && typeof window !== 'undefined') {
|
||||
// 这里可以集成具体的 toast 库
|
||||
// 暂时使用简单的 alert 作为后备
|
||||
if (severity === 'critical' || severity === 'error') {
|
||||
// 在生产环境中应该使用更优雅的通知组件
|
||||
console.error('Error notification:', message || err.message);
|
||||
}
|
||||
}
|
||||
|
||||
return err;
|
||||
}, [onError, showToast, logToConsole]);
|
||||
|
||||
// 便捷方法
|
||||
const handleInfo = useCallback((error: unknown, message?: string) => {
|
||||
return handleError(error, message, 'info');
|
||||
}, [handleError]);
|
||||
|
||||
const handleWarning = useCallback((error: unknown, message?: string) => {
|
||||
return handleError(error, message, 'warning');
|
||||
}, [handleError]);
|
||||
|
||||
const handleErrorLevel = useCallback((error: unknown, message?: string) => {
|
||||
return handleError(error, message, 'error');
|
||||
}, [handleError]);
|
||||
|
||||
const handleCritical = useCallback((error: unknown, message?: string) => {
|
||||
return handleError(error, message, 'critical');
|
||||
}, [handleError]);
|
||||
|
||||
// 异步操作包装器
|
||||
const withErrorHandling = useCallback(async <T>(
|
||||
operation: () => Promise<T>,
|
||||
errorMessage?: string,
|
||||
severity: ErrorSeverity = 'error'
|
||||
): Promise<T | null> => {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (err) {
|
||||
handleError(err, errorMessage, severity);
|
||||
return null;
|
||||
}
|
||||
}, [handleError]);
|
||||
|
||||
return {
|
||||
handleError: handleErrorLevel,
|
||||
handleInfo,
|
||||
handleWarning,
|
||||
handleCritical,
|
||||
withErrorHandling
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { CACHE_CONFIG } from '../config/app';
|
||||
import { useInventoryRealtime } from './useRealtime';
|
||||
import type {
|
||||
Product,
|
||||
@ -41,7 +42,7 @@ export function useInventoryData({ companyId, activeType }: UseInventoryDataOpti
|
||||
key: string;
|
||||
}>({ data: null, timestamp: 0, key: '' });
|
||||
|
||||
const CACHE_DURATION = 5000; // 5秒缓存
|
||||
const CACHE_DURATION = CACHE_CONFIG.inventoryDataDuration;
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!companyId || activeType !== 'raw_fabric') {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { statusMap } from '../utils/constants';
|
||||
import { CACHE_CONFIG, PAGINATION_CONFIG } from '../config/app';
|
||||
import { usePlanRealtime } from './useRealtime';
|
||||
import type {
|
||||
ProductionPlan,
|
||||
@ -34,7 +35,7 @@ interface PlanDataState {
|
||||
* 优化的计划数据获取 Hook
|
||||
* 支持分页加载和缓存
|
||||
*/
|
||||
export function usePlanData({ companyId, pageSize = 20 }: UsePlanDataOptions) {
|
||||
export function usePlanData({ companyId, pageSize = PAGINATION_CONFIG.defaultPageSize }: UsePlanDataOptions) {
|
||||
const [state, setState] = useState<PlanDataState>({
|
||||
plans: [],
|
||||
factories: [],
|
||||
@ -54,7 +55,7 @@ export function usePlanData({ companyId, pageSize = 20 }: UsePlanDataOptions) {
|
||||
timestamp: number;
|
||||
}>({ data: null, timestamp: 0 });
|
||||
|
||||
const CACHE_DURATION = 10000; // 10秒缓存
|
||||
const CACHE_DURATION = CACHE_CONFIG.planDataDuration;
|
||||
|
||||
// 获取操作人名称
|
||||
const fetchOperatorNames = useCallback(async (operatorIds: string[]) => {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { supabase } from '../supabase/client';
|
||||
import { SYNC_CONFIG } from '../config/app';
|
||||
import { PLAN_STATUS, STEP_STATUS } from '../utils/constants';
|
||||
|
||||
interface PlanStatusCheckResult {
|
||||
planId: string;
|
||||
@ -62,7 +64,7 @@ export function usePlanStatusSync(companyId: string | undefined) {
|
||||
const confirmStep = stepsData.find(s => s.plan_id === plan.id);
|
||||
|
||||
// 如果 confirm 步骤已完成但计划状态仍为 pending,则需要修复
|
||||
if (confirmStep?.status === 'completed' && plan.status === 'pending') {
|
||||
if (confirmStep?.status === STEP_STATUS.COMPLETED && plan.status === PLAN_STATUS.PENDING) {
|
||||
inconsistentPlans.push({
|
||||
planId: plan.id,
|
||||
planCode: plan.plan_code,
|
||||
@ -80,7 +82,7 @@ export function usePlanStatusSync(companyId: string | undefined) {
|
||||
for (const item of inconsistentPlans) {
|
||||
const { error } = await supabase
|
||||
.from('production_plans')
|
||||
.update({ status: 'producing' })
|
||||
.update({ status: PLAN_STATUS.PRODUCING })
|
||||
.eq('id', item.planId);
|
||||
|
||||
if (error) {
|
||||
@ -103,8 +105,8 @@ export function usePlanStatusSync(companyId: string | undefined) {
|
||||
// 立即执行一次检查
|
||||
checkAndFixPlanStatus();
|
||||
|
||||
// 每5分钟检查一次(测试时改为10秒)
|
||||
intervalRef.current = setInterval(checkAndFixPlanStatus, 10 * 1000); // 10秒(测试用)
|
||||
// 同步间隔由配置中心管理(开发环境10秒,生产环境5分钟)
|
||||
intervalRef.current = setInterval(checkAndFixPlanStatus, SYNC_CONFIG.planStatusSyncInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
|
||||
@ -5,12 +5,20 @@ import { motion } from 'framer-motion';
|
||||
import { Eye, EyeOff, LogIn, Building2, Factory, Droplets } from 'lucide-react';
|
||||
import { useResponsive } from '../hooks/useResponsive';
|
||||
import { ComingSoonModal } from '../components/ComingSoonModal';
|
||||
import { DEMO_ACCOUNTS, SHOW_DEMO_ACCOUNTS, APP_CONFIG, STORAGE_KEYS } from '../config/app';
|
||||
|
||||
const demoAccounts = [
|
||||
{ username: 'purchaser', label: '采购商(布行)', color: 'from-blue-500 to-blue-600', icon: Building2, enabled: true },
|
||||
{ username: 'textile', label: '纺织厂', color: 'from-emerald-500 to-green-600', icon: Factory, enabled: true },
|
||||
{ username: 'washing', label: '水洗厂', color: 'from-violet-400 to-fuchsia-400', icon: Droplets, enabled: true }
|
||||
];
|
||||
// 图标映射(将字符串映射到实际组件)
|
||||
const iconMap: Record<string, typeof Building2> = {
|
||||
Building2,
|
||||
Factory,
|
||||
Droplets,
|
||||
};
|
||||
|
||||
// 从配置中心获取演示账号,并映射图标组件
|
||||
const demoAccounts = DEMO_ACCOUNTS.map(account => ({
|
||||
...account,
|
||||
icon: iconMap[account.icon] || Building2,
|
||||
}));
|
||||
|
||||
// 纺织主题背景动画组件
|
||||
function YarnAnimation() {
|
||||
@ -290,10 +298,10 @@ export function LoginPage() {
|
||||
|
||||
// 页面加载时检查本地存储的登录信息
|
||||
useEffect(() => {
|
||||
const savedUsername = localStorage.getItem('saved_username');
|
||||
const savedPassword = localStorage.getItem('saved_password');
|
||||
const savedAutoLogin = localStorage.getItem('auto_login');
|
||||
const autoLoginExpiry = localStorage.getItem('auto_login_expiry');
|
||||
const savedUsername = localStorage.getItem(STORAGE_KEYS.savedUsername);
|
||||
const savedPassword = localStorage.getItem(STORAGE_KEYS.savedPassword);
|
||||
const savedAutoLogin = localStorage.getItem(STORAGE_KEYS.autoLogin);
|
||||
const autoLoginExpiry = localStorage.getItem(STORAGE_KEYS.autoLoginExpiry);
|
||||
|
||||
if (savedUsername) {
|
||||
setUsername(savedUsername);
|
||||
@ -313,8 +321,8 @@ export function LoginPage() {
|
||||
handleAutoLogin(savedUsername, savedPassword);
|
||||
} else {
|
||||
// 已过期,清除免密登录状态
|
||||
localStorage.removeItem('auto_login');
|
||||
localStorage.removeItem('auto_login_expiry');
|
||||
localStorage.removeItem(STORAGE_KEYS.autoLogin);
|
||||
localStorage.removeItem(STORAGE_KEYS.autoLoginExpiry);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
@ -340,20 +348,20 @@ export function LoginPage() {
|
||||
if (success) {
|
||||
// 保存登录信息到本地存储
|
||||
if (rememberPassword) {
|
||||
localStorage.setItem('saved_username', username);
|
||||
localStorage.setItem('saved_password', password);
|
||||
localStorage.setItem(STORAGE_KEYS.savedUsername, username);
|
||||
localStorage.setItem(STORAGE_KEYS.savedPassword, password);
|
||||
} else {
|
||||
localStorage.removeItem('saved_username');
|
||||
localStorage.removeItem('saved_password');
|
||||
localStorage.removeItem(STORAGE_KEYS.savedUsername);
|
||||
localStorage.removeItem(STORAGE_KEYS.savedPassword);
|
||||
}
|
||||
|
||||
// 保存免密登录设置(30天)
|
||||
// 保存免密登录设置
|
||||
if (autoLogin) {
|
||||
localStorage.setItem('auto_login', 'true');
|
||||
localStorage.setItem('auto_login_expiry', String(Date.now() + 30 * 24 * 60 * 60 * 1000));
|
||||
localStorage.setItem(STORAGE_KEYS.autoLogin, 'true');
|
||||
localStorage.setItem(STORAGE_KEYS.autoLoginExpiry, String(Date.now() + APP_CONFIG.autoLoginExpiryMs));
|
||||
} else {
|
||||
localStorage.removeItem('auto_login');
|
||||
localStorage.removeItem('auto_login_expiry');
|
||||
localStorage.removeItem(STORAGE_KEYS.autoLogin);
|
||||
localStorage.removeItem(STORAGE_KEYS.autoLoginExpiry);
|
||||
}
|
||||
|
||||
navigate('/role-select');
|
||||
@ -366,7 +374,7 @@ export function LoginPage() {
|
||||
const handleDemoLogin = async (uname: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
const success = await login(uname, '123456');
|
||||
const success = await login(uname, APP_CONFIG.defaultDemoPassword);
|
||||
if (success) {
|
||||
navigate('/role-select');
|
||||
} else {
|
||||
@ -550,7 +558,7 @@ export function LoginPage() {
|
||||
animate={{ opacity: [0.5, 0.8, 0.5] }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||||
>
|
||||
快速体验(密码: 123456)
|
||||
快速体验(密码: {APP_CONFIG.defaultDemoPassword})
|
||||
</motion.p>
|
||||
<div className="grid grid-cols-3 gap-2 sm:gap-3">
|
||||
{demoAccounts.map((account, i) => (
|
||||
|
||||
@ -425,6 +425,28 @@ export function MemberManage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 数据完整性监控入口 */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 md:p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Shield className={`w-5 h-5 ${theme.text}`} />
|
||||
<h2 className="font-semibold text-gray-800">系统管理</h2>
|
||||
</div>
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={() => navigate('/admin/data-integrity')}
|
||||
className={`flex items-center gap-3 p-4 w-full ${theme.bg} rounded-xl ${theme.bgHover} transition-colors text-left`}
|
||||
>
|
||||
<div className={`w-10 h-10 ${theme.bg} rounded-lg flex items-center justify-center`}>
|
||||
<Shield className={`w-5 h-5 ${theme.text}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">数据完整性监控</p>
|
||||
<p className="text-xs text-gray-500">查看系统数据一致性与错误率</p>
|
||||
</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* 帮助与支持卡片 */}
|
||||
<div className="bg-white rounded-lg shadow-sm p-4 md:p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
|
||||
@ -41,6 +41,7 @@ import { VersionUpdate, useVersionCheck, AboutModal } from '../../components/Ver
|
||||
import { FeedbackModal } from '../../components/FeedbackModal';
|
||||
import { UserManual } from '../../components/UserManual';
|
||||
import type { ProductionPlan } from '../../types';
|
||||
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any; borderColor: string }> = {
|
||||
pending: { label: '待确定', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock },
|
||||
@ -48,16 +49,6 @@ const statusMap: Record<string, { label: string; color: string; bgColor: string;
|
||||
completed: { label: '已完成', color: 'text-emerald-600', bgColor: 'bg-emerald-50', borderColor: 'border-emerald-200', icon: Package }
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.08 } }
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] } }
|
||||
};
|
||||
|
||||
export function PurchaserDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const { auth, logout } = useAuth();
|
||||
@ -219,9 +210,7 @@ export function PurchaserDashboard() {
|
||||
{/* 页面内容 */}
|
||||
<div className="p-3 sm:p-4 md:p-8 max-w-7xl mx-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner text="加载仪表盘数据..." className="py-20" />
|
||||
) : (
|
||||
<div className="space-y-4 sm:space-y-6 animate-fade-in">
|
||||
{/* 统计卡片 - 移动端3列紧凑布局,桌面端4列 */}
|
||||
|
||||
@ -90,9 +90,9 @@ export function FinishedWarehouse() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-emerald-50 to-teal-50">
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-amber-50 to-orange-50">
|
||||
{/* Header */}
|
||||
<header className="bg-gradient-to-r from-emerald-600 to-teal-600 shadow-lg">
|
||||
<header className="bg-gradient-to-r from-amber-500 to-orange-500 shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 py-4 flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/purchaser')}
|
||||
@ -116,7 +116,7 @@ export function FinishedWarehouse() {
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<p className="text-xs text-gray-500 mb-1">库存米数</p>
|
||||
<p className="text-2xl font-bold text-emerald-600">{stats.totalStockMeters.toFixed(2)}</p>
|
||||
<p className="text-2xl font-bold text-amber-600">{stats.totalStockMeters.toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<p className="text-xs text-gray-500 mb-1">库存匹/件数</p>
|
||||
@ -137,7 +137,7 @@ export function FinishedWarehouse() {
|
||||
placeholder="搜索成品名称、坯布码、颜色..."
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -190,7 +190,7 @@ export function FinishedWarehouse() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openRecordsModal(product)}
|
||||
className="px-4 py-2 text-emerald-600 hover:bg-emerald-50 rounded-lg transition-colors text-sm font-medium flex items-center gap-1"
|
||||
className="px-4 py-2 text-amber-600 hover:bg-amber-50 rounded-lg transition-colors text-sm font-medium flex items-center gap-1"
|
||||
>
|
||||
<History className="w-4 h-4" />
|
||||
出入库记录
|
||||
|
||||
@ -12,7 +12,9 @@ import { VersionUpdate, useVersionCheck, AboutModal } from '../../components/Ver
|
||||
import { FeedbackModal } from '../../components/FeedbackModal';
|
||||
import { UserManual } from '../../components/UserManual';
|
||||
import { CrashReporter } from '../../components/CrashReporter';
|
||||
import { STORAGE_KEYS } from '../../config/app';
|
||||
import type { ProductionPlan } from '../../types';
|
||||
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any; borderColor: string }> = {
|
||||
pending: { label: '待确定', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock },
|
||||
@ -20,16 +22,6 @@ const statusMap: Record<string, { label: string; color: string; bgColor: string;
|
||||
completed: { label: '已完成', color: 'text-teal-600', bgColor: 'bg-teal-50', borderColor: 'border-teal-200', icon: Package }
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.08 } }
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] } }
|
||||
};
|
||||
|
||||
const getQuickActions = () => {
|
||||
return [
|
||||
{ id: 'plans', icon: FileText, label: '计划总览', path: '/textile/plans', gradient: 'from-emerald-400 to-teal-400', description: '查看所有计划' },
|
||||
@ -71,7 +63,7 @@ export function TextileDashboard() {
|
||||
if (!auth.company) return;
|
||||
fetchPlans();
|
||||
// 从 localStorage 读取被拒绝的计划ID
|
||||
const stored = localStorage.getItem('textile_rejected_plans');
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.textileRejectedPlans);
|
||||
if (stored) {
|
||||
setRejectedPlanIds(new Set(JSON.parse(stored)));
|
||||
}
|
||||
@ -184,18 +176,14 @@ export function TextileDashboard() {
|
||||
|
||||
<div className="p-3 sm:p-4 md:p-8 max-w-7xl mx-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner text="加载仪表盘数据..." className="py-20" />
|
||||
) : (
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6 animate-fade-in">
|
||||
{/* 统计卡片 - 移动端3列紧凑布局,桌面端3列 */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-3 gap-2 sm:gap-4">
|
||||
{statCards.map((stat) => (
|
||||
<motion.div
|
||||
<div
|
||||
key={stat.id}
|
||||
variants={itemVariants}
|
||||
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
|
||||
onClick={() => navigate(stat.path)}
|
||||
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-slate-200 cursor-pointer hover:border-emerald-300 hover:shadow-md transition-all"
|
||||
>
|
||||
@ -207,12 +195,12 @@ export function TextileDashboard() {
|
||||
</div>
|
||||
<p className="text-xl sm:text-3xl font-bold text-slate-900">{stat.value}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5 sm:mt-1">{stat.sublabel}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 最近计划 - 左右滑动展示 - 与采购商保持一致 */}
|
||||
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-slate-200 p-3 sm:p-4 md:p-6">
|
||||
<div className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-slate-200 p-3 sm:p-4 md:p-6">
|
||||
<div className="flex justify-between items-center mb-3 sm:mb-4">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-slate-900 whitespace-nowrap">最近计划</h2>
|
||||
<button
|
||||
@ -391,11 +379,11 @@ export function TextileDashboard() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 快捷操作 - 仅移动端显示,桌面端通过侧边栏导航 */}
|
||||
{isMobile && (
|
||||
<motion.div variants={itemVariants} className="bg-white rounded-xl shadow-sm border border-slate-200 p-3">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-3">
|
||||
<h2 className="text-sm font-semibold text-slate-900 mb-3 whitespace-nowrap">快捷操作</h2>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{getQuickActions().map((action) => (
|
||||
@ -411,9 +399,9 @@ export function TextileDashboard() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -8,15 +8,10 @@ import { ChevronDown, ChevronRight, ArrowLeft, Link2, Clipboard, Check, Loader2,
|
||||
import { ProcessFlow } from '../../components/ProcessFlow';
|
||||
import { notifyStepCompleted, notifyPlanUpdated } from '../../utils/notifications';
|
||||
import { getSupabaseUrl } from '../../supabase/client';
|
||||
import { simpleStatusMap as statusMap, PLAN_STATUS, STEP_STATUS } from '../../utils/constants';
|
||||
import { STORAGE_KEYS } from '../../config/app';
|
||||
import type { ProductionPlan, PlanProcessStep, Company, Profile, YarnRatio } from '../../types';
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待确定', color: 'bg-amber-100 text-amber-700' },
|
||||
producing: { label: '生产中', color: 'bg-emerald-100 text-emerald-700' },
|
||||
completed: { label: '已完成', color: 'bg-green-100 text-green-700' },
|
||||
rejected: { label: '已拒绝', color: 'bg-gray-100 text-gray-600' }
|
||||
};
|
||||
|
||||
// 纱线类型显示映射
|
||||
const yarnTypeLabels: Record<string, string> = {
|
||||
warp: '经线',
|
||||
@ -380,11 +375,11 @@ export function TextilePlanOverview() {
|
||||
setRejectedPlans(prev => new Set([...prev, step.plan_id]));
|
||||
|
||||
// 存储到 localStorage,让 Dashboard 也能过滤掉
|
||||
const stored = localStorage.getItem('textile_rejected_plans');
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.textileRejectedPlans);
|
||||
const rejectedArray = stored ? JSON.parse(stored) : [];
|
||||
if (!rejectedArray.includes(step.plan_id)) {
|
||||
rejectedArray.push(step.plan_id);
|
||||
localStorage.setItem('textile_rejected_plans', JSON.stringify(rejectedArray));
|
||||
localStorage.setItem(STORAGE_KEYS.textileRejectedPlans, JSON.stringify(rejectedArray));
|
||||
}
|
||||
|
||||
// 关闭弹窗并收起计划卡片
|
||||
|
||||
@ -13,6 +13,7 @@ import { FeedbackModal } from '../../components/FeedbackModal';
|
||||
import { UserManual } from '../../components/UserManual';
|
||||
import { CrashReporter } from '../../components/CrashReporter';
|
||||
import type { WashingPlan } from '../../types';
|
||||
import { LoadingSpinner } from '../../components/ui/LoadingSpinner';
|
||||
|
||||
const statusMap: Record<string, { label: string; color: string; bgColor: string; icon: any; borderColor: string }> = {
|
||||
pending: { label: '待处理', color: 'text-amber-600', bgColor: 'bg-amber-50', borderColor: 'border-amber-200', icon: Clock },
|
||||
@ -34,15 +35,6 @@ const getQuickActions = (isMaster: boolean) => {
|
||||
return actions;
|
||||
};
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { staggerChildren: 0.08 } }
|
||||
};
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.4, ease: [0.25, 0.46, 0.45, 0.94] } }
|
||||
};
|
||||
|
||||
export function WashingDashboard() {
|
||||
const navigate = useNavigate();
|
||||
@ -156,18 +148,14 @@ export function WashingDashboard() {
|
||||
|
||||
<div className="p-3 sm:p-4 md:p-8 max-w-7xl mx-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="w-8 h-8 border-2 border-violet-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
<LoadingSpinner text="加载仪表盘数据..." className="py-20" />
|
||||
) : (
|
||||
<motion.div variants={containerVariants} initial="hidden" animate="visible" className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6 animate-fade-in">
|
||||
{/* 统计卡片 - 移动端3列紧凑布局,桌面端3列 */}
|
||||
<div className="grid grid-cols-3 sm:grid-cols-3 gap-2 sm:gap-4">
|
||||
{statCards.map((stat) => (
|
||||
<motion.div
|
||||
<div
|
||||
key={stat.id}
|
||||
variants={itemVariants}
|
||||
whileHover={isMobile ? {} : { scale: 1.02, y: -2 }}
|
||||
onClick={() => navigate(stat.path)}
|
||||
className="bg-white rounded-xl sm:rounded-2xl p-3 sm:p-5 shadow-sm border border-slate-200 cursor-pointer hover:border-violet-300 hover:shadow-md transition-all"
|
||||
>
|
||||
@ -179,12 +167,12 @@ export function WashingDashboard() {
|
||||
</div>
|
||||
<p className="text-xl sm:text-3xl font-bold text-slate-900">{stat.value}</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5 sm:mt-1">{stat.sublabel}</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 最近计划 - 左右滑动展示 */}
|
||||
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-slate-200 p-3 sm:p-4 md:p-6">
|
||||
<div className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-slate-200 p-3 sm:p-4 md:p-6">
|
||||
<div className="flex justify-between items-center mb-3 sm:mb-4">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-slate-900 whitespace-nowrap">最近计划</h2>
|
||||
<button
|
||||
@ -330,10 +318,10 @@ export function WashingDashboard() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<motion.div variants={itemVariants} className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-slate-200 p-3 sm:p-4 md:p-6">
|
||||
<div className="bg-white rounded-xl sm:rounded-2xl shadow-sm border border-slate-200 p-3 sm:p-4 md:p-6">
|
||||
<h2 className="text-sm sm:text-base font-semibold text-slate-900 mb-3 sm:mb-4 whitespace-nowrap">快捷操作</h2>
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||
{getQuickActions(auth.user?.is_master || false).map((action) => (
|
||||
@ -351,8 +339,8 @@ export function WashingDashboard() {
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -70,6 +70,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "accounts_payable_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "accounts_payable_purchaser_id_fkey"
|
||||
columns: ["purchaser_id"]
|
||||
@ -131,6 +137,12 @@ export type Database = {
|
||||
referencedRelation: "accounts_payable"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "accounts_payable_items_accounts_payable_id_fkey"
|
||||
columns: ["accounts_payable_id"]
|
||||
referencedRelation: "v_accounts_payable_check"
|
||||
referencedColumns: ["ap_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "accounts_payable_items_inventory_record_id_fkey"
|
||||
columns: ["inventory_record_id"]
|
||||
@ -143,6 +155,12 @@ export type Database = {
|
||||
referencedRelation: "payments"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "accounts_payable_items_payment_id_fkey"
|
||||
columns: ["payment_id"]
|
||||
referencedRelation: "v_payment_check"
|
||||
referencedColumns: ["payment_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
audit_logs: {
|
||||
@ -334,6 +352,45 @@ export type Database = {
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
data_audit_logs: {
|
||||
Row: {
|
||||
changed_at: string
|
||||
changed_by: string | null
|
||||
error_message: string | null
|
||||
id: string
|
||||
new_values: Json | null
|
||||
old_values: Json | null
|
||||
operation: string
|
||||
record_id: string
|
||||
table_name: string
|
||||
validation_passed: boolean
|
||||
}
|
||||
Insert: {
|
||||
changed_at?: string
|
||||
changed_by?: string | null
|
||||
error_message?: string | null
|
||||
id?: string
|
||||
new_values?: Json | null
|
||||
old_values?: Json | null
|
||||
operation: string
|
||||
record_id: string
|
||||
table_name: string
|
||||
validation_passed?: boolean
|
||||
}
|
||||
Update: {
|
||||
changed_at?: string
|
||||
changed_by?: string | null
|
||||
error_message?: string | null
|
||||
id?: string
|
||||
new_values?: Json | null
|
||||
old_values?: Json | null
|
||||
operation?: string
|
||||
record_id?: string
|
||||
table_name?: string
|
||||
validation_passed?: boolean
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
finished_product_inventory_records: {
|
||||
Row: {
|
||||
batch_no: string | null
|
||||
@ -581,6 +638,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "inventory_records_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "inventory_records_warehouse_id_fkey"
|
||||
columns: ["warehouse_id"]
|
||||
@ -636,6 +699,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "notifications_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "notifications_sender_company_id_fkey"
|
||||
columns: ["sender_company_id"]
|
||||
@ -694,6 +763,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "payments_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "payments_to_company_id_fkey"
|
||||
columns: ["to_company_id"]
|
||||
@ -737,6 +812,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "plan_factories_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
plan_process_steps: {
|
||||
@ -786,6 +867,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "plan_process_steps_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
product_inventory_records: {
|
||||
@ -1116,6 +1203,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "production_price_history_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
products: {
|
||||
@ -1645,6 +1738,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "yarn_ratios_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
yarn_stock: {
|
||||
@ -1749,6 +1848,12 @@ export type Database = {
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "yarn_stock_records_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "yarn_stock_records_yarn_stock_id_fkey"
|
||||
columns: ["yarn_stock_id"]
|
||||
@ -1759,7 +1864,103 @@ export type Database = {
|
||||
}
|
||||
}
|
||||
Views: {
|
||||
[_ in never]: never
|
||||
v_accounts_payable_check: {
|
||||
Row: {
|
||||
ap_id: string | null
|
||||
check_result: string | null
|
||||
items_paid: number | null
|
||||
items_total: number | null
|
||||
paid_amount: number | null
|
||||
paid_diff: number | null
|
||||
plan_id: string | null
|
||||
total_amount: number | null
|
||||
total_diff: number | null
|
||||
unpaid_amount: number | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "accounts_payable_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "accounts_payable_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
v_error_rate_monitor: {
|
||||
Row: {
|
||||
check_date: string | null
|
||||
error_rate_per_10k: number | null
|
||||
failed_operations: number | null
|
||||
table_name: string | null
|
||||
target_status: string | null
|
||||
total_operations: number | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
v_payment_check: {
|
||||
Row: {
|
||||
amount: number | null
|
||||
check_result: string | null
|
||||
difference: number | null
|
||||
expected_amount: number | null
|
||||
payment_id: string | null
|
||||
plan_id: string | null
|
||||
price_per_meter: number | null
|
||||
quantity: number | null
|
||||
}
|
||||
Insert: {
|
||||
amount?: number | null
|
||||
check_result?: never
|
||||
difference?: never
|
||||
expected_amount?: never
|
||||
payment_id?: string | null
|
||||
plan_id?: string | null
|
||||
price_per_meter?: number | null
|
||||
quantity?: number | null
|
||||
}
|
||||
Update: {
|
||||
amount?: number | null
|
||||
check_result?: never
|
||||
difference?: never
|
||||
expected_amount?: never
|
||||
payment_id?: string | null
|
||||
plan_id?: string | null
|
||||
price_per_meter?: number | null
|
||||
quantity?: number | null
|
||||
}
|
||||
Relationships: [
|
||||
{
|
||||
foreignKeyName: "payments_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "production_plans"
|
||||
referencedColumns: ["id"]
|
||||
},
|
||||
{
|
||||
foreignKeyName: "payments_plan_id_fkey"
|
||||
columns: ["plan_id"]
|
||||
referencedRelation: "v_plan_quantity_check"
|
||||
referencedColumns: ["plan_id"]
|
||||
},
|
||||
]
|
||||
}
|
||||
v_plan_quantity_check: {
|
||||
Row: {
|
||||
check_result: string | null
|
||||
completed_quantity: number | null
|
||||
difference: number | null
|
||||
inventory_total: number | null
|
||||
plan_code: string | null
|
||||
plan_id: string | null
|
||||
status: Database["public"]["Enums"]["plan_status"] | null
|
||||
}
|
||||
Relationships: []
|
||||
}
|
||||
}
|
||||
Functions: {
|
||||
binary_quantize: {
|
||||
@ -1850,6 +2051,16 @@ export type Database = {
|
||||
Args: { "": string }
|
||||
Returns: number
|
||||
}
|
||||
run_data_integrity_check: {
|
||||
Args: Record<PropertyKey, never>
|
||||
Returns: {
|
||||
check_type: string
|
||||
total_records: number
|
||||
inconsistent_records: number
|
||||
error_rate: number
|
||||
status: string
|
||||
}[]
|
||||
}
|
||||
sparsevec_out: {
|
||||
Args: { "": unknown }
|
||||
Returns: unknown
|
||||
|
||||
@ -1,19 +1,101 @@
|
||||
/**
|
||||
* 公共常量 - 从统一配置中心导入并重新导出
|
||||
* 保持向后兼容,所有旧引用无需修改
|
||||
*/
|
||||
|
||||
import {
|
||||
ANIMATION_CONFIG,
|
||||
PAGINATION_CONFIG,
|
||||
PROGRESS_THRESHOLDS,
|
||||
} from '../config/app';
|
||||
|
||||
// ==================== 状态映射配置 ====================
|
||||
|
||||
/** 计划状态枚举值(消除散落的字符串字面量) */
|
||||
export const PLAN_STATUS = {
|
||||
PENDING: 'pending',
|
||||
PRODUCING: 'producing',
|
||||
COMPLETED: 'completed',
|
||||
REJECTED: 'rejected',
|
||||
} as const;
|
||||
|
||||
/** 流程步骤状态枚举值 */
|
||||
export const STEP_STATUS = {
|
||||
PENDING: 'pending',
|
||||
ACTIVE: 'active',
|
||||
COMPLETED: 'completed',
|
||||
REJECTED: 'rejected',
|
||||
} as const;
|
||||
|
||||
/** 工厂类型枚举值 */
|
||||
export const FACTORY_TYPE = {
|
||||
TEXTILE: 'textile',
|
||||
WASHING: 'washing',
|
||||
} as const;
|
||||
|
||||
/** 仓库类型枚举值 */
|
||||
export const WAREHOUSE_TYPE = {
|
||||
RAW_FABRIC: 'raw_fabric',
|
||||
FABRIC: 'fabric',
|
||||
FINISHED: 'finished',
|
||||
YARN: 'yarn',
|
||||
} as const;
|
||||
|
||||
/** 结款状态枚举值 */
|
||||
export const PAYMENT_STATUS = {
|
||||
PENDING: 'pending',
|
||||
COMPLETED: 'completed',
|
||||
} as const;
|
||||
|
||||
/** 分享链接状态枚举值 */
|
||||
export const SHARE_LINK_STATUS = {
|
||||
PENDING: 'pending',
|
||||
CLICKED: 'clicked',
|
||||
CONFIRMED: 'confirmed',
|
||||
REJECTED: 'rejected',
|
||||
EXPIRED: 'expired',
|
||||
CANCELLED: 'cancelled',
|
||||
} as const;
|
||||
|
||||
/** 水洗计划状态枚举值 */
|
||||
export const WASHING_PLAN_STATUS = {
|
||||
PENDING: 'pending',
|
||||
IN_PROGRESS: 'in_progress',
|
||||
COMPLETED: 'completed',
|
||||
} as const;
|
||||
|
||||
/** 出入库记录类型 */
|
||||
export const INVENTORY_RECORD_TYPE = {
|
||||
IN: 'in',
|
||||
OUT: 'out',
|
||||
} as const;
|
||||
|
||||
/** 出库类型 */
|
||||
export const OUTBOUND_TYPE = {
|
||||
MANUAL: 'manual',
|
||||
AUTO: 'auto',
|
||||
} as const;
|
||||
|
||||
// ==================== UI 状态映射 ====================
|
||||
|
||||
// 状态映射配置
|
||||
export const statusMap: Record<string, { label: string; color: string; bgColor: string; borderColor: string }> = {
|
||||
pending: { label: '待确定', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' },
|
||||
producing: { label: '生产中', color: 'text-blue-700', bgColor: 'bg-blue-50', borderColor: 'border-blue-200' },
|
||||
completed: { label: '已完成', color: 'text-emerald-700', bgColor: 'bg-emerald-50', borderColor: 'border-emerald-200' },
|
||||
rejected: { label: '已拒绝', color: 'text-gray-700', bgColor: 'bg-gray-100', borderColor: 'border-gray-300' }
|
||||
[PLAN_STATUS.PENDING]: { label: '待确定', color: 'text-amber-700', bgColor: 'bg-amber-50', borderColor: 'border-amber-200' },
|
||||
[PLAN_STATUS.PRODUCING]: { label: '生产中', color: 'text-blue-700', bgColor: 'bg-blue-50', borderColor: 'border-blue-200' },
|
||||
[PLAN_STATUS.COMPLETED]: { label: '已完成', color: 'text-emerald-700', bgColor: 'bg-emerald-50', borderColor: 'border-emerald-200' },
|
||||
[PLAN_STATUS.REJECTED]: { label: '已拒绝', color: 'text-gray-700', bgColor: 'bg-gray-100', borderColor: 'border-gray-300' }
|
||||
};
|
||||
|
||||
// 简化的状态映射(用于纺织厂)
|
||||
export const simpleStatusMap: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '待确定', color: 'bg-yellow-100 text-yellow-700' },
|
||||
producing: { label: '生产中', color: 'bg-blue-100 text-blue-700' },
|
||||
completed: { label: '已完成', color: 'bg-green-100 text-green-700' },
|
||||
rejected: { label: '已拒绝', color: 'bg-gray-100 text-gray-600' }
|
||||
[PLAN_STATUS.PENDING]: { label: '待确定', color: 'bg-yellow-100 text-yellow-700' },
|
||||
[PLAN_STATUS.PRODUCING]: { label: '生产中', color: 'bg-blue-100 text-blue-700' },
|
||||
[PLAN_STATUS.COMPLETED]: { label: '已完成', color: 'bg-green-100 text-green-700' },
|
||||
[PLAN_STATUS.REJECTED]: { label: '已拒绝', color: 'bg-gray-100 text-gray-600' }
|
||||
};
|
||||
|
||||
// ==================== 步骤配置 ====================
|
||||
|
||||
// 步骤类型配置
|
||||
export const stepTypeConfig: Record<string, { name: string; icon: string }> = {
|
||||
confirm: { name: '确认计划', icon: 'Check' },
|
||||
@ -35,40 +117,19 @@ export const stepNames: Record<string, string> = {
|
||||
// 步骤顺序
|
||||
export const stepOrder = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
|
||||
|
||||
// ==================== 从配置中心导出的常量(向后兼容) ====================
|
||||
|
||||
// 动画配置
|
||||
export const animationConfig = {
|
||||
// 标准过渡 - 用于展开/收起,使用更平滑的缓动函数避免抖动
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
ease: [0.4, 0, 0.2, 1] // 使用 ease-out 缓动,更平滑
|
||||
},
|
||||
// 快速过渡
|
||||
fastTransition: {
|
||||
duration: 0.2,
|
||||
ease: [0.4, 0, 0.2, 1]
|
||||
},
|
||||
// 慢速过渡
|
||||
slowTransition: {
|
||||
duration: 0.4,
|
||||
ease: [0.4, 0, 0.2, 1]
|
||||
},
|
||||
// 弹簧动画 - 用于交互反馈,降低弹性避免抖动
|
||||
spring: {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 25,
|
||||
mass: 0.8
|
||||
}
|
||||
};
|
||||
export const animationConfig = ANIMATION_CONFIG;
|
||||
|
||||
// 分页配置
|
||||
export const paginationConfig = {
|
||||
defaultPageSize: 20,
|
||||
maxInventoryRecords: 100,
|
||||
maxPriceHistory: 50
|
||||
defaultPageSize: PAGINATION_CONFIG.defaultPageSize,
|
||||
maxInventoryRecords: PAGINATION_CONFIG.maxInventoryRecords,
|
||||
maxPriceHistory: PAGINATION_CONFIG.maxPriceHistory,
|
||||
};
|
||||
|
||||
// 进度阈值
|
||||
export const progressThresholds = {
|
||||
complete: 97 // 计划完成阈值
|
||||
complete: PROGRESS_THRESHOLDS.complete,
|
||||
};
|
||||
|
||||
410
src/utils/dataValidation.ts
Normal file
410
src/utils/dataValidation.ts
Normal file
@ -0,0 +1,410 @@
|
||||
/**
|
||||
* 数据完整性校验工具
|
||||
* 确保财务与核心数据100%正确
|
||||
*/
|
||||
|
||||
import { supabase } from '../supabase/client';
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface DataIntegrityReport {
|
||||
timestamp: string;
|
||||
checks: {
|
||||
planQuantity: CheckResult;
|
||||
paymentAmount: CheckResult;
|
||||
accountsPayable: CheckResult;
|
||||
};
|
||||
overallStatus: 'PASS' | 'FAIL';
|
||||
errorRate: number; // 万分比
|
||||
}
|
||||
|
||||
interface CheckResult {
|
||||
totalRecords: number;
|
||||
inconsistentRecords: number;
|
||||
errorRate: number;
|
||||
status: 'PASS' | 'FAIL';
|
||||
details?: any[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 前端校验函数(写入前预检)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 校验入库记录
|
||||
*/
|
||||
export function validateInventoryRecord(record: {
|
||||
quantity: number;
|
||||
rolls: number;
|
||||
price_per_meter?: number;
|
||||
}): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (record.quantity < 0) {
|
||||
errors.push('入库数量不能为负数');
|
||||
}
|
||||
if (record.quantity === 0) {
|
||||
warnings.push('入库数量为0,请确认是否正确');
|
||||
}
|
||||
if (record.rolls < 0) {
|
||||
errors.push('入库匹数不能为负数');
|
||||
}
|
||||
if (record.price_per_meter !== undefined && record.price_per_meter < 0) {
|
||||
errors.push('单价不能为负数');
|
||||
}
|
||||
if (record.price_per_meter !== undefined && record.price_per_meter > 1000) {
|
||||
warnings.push(`单价 ${record.price_per_meter} 元/米异常偏高,请确认`);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验付款记录
|
||||
*/
|
||||
export function validatePayment(payment: {
|
||||
amount: number;
|
||||
quantity: number;
|
||||
price_per_meter: number;
|
||||
}): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const tolerance = 0.01;
|
||||
|
||||
if (payment.amount < 0) {
|
||||
errors.push('付款金额不能为负数');
|
||||
}
|
||||
if (payment.quantity < 0) {
|
||||
errors.push('付款数量不能为负数');
|
||||
}
|
||||
if (payment.price_per_meter < 0) {
|
||||
errors.push('单价不能为负数');
|
||||
}
|
||||
|
||||
// 验证金额一致性
|
||||
const expectedAmount = payment.quantity * payment.price_per_meter;
|
||||
const difference = Math.abs(payment.amount - expectedAmount);
|
||||
|
||||
if (difference > tolerance) {
|
||||
errors.push(
|
||||
`付款金额不一致:期望 ${expectedAmount.toFixed(2)} 元 (数量 ${payment.quantity} × 单价 ${payment.price_per_meter}),实际 ${payment.amount.toFixed(2)} 元,差异 ${difference.toFixed(2)} 元`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验计划完成数量
|
||||
*/
|
||||
export function validatePlanCompletion(plan: {
|
||||
target_quantity: number;
|
||||
completed_quantity: number;
|
||||
}): ValidationResult {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (plan.completed_quantity < 0) {
|
||||
errors.push('完成数量不能为负数');
|
||||
}
|
||||
if (plan.completed_quantity > plan.target_quantity * 1.1) {
|
||||
warnings.push(
|
||||
`完成数量 (${plan.completed_quantity}) 超过目标数量 (${plan.target_quantity}) 的10%,请确认`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 数据库一致性检查(读取后验证)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检查计划数量一致性
|
||||
*/
|
||||
export async function checkPlanQuantityConsistency(planId: string): Promise<{
|
||||
isConsistent: boolean;
|
||||
completedQuantity: number;
|
||||
inventoryTotal: number;
|
||||
difference: number;
|
||||
}> {
|
||||
const { data: plan, error: planError } = await supabase
|
||||
.from('production_plans')
|
||||
.select('completed_quantity')
|
||||
.eq('id', planId)
|
||||
.single();
|
||||
|
||||
if (planError || !plan) {
|
||||
throw new Error(`查询计划失败: ${planError?.message}`);
|
||||
}
|
||||
|
||||
const { data: inventorySum, error: sumError } = await supabase
|
||||
.from('inventory_records')
|
||||
.select('quantity')
|
||||
.eq('plan_id', planId);
|
||||
|
||||
if (sumError) {
|
||||
throw new Error(`查询入库记录失败: ${sumError.message}`);
|
||||
}
|
||||
|
||||
const inventoryTotal = inventorySum?.reduce((sum, r) => sum + (r.quantity || 0), 0) || 0;
|
||||
const difference = plan.completed_quantity - inventoryTotal;
|
||||
const tolerance = 0.01;
|
||||
|
||||
return {
|
||||
isConsistent: Math.abs(difference) <= tolerance,
|
||||
completedQuantity: plan.completed_quantity,
|
||||
inventoryTotal,
|
||||
difference,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运行完整的数据完整性检查
|
||||
*/
|
||||
export async function runFullDataIntegrityCheck(): Promise<DataIntegrityReport> {
|
||||
const report: DataIntegrityReport = {
|
||||
timestamp: new Date().toISOString(),
|
||||
checks: {
|
||||
planQuantity: { totalRecords: 0, inconsistentRecords: 0, errorRate: 0, status: 'PASS' },
|
||||
paymentAmount: { totalRecords: 0, inconsistentRecords: 0, errorRate: 0, status: 'PASS' },
|
||||
accountsPayable: { totalRecords: 0, inconsistentRecords: 0, errorRate: 0, status: 'PASS' },
|
||||
},
|
||||
overallStatus: 'PASS',
|
||||
errorRate: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// 调用数据库函数进行检查
|
||||
const { data, error } = await supabase.rpc('run_data_integrity_check');
|
||||
|
||||
if (error) {
|
||||
console.error('数据完整性检查失败:', error);
|
||||
report.overallStatus = 'FAIL';
|
||||
return report;
|
||||
}
|
||||
|
||||
if (data && Array.isArray(data)) {
|
||||
for (const check of data) {
|
||||
switch (check.check_type) {
|
||||
case 'plan_quantity': {
|
||||
const pqTotal = check.total_records ?? 0;
|
||||
const pqInconsistent = check.inconsistent_records ?? 0;
|
||||
report.checks.planQuantity = {
|
||||
totalRecords: pqTotal,
|
||||
inconsistentRecords: pqInconsistent,
|
||||
errorRate: pqTotal > 0 ? (Number(check.error_rate) || 0) : 0,
|
||||
status: pqTotal === 0 ? 'PASS' : (pqInconsistent > 0 ? 'FAIL' : 'PASS'),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'payment_amount': {
|
||||
const paTotal = check.total_records ?? 0;
|
||||
const paInconsistent = check.inconsistent_records ?? 0;
|
||||
report.checks.paymentAmount = {
|
||||
totalRecords: paTotal,
|
||||
inconsistentRecords: paInconsistent,
|
||||
errorRate: paTotal > 0 ? (Number(check.error_rate) || 0) : 0,
|
||||
status: paTotal === 0 ? 'PASS' : (paInconsistent > 0 ? 'FAIL' : 'PASS'),
|
||||
};
|
||||
break;
|
||||
}
|
||||
case 'accounts_payable': {
|
||||
const apTotal = check.total_records ?? 0;
|
||||
const apInconsistent = check.inconsistent_records ?? 0;
|
||||
report.checks.accountsPayable = {
|
||||
totalRecords: apTotal,
|
||||
inconsistentRecords: apInconsistent,
|
||||
errorRate: apTotal > 0 ? (Number(check.error_rate) || 0) : 0,
|
||||
status: apTotal === 0 ? 'PASS' : (apInconsistent > 0 ? 'FAIL' : 'PASS'),
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算总体错误率
|
||||
const totalRecords =
|
||||
report.checks.planQuantity.totalRecords +
|
||||
report.checks.paymentAmount.totalRecords +
|
||||
report.checks.accountsPayable.totalRecords;
|
||||
|
||||
const totalInconsistent =
|
||||
report.checks.planQuantity.inconsistentRecords +
|
||||
report.checks.paymentAmount.inconsistentRecords +
|
||||
report.checks.accountsPayable.inconsistentRecords;
|
||||
|
||||
report.errorRate = totalRecords > 0
|
||||
? (totalInconsistent / totalRecords) * 10000
|
||||
: 0;
|
||||
|
||||
report.overallStatus = report.errorRate <= 1 ? 'PASS' : 'FAIL';
|
||||
|
||||
} catch (err) {
|
||||
console.error('数据完整性检查异常:', err);
|
||||
report.overallStatus = 'FAIL';
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 审计日志查询
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 获取最近的审计日志
|
||||
*/
|
||||
export async function getRecentAuditLogs(limit: number = 100) {
|
||||
const { data, error } = await supabase
|
||||
.from('data_audit_logs')
|
||||
.select('*')
|
||||
.order('changed_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`查询审计日志失败: ${error.message}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取失败的验证记录
|
||||
*/
|
||||
export async function getFailedValidations(limit: number = 50) {
|
||||
const { data, error } = await supabase
|
||||
.from('data_audit_logs')
|
||||
.select('*')
|
||||
.eq('validation_passed', false)
|
||||
.order('changed_at', { ascending: false })
|
||||
.limit(limit);
|
||||
|
||||
if (error) {
|
||||
throw new Error(`查询失败记录失败: ${error.message}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误率统计
|
||||
*/
|
||||
export async function getErrorRateStats(days: number = 30) {
|
||||
const { data, error } = await supabase
|
||||
.from('v_error_rate_monitor')
|
||||
.select('*')
|
||||
.gte('check_date', new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString())
|
||||
.order('check_date', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
throw new Error(`查询错误率统计失败: ${error.message}`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 安全写入包装器
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 安全插入入库记录(带双重校验)
|
||||
*/
|
||||
export async function safeInsertInventoryRecord(record: {
|
||||
plan_id: string;
|
||||
warehouse_id: string;
|
||||
quantity: number;
|
||||
rolls: number;
|
||||
company_id: string;
|
||||
operator_id: string;
|
||||
price_per_meter?: number;
|
||||
}) {
|
||||
// 第一层:前端校验
|
||||
const validation = validateInventoryRecord(record);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// 第二层:数据库写入(触发器会进行二次校验)
|
||||
const { data, error } = await supabase
|
||||
.from('inventory_records')
|
||||
.insert(record)
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
throw new Error(`写入失败: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error('写入失败:可能被 RLS 策略拦截');
|
||||
}
|
||||
|
||||
// 第三层:写入后验证
|
||||
const consistencyCheck = await checkPlanQuantityConsistency(record.plan_id);
|
||||
if (!consistencyCheck.isConsistent) {
|
||||
console.warn(
|
||||
`警告:入库后数据不一致,计划 ${record.plan_id} 完成数量 ${consistencyCheck.completedQuantity},入库总和 ${consistencyCheck.inventoryTotal}`
|
||||
);
|
||||
}
|
||||
|
||||
return data[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全插入付款记录(带双重校验)
|
||||
*/
|
||||
export async function safeInsertPayment(payment: {
|
||||
plan_id: string;
|
||||
from_company_id: string;
|
||||
to_company_id: string;
|
||||
amount: number;
|
||||
quantity: number;
|
||||
price_per_meter: number;
|
||||
status: 'pending' | 'completed';
|
||||
}) {
|
||||
// 第一层:前端校验
|
||||
const validation = validatePayment(payment);
|
||||
if (!validation.isValid) {
|
||||
throw new Error(`数据校验失败: ${validation.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// 第二层:数据库写入
|
||||
const { data, error } = await supabase
|
||||
.from('payments')
|
||||
.insert(payment)
|
||||
.select();
|
||||
|
||||
if (error) {
|
||||
throw new Error(`写入失败: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
throw new Error('写入失败:可能被 RLS 策略拦截');
|
||||
}
|
||||
|
||||
return data[0];
|
||||
}
|
||||
137
src/utils/planCalculations.ts
Normal file
137
src/utils/planCalculations.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import type { ProductionPlan, PlanProcessStep, YarnRatio } from '../types';
|
||||
|
||||
interface InventoryRecord {
|
||||
plan_id: string;
|
||||
created_at: string;
|
||||
quantity: number;
|
||||
rolls: number | null;
|
||||
}
|
||||
|
||||
interface Product {
|
||||
fabric_code: string;
|
||||
company_id: string;
|
||||
image_url?: string | null;
|
||||
warp_weight?: number | null;
|
||||
weft_weight?: number | null;
|
||||
total_weight?: number | null;
|
||||
weight?: number | null;
|
||||
}
|
||||
|
||||
export interface PlanGroup {
|
||||
purchaserId: string;
|
||||
purchaserName: string;
|
||||
plans: (ProductionPlan & {
|
||||
processSteps: PlanProcessStep[];
|
||||
inventoryRecords: { time: string; quantity: number; rolls: number | null }[];
|
||||
yarnRatios?: YarnRatio[];
|
||||
image_url?: string | null;
|
||||
warp_weight?: number | null;
|
||||
weft_weight?: number | null;
|
||||
total_weight?: number | null;
|
||||
weight?: number | null;
|
||||
})[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 按采购商分组计划数据(纯函数,可缓存)
|
||||
*/
|
||||
export function groupPlansByPurchaser(
|
||||
planData: ProductionPlan[],
|
||||
stepsData: PlanProcessStep[] | null,
|
||||
inventoryData: InventoryRecord[] | null,
|
||||
yarnRatiosData: YarnRatio[] | null,
|
||||
productsData: Product[] | null,
|
||||
companyMap: Record<string, string>
|
||||
): PlanGroup[] {
|
||||
const grouped: Record<string, PlanGroup> = {};
|
||||
|
||||
planData.forEach(plan => {
|
||||
const pid = plan.purchaser_id;
|
||||
if (!grouped[pid]) {
|
||||
grouped[pid] = {
|
||||
purchaserId: pid,
|
||||
purchaserName: companyMap[pid] || '未知布行',
|
||||
plans: []
|
||||
};
|
||||
}
|
||||
|
||||
const processSteps = (stepsData || [])
|
||||
.filter(s => s.plan_id === plan.id)
|
||||
.sort((a, b) => {
|
||||
const order = ['confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse'];
|
||||
return order.indexOf(a.step_type) - order.indexOf(b.step_type);
|
||||
});
|
||||
|
||||
const inventoryRecords = (inventoryData || [])
|
||||
.filter(r => r.plan_id === plan.id)
|
||||
.map(r => ({ time: r.created_at, quantity: r.quantity, rolls: r.rolls }));
|
||||
|
||||
const yarnRatios = (yarnRatiosData || [])
|
||||
.filter(y => y.plan_id === plan.id)
|
||||
.map(y => ({
|
||||
id: y.id,
|
||||
plan_id: y.plan_id,
|
||||
yarn_name: y.yarn_name,
|
||||
ratio: y.ratio,
|
||||
amount_per_meter: y.amount_per_meter,
|
||||
total_amount: y.total_amount,
|
||||
yarn_type: y.yarn_type,
|
||||
created_at: y.created_at,
|
||||
company_id: y.company_id
|
||||
}));
|
||||
|
||||
// 查找关联的产品信息
|
||||
const product = (productsData || []).find(p =>
|
||||
p.fabric_code === plan.fabric_code && p.company_id === plan.purchaser_id
|
||||
);
|
||||
|
||||
grouped[pid].plans.push({
|
||||
...plan,
|
||||
processSteps,
|
||||
inventoryRecords,
|
||||
yarnRatios,
|
||||
image_url: product?.image_url || null,
|
||||
warp_weight: product?.warp_weight || null,
|
||||
weft_weight: product?.weft_weight || null,
|
||||
total_weight: product?.total_weight || null,
|
||||
weight: product?.weight || null
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(grouped);
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤被拒绝的计划(纯函数,可缓存)
|
||||
*/
|
||||
export function filterRejectedPlans<T extends { id: string }>(
|
||||
plans: T[],
|
||||
rejectedPlanIds: Set<string>
|
||||
): T[] {
|
||||
return plans.filter(p => !rejectedPlanIds.has(p.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按计划状态过滤(纯函数,可缓存)
|
||||
*/
|
||||
export function filterPlansByStatus<T extends { status: string }>(
|
||||
plans: T[],
|
||||
statusFilter: string | null
|
||||
): T[] {
|
||||
if (!statusFilter || statusFilter === 'all') {
|
||||
return plans;
|
||||
}
|
||||
return plans.filter(p => p.status === statusFilter);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算计划统计信息(纯函数,可缓存)
|
||||
*/
|
||||
export function calculatePlanStats<T extends { status: string }>(plans: T[]) {
|
||||
return {
|
||||
all: plans.length,
|
||||
pending: plans.filter(p => p.status === 'pending').length,
|
||||
producing: plans.filter(p => p.status === 'producing').length,
|
||||
completed: plans.filter(p => p.status === 'completed').length
|
||||
};
|
||||
}
|
||||
24
test-results/.last-run.json
Normal file
24
test-results/.last-run.json
Normal file
@ -0,0 +1,24 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"64b97c00ddae72b6d931-44eff1d20bb5144ecf55",
|
||||
"64b97c00ddae72b6d931-505374953e40e8415eed",
|
||||
"64b97c00ddae72b6d931-12af60bd65189d4bb622",
|
||||
"64b97c00ddae72b6d931-0ce8a47738128e38c6d4",
|
||||
"64b97c00ddae72b6d931-56dde2097a1863063041",
|
||||
"64b97c00ddae72b6d931-f01ec120216f90d75b11",
|
||||
"64b97c00ddae72b6d931-aa6bc907139c8f6e02fb",
|
||||
"64b97c00ddae72b6d931-68efd5000f021f4d77fc",
|
||||
"64b97c00ddae72b6d931-ff4f95dc39efcca6237f",
|
||||
"64b97c00ddae72b6d931-b54d075600ba69e756ef",
|
||||
"64b97c00ddae72b6d931-914108ae940011e0e38c",
|
||||
"64b97c00ddae72b6d931-e123d08c361300536365",
|
||||
"64b97c00ddae72b6d931-d4712d5b8b6a105937f8",
|
||||
"64b97c00ddae72b6d931-400802d355c30dfb375c",
|
||||
"64b97c00ddae72b6d931-77a6e0c3ec7c80d00cdf",
|
||||
"64b97c00ddae72b6d931-060cb4e6ca34aa745d21",
|
||||
"64b97c00ddae72b6d931-0028cc68f4e6f35ba4cb",
|
||||
"64b97c00ddae72b6d931-38c229c97b07b52e04df",
|
||||
"64b97c00ddae72b6d931-07396ebf04da3dac2f50"
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段三:纺织厂生产流程 >> 3.2 查看计划总览和生产进度
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:137:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段四:分批次入库 >> 4.2 执行入库操作
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:184:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段一:采购商创建计划 >> 1.2 创建产品信息
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:59:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段三:纺织厂生产流程 >> 3.3 确认生产流程节点
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:151:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段二:分享链接与首次合作 >> 2.2 纺织厂通过链接导入计划
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:113:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段二:分享链接与首次合作 >> 2.1 进入计划总览并分享
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:99:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段五:数据验证 >> 5.2 采购商验证成品仓库
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:216:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段一:采购商创建计划 >> 1.3 新建纺织计划
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:73:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段五:数据验证 >> 5.1 采购商验证计划完成状态
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:202:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段三:纺织厂生产流程 >> 3.1 纺织厂登录并查看计划
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:129:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段四:分批次入库 >> 4.1 纺织厂进入坯布仓库
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:170:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段一:采购商创建计划 >> 1.1 采购商登录并进入工作台
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:47:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 全流程模拟:采购商下单到纺织厂入库 >> 阶段五:数据验证 >> 5.3 纺织厂验证待结款
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:225:9
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 多角色协作实时同步测试 >> 采购商和纺织厂双窗口实时同步
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:238:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 性能测试 >> 资源加载无严重错误
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:424:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 性能测试 >> 页面加载时间
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:409:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 边界情况和异常处理 >> 无效 token 导入计划
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:379:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 边界情况和异常处理 >> 未登录用户访问受保护页面
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:370:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -0,0 +1,24 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: full-workflow-simulation.spec.ts >> 边界情况和异常处理 >> 网络断开时的离线提示
|
||||
- Location: e2e/full-workflow-simulation.spec.ts:390:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: browserType.launch: Executable doesn't exist at /root/.cache/ms-playwright/chromium_headless_shell-1223/chrome-headless-shell-linux64/chrome-headless-shell
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Looks like Playwright was just installed or updated. ║
|
||||
║ Please run the following command to download new browsers: ║
|
||||
║ ║
|
||||
║ npx playwright install ║
|
||||
║ ║
|
||||
║ <3 Playwright Team ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
```
|
||||
@ -3,6 +3,9 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const webpack = require('webpack');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
|
||||
// 端口从环境变量读取,默认 3015(沙箱固定端口)
|
||||
const DEV_PORT = parseInt(process.env.DEV_PORT || '3015', 10);
|
||||
|
||||
module.exports = (env, argv) => {
|
||||
const isDev = argv.mode !== 'production';
|
||||
|
||||
@ -86,7 +89,7 @@ module.exports = (env, argv) => {
|
||||
extensions: ['.mjs', '.ts', '.tsx', '.js', '.jsx']
|
||||
},
|
||||
devServer: {
|
||||
port: 3015,
|
||||
port: DEV_PORT,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: 'all',
|
||||
hot: true,
|
||||
@ -101,6 +104,7 @@ module.exports = (env, argv) => {
|
||||
'process.env.NODE_ENV': JSON.stringify(isDev ? 'development' : 'production'),
|
||||
'process.env.VITE_SUPABASE_URL': JSON.stringify(process.env.VITE_SUPABASE_URL || ''),
|
||||
'process.env.VITE_SUPABASE_ANON_KEY': JSON.stringify(process.env.VITE_SUPABASE_ANON_KEY || ''),
|
||||
'__APP_ENV__': JSON.stringify(isDev ? 'development' : 'production'),
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user