Initial commit: ERP system with advance verification fixes

This commit is contained in:
System Administrator
2026-03-25 23:55:36 +07:00
commit 563ca12d76
5920 changed files with 828689 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Dependencies
node_modules/
*/node_modules/
# Database files
*.db
*.sqlite
*.sqlite3
# Environment files
.env
.env.local
.env.*.local
# Build files
dist/
build/
# Backup files
backups/
# IDE files
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Temporary files
temp/
tmp/
+103
View File
@@ -0,0 +1,103 @@
# 跨平台ERP系统 - 验证清单
## 系统架构与技术栈
- [ ] 系统架构设计文档完整
- [ ] 技术栈选择合理,支持跨平台
- [ ] API接口规范设计完整
## 核心数据模型
- [ ] 客户、供应商、分包商数据模型设计完整
- [ ] 项目、合同、付款节点数据模型设计完整
- [ ] 财务、商品数据模型设计完整
- [ ] 汇率管理数据模型设计完整
- [ ] 数据模型之间的关系设计正确
## 用户认证与权限管理
- [ ] 用户注册、登录功能正常
- [ ] 角色权限体系设计合理
- [ ] 基于角色的访问控制有效
- [ ] 密码加密存储安全
## 客户、供应商、分包商管理
- [ ] 客户信息CRUD操作正常
- [ ] 供应商信息CRUD操作正常
- [ ] 分包商信息CRUD操作正常
- [ ] 交易记录和项目历史显示正确
- [ ] 交易总金额、已支付金额、应收/应付金额计算准确
## 项目管理
- [ ] 项目创建、编辑功能正常
- [ ] 合同管理功能正常,支持上传合同文件
- [ ] 付款节点设置和管理功能正常
- [ ] 工期计算功能准确(天数与日期互算)
- [ ] 项目状态跟踪功能正常
- [ ] 支持总价包干和单价结算两种方式
## 分包管理
- [ ] 分包创建功能正常,可选择项目和分包商
- [ ] 支持总价包干和单价结算方式
- [ ] 支持上传分包合同
- [ ] 分包完成后自动更新项目详情
## 采购管理
- [ ] 采购创建功能正常,可选择项目和供应商
- [ ] 支持选择老挝本地或中国供应商
- [ ] 录入采购商品信息功能正常
- [ ] 支持中国供应商的预计发货日期
- [ ] 支持上传采购合同和设置付款节点
- [ ] 采购完成后自动更新项目详情
## 财务管理
- [ ] 员工个人记账功能正常
- [ ] 公司账目管理功能正常
- [ ] 预支、报销、核销流程正常
- [ ] 申请、审核、打款流程正常
- [ ] 支持上传凭证
- [ ] 财务数据自动更新准确
- [ ] 支持公司支出和项目支出分类
- [ ] 财务串通管理功能正常,所有收入支出既能从单个项目查看,又能从公司总账目中查看
## 施工管理
- [ ] 施工过程资料上传功能正常(图片、日期、描述)
- [ ] 施工进度跟踪功能正常
- [ ] 付款申请功能正常,支持上传凭证
- [ ] 项目状态更新功能正常(已带电、质保期、已完结)
- [ ] 质保金管理和提醒功能正常
## 汇率管理
- [ ] 四种币种(人民币、老挝基普、泰铢、美元)的汇率设置功能正常
- [ ] 支持双向汇率输入
- [ ] 金额自动换算功能准确
- [ ] 币种符号和千分位显示正确
- [ ] 所有涉及金额的地方都支持多币种选择
## 商品管理
- [ ] 商品分类管理功能正常
- [ ] 商品信息管理功能正常
- [ ] 成本价自动核算功能准确
- [ ] 项目材料管理功能正常,包括采购材料、实际用料、退库材料
## 预算报价管理
- [ ] 预算报价创建和修改功能正常
- [ ] 报价模板管理功能正常
- [ ] 项目达成率跟踪功能正常
- [ ] 支持同一客户多个项目的管理
- [ ] 支持报价中单个同型号材料价差控制
## 系统接口预留
- [ ] 小工具接口(如电工计算器)预留完整
- [ ] 与后续进销存系统的集成接口预留完整
- [ ] 接口设计合理,支持后续扩展
## 系统测试与优化
- [ ] 所有功能测试通过
- [ ] 系统响应时间不超过2秒
- [ ] 安全测试通过
- [ ] 用户体验测试良好
- [ ] 跨平台兼容性测试通过
## 文档与部署
- [ ] 系统文档完整
- [ ] 部署指南完整
- [ ] 测试报告完整
- [ ] 维护指南完整
+180
View File
@@ -0,0 +1,180 @@
# 跨平台ERP系统 - 产品需求文档
## 概述
- **Summary**: 一个跨平台的ERP系统,以财务串通管理为核心,包含客户管理、供应商管理、分包商管理、项目管理、财务管理、施工管理、商品管理等功能,支持多币种汇率管理,实现所有收入支出既能从单个项目查看,又能从公司总账目中查看。
- **Purpose**: 解决企业在项目管理、财务管理、供应链管理等方面的效率问题,提供实时数据可视化,支持业务决策,特别强调财务数据的完整性和一致性。
- **Target Users**: 企业管理员、项目经理、财务人员、采购人员、施工人员等,初期由管理员兼任财务和采购工作。
## 目标
* 实现客户、供应商、分包商的全面管理
* 提供完整的项目管理功能,包括合同管理、付款节点设置、进度跟踪
* 实现财务管理,包括个人记账、公司账目、预支、报销、核销等功能
* 支持施工管理,包括进度跟踪、资料上传、付款申请等
* 实现多币种汇率管理,支持人民币、老挝基普、泰铢、美元
* 提供商品分类和管理功能,自动核算成本价
* 预留接口,支持后续扩展小工具
* 支持预算报价管理,跟踪项目达成率
## 非目标(超出范围)
* 不包含完整的HR管理功能
* 不包含复杂的生产制造管理
* 不包含CRM的市场营销功能
* 不包含完整的库存管理系统(后期加入进销存)
* 不包含高级BI分析功能
## 背景与上下文
* 公司目前没有专业的财务和采购人员,暂时由管理员替代
* 业务涉及跨境采购,需要多币种支持
* 项目涉及施工管理,需要进度跟踪和资料管理
* 未来计划加入进销存功能,需要预留接口
## 功能需求
* **FR-1**: 客户管理 - 支持客户信息录入、查询、编辑,显示客户过往项目记录和交易信息
* **FR-2**: 供应商管理 - 支持供应商信息录入、查询、编辑,显示供应商过往交易记录
* **FR-3**: 分包商管理 - 支持分包商信息录入、查询、编辑,显示分包商过往项目记录
* **FR-4**: 项目管理 - 支持新建项目、合同管理、付款节点设置、工期计算、项目状态跟踪
* **FR-5**: 分包管理 - 支持新建分包、选择分包商、设置结算方式、上传合同
* **FR-6**: 采购管理 - 支持新建采购、选择供应商、录入商品信息、设置付款节点、上传合同
* **FR-7**: 财务管理 - 支持员工个人记账、公司账目管理、预支、报销、核销流程
* **FR-8**: 施工管理 - 支持施工过程资料上传、进度跟踪、付款申请
* **FR-9**: 汇率管理 - 支持四种币种(人民币、老挝基普、泰铢、美元)的汇率设置和自动换算
* **FR-10**: 商品管理 - 支持商品分类、商品信息管理、成本价自动核算
* **FR-11**: 预算报价管理 - 支持预算报价创建、修改、模板管理、项目达成率跟踪
## 非功能需求
* **NFR-1**: 跨平台 - 支持Web端、移动端访问
* **NFR-2**: 性能 - 系统响应时间不超过2秒
* **NFR-3**: 安全 - 数据加密存储,角色权限控制
* **NFR-4**: 可扩展性 - 预留接口,支持后续功能扩展
* **NFR-5**: 易用性 - 界面友好,操作简单
## 约束
- **技术**: 采用PWA技术实现跨平台,支持Web端和移动端访问
- **业务**: 初期由管理员兼任财务和采购工作
- **依赖**: 后续需要接入进销存系统
## 假设
* 系统用户具备基本的计算机操作能力
* 网络环境稳定,支持系统正常运行
* 后续会有专业的财务和采购人员加入
## 验收标准
### AC-1: 客户管理功能
* **Given**: 系统已登录,用户具有客户管理权限
* **When**: 用户新建客户信息,录入客户详情
* **Then**: 系统保存客户信息,显示客户列表,支持客户详情查看,包括过往项目记录和交易信息
* **Verification**: `programmatic`
### AC-2: 项目管理功能
* **Given**: 系统已登录,用户具有项目管理权限
* **When**: 用户新建项目,录入合同信息、付款节点、工期要求
* **Then**: 系统保存项目信息,自动计算结束日期或天数,显示项目详情
* **Verification**: `programmatic`
### AC-3: 财务管理功能
* **Given**: 系统已登录,用户具有财务管理权限
* **When**: 用户提交预支、报销、核销申请
* **Then**: 系统按照流程进行审核、打款,自动更新财务数据
* **Verification**: `programmatic`
### AC-4: 施工管理功能
* **Given**: 系统已登录,用户具有施工管理权限
* **When**: 员工上传施工过程资料,申请付款
* **Then**: 系统保存资料,更新项目状态,处理付款申请
* **Verification**: `programmatic`
### AC-5: 汇率管理功能
* **Given**: 系统已登录,用户具有汇率管理权限
* **When**: 用户设置汇率,录入多币种金额
* **Then**: 系统自动换算等价人民币金额,显示对应币种符号和千分位
* **Verification**: `programmatic`
### AC-6: 商品管理功能
* **Given**: 系统已登录,用户具有商品管理权限
* **When**: 用户录入商品信息,记录进货费用
* **Then**: 系统自动核算成本价,更新商品信息
* **Verification**: `programmatic`
### AC-7: 预算报价管理功能
* **Given**: 系统已登录,用户具有预算报价权限
* **When**: 用户创建预算报价,修改报价,跟踪项目达成率
* **Then**: 系统保存报价信息,支持模板管理,显示项目达成率
* **Verification**: `programmatic`
## 未解决问题
* [ ] 系统的具体技术栈选择
* [ ] 数据备份和恢复策略
* [ ] 系统部署方式和环境要求
* [ ] 具体的权限管理方案
* [ ] 与后续进销存系统的集成方式
+392
View File
@@ -0,0 +1,392 @@
# 跨平台ERP系统 - 实现计划(分解和优先级任务列表)
## \[ ] 任务 1: 系统架构设计与技术栈选择
* **优先级**: P0
* **依赖**: 无
* **描述**:
* 设计系统架构,包括前端、后端、数据库结构
* 选择PWA技术栈实现跨平台,支持Web端和移动端
* 设计API接口规范
* **验收标准**: AC-1, AC-2, AC-3, AC-4, AC-5, AC-6, AC-7
* **测试要求**:
* `programmatic` TR-1.1: 系统架构文档完整,技术栈选择合理
* `human-judgement` TR-1.2: 架构设计符合系统需求,具有可扩展性
* **备注**: 需考虑后续进销存系统的集成需求,支持本地部署测试
## \[ ] 任务 2: 核心数据模型设计
* **优先级**: P0
* **依赖**: 任务 1
* **描述**:
* 设计客户、供应商、分包商数据模型
* 设计项目、合同、付款节点数据模型
* 设计财务、商品数据模型
* 设计汇率管理数据模型
* **验收标准**: AC-1, AC-2, AC-3, AC-5, AC-6
* **测试要求**:
* `programmatic` TR-2.1: 数据模型设计完整,字段定义合理
* `programmatic` TR-2.2: 数据模型之间的关系设计正确
* **备注**: 需考虑多币种支持和数据一致性
## \[ ] 任务 3: 用户认证与权限管理系统
* **优先级**: P0
* **依赖**: 任务 1
* **描述**:
* 实现用户注册、登录功能
* 设计角色权限体系
* 实现基于角色的访问控制
* 实现可编辑的权限管理和流程节点管理,在后台管理页面上进行配置
* **验收标准**: AC-1, AC-2, AC-3, AC-4, AC-6, AC-7
* **测试要求**:
* `programmatic` TR-3.1: 用户认证功能正常,密码加密存储
* `programmatic` TR-3.2: 权限控制有效,不同角色只能访问授权资源
* `programmatic` TR-3.3: 权限管理和流程节点管理功能正常
* **备注**: 初期由管理员兼任财务和采购工作,需预留权限分发功能
## \[ ] 任务 4: 客户、供应商、分包商管理模块
* **优先级**: P1
* **依赖**: 任务 2, 任务 3
* **描述**:
* 实现客户信息录入、查询、编辑功能
* 实现供应商信息录入、查询、编辑功能
* 实现分包商信息录入、查询、编辑功能
* 显示客户、供应商、分包商的交易记录和项目历史
* **验收标准**: AC-1
* **测试要求**:
* `programmatic` TR-4.1: 客户、供应商、分包商信息CRUD操作正常
* `programmatic` TR-4.2: 交易记录和项目历史显示正确
* **备注**: 需显示交易总金额、已支付金额、应收/应付金额
## \[ ] 任务 5: 项目管理模块
* **优先级**: P1
* **依赖**: 任务 2, 任务 3
* **描述**:
* 实现项目创建、编辑功能
* 实现合同管理,支持上传合同文件
* 实现付款节点设置和管理
* 实现工期计算功能(天数与日期互算)
* 实现项目状态跟踪
* **验收标准**: AC-2
* **测试要求**:
* `programmatic` TR-5.1: 项目创建和编辑功能正常
* `programmatic` TR-5.2: 工期计算准确,付款节点设置有效
* `programmatic` TR-5.3: 项目状态更新正确
* **备注**: 需支持总价包干和单价结算两种方式
## \[ ] 任务 6: 分包管理模块
* **优先级**: P1
* **依赖**: 任务 2, 任务 3, 任务 5
* **描述**:
* 实现分包创建功能,选择项目和分包商
* 支持总价包干和单价结算方式
* 支持上传分包合同
* 实现分包完成后自动更新项目详情
* **验收标准**: AC-2
* **测试要求**:
* `programmatic` TR-6.1: 分包创建功能正常
* `programmatic` TR-6.2: 分包完成后项目详情更新正确
* **备注**: 需与项目管理模块集成
## \[ ] 任务 7: 采购管理模块
* **优先级**: P1
* **依赖**: 任务 2, 任务 3, 任务 5
* **描述**:
* 实现采购创建功能,选择项目和供应商
* 支持选择老挝本地或中国供应商
* 录入采购商品信息,支持中国供应商的预计发货日期
* 支持上传采购合同和设置付款节点
* 实现采购完成后自动更新项目详情
* **验收标准**: AC-2
* **测试要求**:
* `programmatic` TR-7.1: 采购创建功能正常
* `programmatic` TR-7.2: 采购完成后项目详情更新正确
* **备注**: 需与项目管理模块集成
## \[ ] 任务 8: 财务管理模块
* **优先级**: P1
* **依赖**: 任务 2, 任务 3, 任务 5
* **描述**:
* 实现员工个人记账功能
* 实现公司账目管理
* 实现预支、报销、核销流程
* 实现申请、审核、打款流程
* 支持上传凭证
* 实现财务数据自动更新
* 实现财务串通管理,确保所有收入支出既能从单个项目查看,又能从公司总账目中查看
* **验收标准**: AC-3
* **测试要求**:
* `programmatic` TR-8.1: 财务流程操作正常
* `programmatic` TR-8.2: 财务数据更新准确
* `programmatic` TR-8.3: 凭证上传功能正常
* `programmatic` TR-8.4: 财务串通管理功能正常,数据一致性良好
* **备注**: 需支持公司支出和项目支出分类
## \[ ] 任务 9: 施工管理模块
* **优先级**: P1
* **依赖**: 任务 2, 任务 3, 任务 5
* **描述**:
* 实现施工过程资料上传功能(图片、日期、描述)
* 实现施工进度跟踪
* 实现付款申请功能,支持上传凭证
* 实现项目状态更新(已带电、质保期、已完结)
* 实现质保金管理和提醒
* **验收标准**: AC-4
* **测试要求**:
* `programmatic` TR-9.1: 施工资料上传功能正常
* `programmatic` TR-9.2: 项目状态更新正确
* `programmatic` TR-9.3: 质保金管理功能正常
* **备注**: 需支持施工过程中的付款节点申请
## \[ ] 任务 10: 汇率管理模块
* **优先级**: P1
* **依赖**: 任务 2, 任务 3
* **描述**:
* 实现四种币种(人民币、老挝基普、泰铢、美元)的汇率设置
* 支持双向汇率输入
* 实现金额自动换算功能
* 实现币种符号和千分位显示
* **验收标准**: AC-5
* **测试要求**:
* `programmatic` TR-10.1: 汇率设置功能正常
* `programmatic` TR-10.2: 金额换算准确
* `programmatic` TR-10.3: 币种符号和千分位显示正确
* **备注**: 所有涉及金额的地方都需支持多币种选择
## \[ ] 任务 11: 商品管理模块
* **优先级**: P2
* **依赖**: 任务 2, 任务 3
* **描述**:
* 实现商品分类管理
* 实现商品信息管理
* 实现成本价自动核算功能
* 实现项目材料管理,包括采购材料、实际用料、退库材料
* **验收标准**: AC-6
* **测试要求**:
* `programmatic` TR-11.1: 商品分类和信息管理功能正常
* `programmatic` TR-11.2: 成本价核算准确
* `programmatic` TR-11.3: 项目材料管理功能正常
* **备注**: 需根据进货费用和物流清关费用自动核算成本价
## \[ ] 任务 12: 预算报价管理模块
* **优先级**: P2
* **依赖**: 任务 2, 任务 3
* **描述**:
* 实现预算报价创建和修改功能
* 实现报价模板管理
* 实现项目达成率跟踪
* 支持同一客户多个项目的管理
* **验收标准**: AC-7
* **测试要求**:
* `programmatic` TR-12.1: 预算报价创建和修改功能正常
* `programmatic` TR-12.2: 项目达成率计算准确
* `programmatic` TR-12.3: 模板管理功能正常
* **备注**: 需支持报价中单个同型号材料价差控制
## \[ ] 任务 13: 系统接口预留
* **优先级**: P2
* **依赖**: 任务 1
* **描述**:
* 预留小工具接口(如电工计算器)
* 预留与后续进销存系统的集成接口
* **验收标准**: NFR-4
* **测试要求**:
* `programmatic` TR-13.1: 接口设计文档完整
* `human-judgement` TR-13.2: 接口设计合理,支持后续扩展
* **备注**: 需考虑接口的安全性和可扩展性
## \[ ] 任务 14: 系统测试与优化
* **优先级**: P0
* **依赖**: 所有任务
* **描述**:
* 进行系统功能测试
* 进行性能测试和优化
* 进行安全测试
* 进行用户体验测试
* **验收标准**: NFR-1, NFR-2, NFR-3, NFR-5
* **测试要求**:
* `programmatic` TR-14.1: 所有功能测试通过
* `programmatic` TR-14.2: 系统响应时间不超过2秒
* `programmatic` TR-14.3: 安全测试通过
* `human-judgement` TR-14.4: 用户体验良好
* **备注**: 需确保跨平台兼容性
@@ -0,0 +1,952 @@
# 项目管理完整方案 V5
> 更新时间: 2026-03-18
> 版本: V5
---
## 一、系统模块总览
```
┌─────────────────────────────────────────────────────────────────┐
│ ERP系统 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────────┐ ┌────────────────┐ │
│ │ │ 签约成功 │ │ │
│ │ 预算报价管理 │ ────────────────> │ 项目管理 │ │
│ │ (签约前) │ │ (签约后) │ │
│ │ │ <──────────────── │ │ │
│ └──────────────────────┘ 数据关联(隐藏) └────────────────┘ │
│ │
│ 菜单入口: │
│ ├─ 预算报价管理 │
│ │ └─ 项目商谈、报价、签约 │
│ │ │
│ ├─ 项目管理 │
│ │ └─ 合同执行、施工、结算 │
│ │ │
│ └─ 施工管理(员工入口) │
│ └─ 施工日志、照片上传 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 二、预算报价管理(签约前)
### 2.1 列表页设计
```
┌─────────────────────────────────────────────────────────────────┐
│ 预算报价管理 [新建商谈项目] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 状态筛选: [全部] [商谈中] [已签约] [未签约] │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ▼ 博纳斯一车间到三车间22kV线路项目 商谈中 5天 │ │
│ │ 客户: 博纳斯稀土开采独资有限公司 │ │
│ │ 业务经理: 罗仕林 │ │
│ │ ├─ 报价V1 2026-03-10 ¥115,500 草稿 [编辑][删除] │ │
│ │ ├─ 报价V2 2026-03-12 ¥120,000 已发送 [查看][删除] │ │
│ │ └─ 报价V3 2026-03-15 ¥118,000 已发送 [查看][删除] │ │
│ │ [新增报价版本] [标记签约] [标记未签约] │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ ▶ 万象市中心配电房改造项目 已签约 15天 │ │
│ │ 客户: 老挝国家电力公司 │ │
│ │ 业务经理: 罗仕林 │ │
│ │ └─ 报价V1 2026-03-01 ¥85,000 已签约 │ │
│ │ [查看] [进入项目管理] │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ ▼ 南塔省输电线路项目 未签约 32天 │ │
│ │ 客户: 南塔电力公司 │ │
│ │ 业务经理: 李四 │ │
│ │ └─ 报价V1 2026-02-15 ¥200,000 未签约 │ │
│ │ 未签约原因: 价格过高 │ │
│ │ [编辑] [恢复商谈] [新增报价] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 共 15 条记录 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 新建商谈项目(勘察录入)
```
┌─────────────────────────────────────────────────────────────────┐
│ 新建商谈项目(勘察录入) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ───────────────────────────────────────────────────────────── │
│ 基本信息 │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 项目名称 *: [ ] │
│ │
│ 客户 *: [ 选择客户 ▼ ] [新建客户] │
│ │
│ 业务经理 *: [ 选择人员 ▼ ] │
│ │
│ 项目地点: [ ] │
│ │
│ 勘察日期: [ 2026-03-18 ] │
│ │
│ ───────────────────────────────────────────────────────────── │
│ 居间人信息 │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 居间人: [ ] │
│ │
│ 居间费类型: ○ 固定金额 ○ 百分比 │
│ │
│ 居间费: [ ] 元 或 [ ] % │
│ (按合同金额×比例自动计算) │
│ │
│ ───────────────────────────────────────────────────────────── │
│ 工程基本情况 │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 客户要求: [ ] │
│ [ ] │
│ [ ] │
│ │
│ 工程概况: [ ] │
│ [ ] │
│ [ ] │
│ │
│ 附件上传: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 📄 地形图.pdf 📄 电路图.dwg 📄 勘察报告.docx │ │
│ │ [+ 上传文件] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 勘察照片: │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ [图片1] [图片2] [图片3] [图片4] [+ 上传图片] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ [ 取消 ] [ 保存 ] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.3 新增报价版本
```
┌─────────────────────────────────────────────────────────────────┐
│ 新增报价版本 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 项目名称: 博纳斯一车间到三车间22kV线路项目 │
│ 当前版本: V3 │
│ │
│ 报价日期 *: [ 2026-03-18 ] │
│ │
│ 报价金额 *: [ ] 元 │
│ │
│ 币种: [ CNY ▼ ] │
│ │
│ 报价文件: [ 上传文件 ] │
│ │
│ 备注: [ ] │
│ │
│ [ 取消 ] [ 保存 ] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.4 状态流转
```
┌─────────────────────────────────────────────────────────────────┐
│ 状态流转图 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ │
│ │ 新建项目 │ │
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ ┌────────│ 商谈中 │────────┐ │
│ │ └──────────────┘ │ │
│ │ │ │ │
│ │ │ 30天未操作 │ 手动点击 │
│ │ ▼ ▼ │
│ │ ┌──────────────┐ ┌──────────────┐ │
│ │ │ 自动归档 │ │ 未签约 │ │
│ │ │ (未签约) │ │ │ │
│ │ └──────────────┘ └──────┬───────┘ │
│ │ │ │
│ │ │ 恢复商谈 │
│ │ ▼ │
│ │ ┌──────────────┐ │
│ │ │ 商谈中 │ │
│ │ └──────────────┘ │
│ │ │
│ │ 手动点击签约 │
│ ▼ │
│ ┌──────────────┐ │
│ │ 已签约 │ ──────────────────> 进入项目管理 │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.5 提醒机制
```
提醒规则:
┌─────────────────────────────────────────────────────────────────┐
│ 触发条件: 状态为"商谈中" 且 距最后一次报价超过7天 │
│ │
│ 提醒频率: 每7天提醒一次 │
│ │
│ 提醒对象: │
│ ├─ 所有管理员 │
│ └─ 关联的业务经理 │
│ │
│ 提醒内容: │
│ ├─ 项目名称 │
│ ├─ 客户名称 │
│ ├─ 最后报价日期 │
│ ├─ 距今天数 │
│ └─ 操作链接 │
│ │
│ 30天自动归档: │
│ ├─ 第25天: 提醒"即将自动归档" │
│ └─ 第30天: 自动标记为"未签约" │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 三、项目管理(签约后)
### 3.1 项目列表
```
┌─────────────────────────────────────────────────────────────────┐
│ 项目管理 [状态筛选] [搜索] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 状态筛选: [全部] [进行中] [已完工] [已完成] [已暂停] │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 项目名称 │ 客户 │ 金额 │ 状态 │ 操作│ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 博纳斯22kV线路项目 │ 博纳斯 │ ¥115,500│ 施工中 │ [进入]│ │
│ │ 万象配电房改造 │ 老挝电力 │ ¥85,000 │ 已完工 │ [进入]│ │
│ │ 南塔输电线路 │ 南塔电力 │ ¥200,000│ 已完成 │ [进入]│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.2 项目详情页Tab设计
```
┌─────────────────────────────────────────────────────────────────┐
│ 项目名称: 博纳斯一车间到三车间22kV线路项目 │
│ 状态: 施工中 │ 进度: 60% │ 质保金: ¥5,775 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.3 基本信息 Tab
```
┌─────────────────────────────────────────────────────────────────┐
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ ════════ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 项目编号: PRJ260317001 │
│ 项目名称: 博纳斯一车间到三车间22kV线路项目 │
│ 客户: 博纳斯稀土开采独资有限公司 │
│ 项目经理: 罗仕林 │
│ 项目地点: 老挝万象省博纳斯矿区 │
│ 项目状态: 施工中 │
│ │
│ 工程概况: │
│ 从一车间到三车间新建7公里22kV高压输电线路, │
│ 安装1250kVA变压器1台,包含基础施工、设备安装、调试等。 │
│ │
│ 客户要求: │
│ 需在雨季前完工,确保供电稳定性 │
│ │
│ 勘察照片: [图片1] [图片2] [图片3] │
│ │
│ 附件文件: [地形图.pdf] [电路图.dwg] [勘察报告.docx] │
│ │
│ 创建时间: 2026-03-18 │
│ 更新时间: 2026-03-20 │
│ │
│ [编辑基本信息] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.4 合同详情 Tab
```
┌─────────────────────────────────────────────────────────────────┐
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ ════════ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 合同信息 │
│ ───────────────────────────────────────────────────────────── │
│ 合同编号: HT-2026-001 │
│ 合同金额: ¥115,500 │
│ 币种: CNY │
│ 结算方式: 单价结算 │
│ 合同工期: 100 天 │
│ 开工日期: 2026-03-20 │
│ 完工日期: 2026-06-28 (预计) │
│ │
│ 单价明细 │
│ ───────────────────────────────────────────────────────────── │
│ 项目内容 │ 单价 │ 暂定工程量 │ 暂定总价 │
│ ───────────────────────────────────────────────────────────── │
│ 22kV线路安装 │ ¥15,000/km │ 7 km │ ¥105,000 │
│ 变压器安装 │ ¥10,500/台 │ 1 台 │ ¥10,500 │
│ ───────────────────────────────────────────────────────────── │
│ 合计 │ │ │ ¥115,500 │
│ │
│ 付款节点 │
│ ───────────────────────────────────────────────────────────── │
│ 节点名称 │ 比例 │ 金额 │ 状态 │ 预计日期 │
│ ───────────────────────────────────────────────────────────── │
│ 预付款 │ 30% │ ¥34,650 │ 已付款 │ 2026-03-20 │
│ 进度款 │ 40% │ ¥46,200 │ 待付款 │ 2026-05-15 │
│ 完工款 │ 25% │ ¥28,875 │ 待付款 │ 2026-06-28 │
│ 质保金 │ 5% │ ¥5,775 │ 待释放 │ 2027-06-28 │
│ ───────────────────────────────────────────────────────────── │
│ 合计 │ 100% │ ¥115,500 │ │ │
│ │
│ 质保金设置 │
│ ───────────────────────────────────────────────────────────── │
│ 是否有质保金: 是 │
│ 质保金比例: 5% │
│ 质保金金额: ¥5,775 │
│ 质保期限: 12 个月 │
│ 到期日期: 2027-06-28 │
│ │
│ 合同文件: [合同扫描件.pdf] │
│ │
│ [编辑合同详情] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.5 分包管理 Tab
```
┌─────────────────────────────────────────────────────────────────┐
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ ═══════════ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 分包合同列表 [新增分包] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 分包商 │ 合同金额 │ 已付款 │ 状态 │ 操作 │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 老挝电力工程 │ ¥50,000 │ ¥30,000 │ 进行中 │ [详情] │ │
│ │ 万象建材公司 │ ¥15,000 │ ¥15,000 │ 已完成 │ [详情] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 分包合计: ¥65,000 已付款: ¥45,000 │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.6 材料管理 Tab
```
┌─────────────────────────────────────────────────────────────────┐
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ ═══════════ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 项目材料汇总 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 商品名称 │单位│ 预算量 │ 采购量 │使用量│ 均价 │ 总价 │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 22kV高压电缆 │ 米 │ 7000 │ 7200 │ 6500 │ ¥85 │¥612,000│ │
│ │ 变压器1250kVA │ 台 │ 1 │ 1 │ 1 │¥80,000│¥80,000│ │
│ │ 电缆终端头 │ 套 │ 6 │ 6 │ 4 │¥1,200 │ ¥7,200│ │
│ │ 接地线 │ 米 │ 500 │ 500 │ 400 │ ¥25 │¥12,500│ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 说明: │
│ - 预算量: 合同预算工程量 │
│ - 采购量: 该项目实际采购数量 │
│ - 使用量: 施工现场已使用数量 │
│ - 均价: 多次采购的平均单价 │
│ - 总价 = 采购量 × 均价 │
│ │
│ 材料采购明细: [查看采购批次] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.7 施工节点 Tab
```
┌─────────────────────────────────────────────────────────────────┐
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ ═══════════ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 合同付款节点 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 节点名称 │ 比例 │ 金额 │ 完成进度 │ 状态 │ 操作 │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 预付款 │ 30% │ ¥34,650 │ 100% │ 已完成 │ [查看] │ │
│ │ │ │ │ 📎凭证1 │ │ │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 进度款 │ 40% │ ¥46,200 │ 60% │ 进行中 │ [上传] │ │
│ │ │ │ │ 📎凭证2 │ │ │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 完工款 │ 25% │ ¥28,875 │ 0% │ 待开始 │ │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 质保金 │ 5% │ ¥5,775 │ 0% │ 待释放 │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 重要节点完成情况 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ✓ 设备到场 2026-04-10 [查看凭证] │ │
│ │ ✓ 基础完工 2026-04-15 [查看凭证] │ │
│ │ ○ 竣工验收 - [上传凭证] │ │
│ │ ○ 并网通电 - [上传凭证] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.8 施工日志 Tab
```
┌─────────────────────────────────────────────────────────────────┐
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ ═══════════ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 施工日志列表 [+ 新增日志] │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 📅 2026-04-15 天气: 晴 │ │
│ │ 记录人: 张三 │ │
│ │ 今日工作: 完成1号变压器基础浇筑 │ │
│ │ 照片: [图1] [图2] [图3] │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 📅 2026-04-14 天气: 雨 │ │
│ │ 记录人: 李四 │ │
│ │ 今日工作: 铺设电缆沟渠50米 │ │
│ │ 照片: [图1] [图2] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.9 财务信息 Tab
```
┌─────────────────────────────────────────────────────────────────┐
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ ═════════ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ╔═══════════════════════════════════════════════════════════╗ │
│ ║ 合同与结算对比 ║ │
│ ╠═══════════════════════════════════════════════════════════╣ │
│ ║ ║ │
│ ║ 结算方式: 单价结算 ║ │
│ ║ ║ │
│ ║ ┌─────────────────────────────────────────────────────┐ ║ │
│ ║ │ 项目内容 │ 单价 │ 暂定量 │ 实际量 │ 差异 │ ║ │
│ ║ ├─────────────────────────────────────────────────────┤ ║ │
│ ║ │ 22kV线路安装 │ ¥15,000/km│ 7 km │ 7.5 km │ +0.5km│ ║ │
│ ║ │ 变压器安装 │ ¥10,500/台│ 1 台 │ 1 台 │ 0 │ ║ │
│ ║ ├─────────────────────────────────────────────────────┤ ║ │
│ ║ │ 暂定总价 │ │ │ ¥115,500│ │ ║ │
│ ║ │ 实际总价 │ │ │ ¥123,000│ +7.5%│ ║ │
│ ║ │ 结算差异 │ │ │ +¥7,500 │ │ ║ │
│ ║ └─────────────────────────────────────────────────────┘ ║ │
│ ║ ║ │
│ ╚═══════════════════════════════════════════════════════════╝ │
│ │
│ ╔═══════════════════════════════════════════════════════════╗ │
│ ║ 收款情况 ║ │
│ ╠═══════════════════════════════════════════════════════════╣ │
│ ║ ║ │
│ ║ 合同暂定金额: ¥115,500 ║ │
│ ║ 实际结算金额: ¥123,000 (+7.5%) ║ │
│ ║ ─────────────────────────────────────────────────────── ║ │
│ ║ 已收款: ¥92,250 (75%) ║ │
│ ║ 待收款: ¥25,125 (20.4%) ║ │
│ ║ 质保金: ¥6,150 (5%) 待释放 ║ │
│ ║ ─────────────────────────────────────────────────────── ║ │
│ ║ 合计: ¥123,000 (100%) ║ │
│ ║ ║ │
│ ╚═══════════════════════════════════════════════════════════╝ │
│ │
│ ╔═══════════════════════════════════════════════════════════╗ │
│ ║ 支出情况 ║ │
│ ╠═══════════════════════════════════════════════════════════╣ │
│ ║ ║ │
│ ║ 预支总额: ¥20,000 [查看明细] ║ │
│ ║ 报销总额: ¥15,000 [查看明细] ║ │
│ ║ 分包付款: ¥35,000 [查看明细] ║ │
│ ║ ─────────────────────────────────────────────────────── ║ │
│ ║ 支出合计: ¥70,000 ║ │
│ ║ ║ │
│ ╚═══════════════════════════════════════════════════════════╝ │
│ │
│ ╔═══════════════════════════════════════════════════════════╗ │
│ ║ 利润概况 ║ │
│ ╠═══════════════════════════════════════════════════════════╣ │
│ ║ ║ │
│ ║ 实际结算金额: ¥123,000 ║ │
│ ║ 支出合计: ¥70,000 ║ │
│ ║ ─────────────────────────────────────────────────────── ║ │
│ ║ 毛利润: ¥53,000 (43.1%) ║ │
│ ║ ║ │
│ ╚═══════════════════════════════════════════════════════════╝ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 3.10 质保金 Tab
```
┌─────────────────────────────────────────────────────────────────┐
│ [基本信息] [合同详情] [分包管理] [材料管理] [施工节点] [施工日志] [财务信息] [质保金] │
│ ════════│
├─────────────────────────────────────────────────────────────────┤
│ │
│ 质保金信息 │
│ ───────────────────────────────────────────────────────────── │
│ 质保金金额: ¥5,775 │
│ 质保比例: 5% │
│ 质保期限: 12 个月 │
│ 起算日期: 2026-06-28 (完工日期) │
│ 到期日期: 2027-06-28 │
│ 剩余天数: 365 天 │
│ 状态: 待释放 │
│ │
│ ───────────────────────────────────────────────────────────── │
│ │
│ 质保金状态: [待释放] │
│ │
│ 操作: │
│ [标记已释放] [延期] [备注] │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 四、施工管理独立入口(员工快速访问)
### 4.1 用户权限设计
```
┌─────────────────────────────────────────────────────────────────┐
│ 用户角色与权限 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 管理员: 可访问所有功能 │
│ │
│ 项目经理: 可访问所负责项目的 所有Tab │
│ │
│ 施工人员: 专属"施工管理"入口 │
│ - 查看所负责项目列表 │
│ - 录入施工日志 │
│ - 上传施工照片 │
│ - 查看施工节点完成情况 │
│ - ❌ 无财务信息 │
│ - ❌ 无质保金 │
│ │
│ 预算人员: 预算报价管理 + 项目管理(只读) │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 4.2 施工管理入口
```
┌─────────────────────────────────────────────────────────────────┐
│ 施工管理 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 我的施工项目 │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 🎯 博纳斯22kV线路项目 施工中 │ │
│ │ 客户: 博纳斯稀土 │ │
│ │ 进度: 60% │ │
│ │ ─────────────────────────────────────── │ │
│ │ [📝 施工日志] [📷 上传照片] [📋 节点进度] │ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ 🎯 万象配电房改造 完工 │ │
│ │ 客户: 老挝国家电力 │ │
│ │ 进度: 100% │ │
│ │ ─────────────────────────────────────── │ │
│ │ [📝 施工日志] [📷 上传照片] [📋 节点进度] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### 4.3 移动端施工日志录入
```
┌─────────────────────────┐
│ 🏠 施工管理 │
├─────────────────────────┤
│ │
│ [我的项目] │
│ ┌───────────────────┐ │
│ │ 🎯 博纳斯22kV │ │
│ │ 进度: 60% │ │
│ └───────────────────┘ │
│ │
│ ─────────────────────│
│ │
│ 📝 写施工日志 │
│ ┌───────────────────┐ │
│ │ 日期: 2026-04-15 │ │
│ │ 天气: ☀️ 晴 │ │
│ │ │ │
│ │ 今天做了什么? │ │
│ │ [ ] │ │
│ │ │ │
│ │ 明天计划做什么? │ │
│ │ [ ] │ │
│ │ │ │
│ │ 📷 上传照片 │ │
│ │ [+] │ │
│ │ │ │
│ │ [ 提 交 ] │ │
│ └───────────────────┘ │
│ │
└─────────────────────────┘
```
---
## 五、质保金管理窗口(全局)
```
┌─────────────────────────────────────────────────────────────────┐
│ 质保金管理 [×] │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 筛选: [全部] [即将到期] [已到期] [已释放] │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 项目名称 │ 金额 │ 到期日期 │ 剩余天数 │ 状态│ │
│ ├─────────────────────────────────────────────────────────┤ │
│ │ ⚠️ 万象配电房改造 │ ¥4,250 │ 2026-04-15 │ 15天 │即将到期│ │
│ │ 博纳斯22kV线路 │ ¥5,775 │ 2027-06-28 │ 365天 │待释放 │ │
│ │ 南塔输电线路 │ ¥10,000│ 2026-03-01 │ 已到期 │已到期 │ │
│ │ 琅勃拉邦商场 │ ¥3,500 │ 2026-01-15 │ 已释放 │已释放 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 即将到期提醒(30天内): │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ⚠️ 万象配电房改造 质保金 ¥4,250 将于 15天后到期 │ │
│ │ [标记已释放] [延期] [查看项目] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
---
## 六、数据库设计
```sql
-- ============================================
-- 预算报价管理(签约前)
-- ============================================
-- 预算报价项目表
CREATE TABLE budget_projects (
id SERIAL PRIMARY KEY,
budget_code VARCHAR(50) UNIQUE, -- 预算编号
name VARCHAR(255) NOT NULL, -- 项目名称
customer_id INTEGER REFERENCES customers(id),
location VARCHAR(255), -- 项目地点
survey_date DATE, -- 勘察日期
survey_notes TEXT, -- 工程概况
customer_requirements TEXT, -- 客户要求
-- 居间人信息(勘察时录入)
intermediary_name VARCHAR(100), -- 居间人
intermediary_fee_type VARCHAR(10), -- 'fixed' 或 'percent'
intermediary_fee DECIMAL(15,2), -- 居间费金额或比例
-- 业务信息
business_manager_id INTEGER REFERENCES users(id), -- 业务经理
-- 状态
status VARCHAR(20) DEFAULT 'negotiating', -- negotiating/signed/unsigned
last_quotation_at TIMESTAMP, -- 最后报价时间
unsigned_reason VARCHAR(255), -- 未签约原因
-- 关联项目(签约后)
project_id INTEGER REFERENCES projects(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id)
);
-- 报价版本表
CREATE TABLE budget_quotations (
id SERIAL PRIMARY KEY,
budget_project_id INTEGER REFERENCES budget_projects(id),
version INTEGER, -- 版本号
quotation_date DATE, -- 报价日期
amount DECIMAL(15,2), -- 报价金额
currency VARCHAR(10) DEFAULT 'CNY',
file_url TEXT, -- 报价文件
status VARCHAR(20) DEFAULT 'draft', -- draft/sent/accepted/rejected
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 附件表(工程基本情况附件)
CREATE TABLE budget_attachments (
id SERIAL PRIMARY KEY,
budget_project_id INTEGER REFERENCES budget_projects(id),
file_name VARCHAR(255),
file_url TEXT,
file_type VARCHAR(50), -- image/document/drawing
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================
-- 项目管理(签约后)
-- ============================================
-- 项目表新增字段
ALTER TABLE projects ADD COLUMN budget_project_id INTEGER REFERENCES budget_projects(id);
ALTER TABLE projects ADD COLUMN contract_code VARCHAR(50); -- 合同编号
ALTER TABLE projects ADD COLUMN has_warranty BOOLEAN DEFAULT FALSE;
ALTER TABLE projects ADD COLUMN warranty_amount DECIMAL(15,2);
ALTER TABLE projects ADD COLUMN warranty_percent DECIMAL(5,2);
ALTER TABLE projects ADD COLUMN warranty_months INTEGER DEFAULT 12;
ALTER TABLE projects ADD COLUMN warranty_start_date DATE;
ALTER TABLE projects ADD COLUMN warranty_end_date DATE;
ALTER TABLE projects ADD COLUMN warranty_status VARCHAR(20) DEFAULT 'pending';
-- 工程量明细表(单价结算用)
CREATE TABLE project_work_items (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id),
item_name VARCHAR(255), -- 项目内容
unit VARCHAR(50), -- 单位
unit_price DECIMAL(15,2), -- 单价
estimated_quantity DECIMAL(10,2), -- 暂定工程量
actual_quantity DECIMAL(10,2), -- 实际工程量
is_lump_sum BOOLEAN DEFAULT FALSE, -- 是否总价包干
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 施工日志表
CREATE TABLE construction_logs (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id),
log_date DATE NOT NULL,
weather VARCHAR(50),
recorded_by INTEGER REFERENCES users(id),
work_content TEXT,
next_plan TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 施工日志附件表
CREATE TABLE construction_log_photos (
id SERIAL PRIMARY KEY,
log_id INTEGER REFERENCES construction_logs(id),
file_name VARCHAR(255),
file_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 施工节点表
CREATE TABLE construction_milestones (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id),
milestone_name VARCHAR(255),
planned_date DATE,
actual_date DATE,
status VARCHAR(20) DEFAULT 'pending', -- pending/completed
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 施工节点凭证表
CREATE TABLE milestone_evidence (
id SERIAL PRIMARY KEY,
milestone_id INTEGER REFERENCES construction_milestones(id),
file_name VARCHAR(255),
file_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ============================================
-- 材料管理
-- ============================================
-- 项目材料汇总视图
CREATE VIEW project_materials_summary AS
SELECT
p.id as project_id,
p.name as project_name,
pr.product_id,
pr.product_name,
pr.unit,
-- 预算量(从合同工程量)
(SELECT estimated_quantity FROM project_work_items WHERE project_id = p.id AND product_id = pr.product_id LIMIT 1) as budget_quantity,
-- 采购量
SUM(pr.quantity) as purchase_quantity,
-- 使用量(需要手动记录)
0 as used_quantity,
-- 均价
AVG(pr.unit_price) as avg_unit_price,
-- 总价
SUM(pr.total_price) as total_price
FROM projects p
JOIN purchase_requests pr ON pr.project_id = p.id
GROUP BY p.id, pr.product_id, pr.product_name, pr.unit;
-- ============================================
-- 提醒机制
-- ============================================
-- 质保金提醒表
CREATE TABLE warranty_reminders (
id SERIAL PRIMARY KEY,
project_id INTEGER REFERENCES projects(id),
reminder_date DATE,
reminder_type VARCHAR(20), -- 30days/7days/expire
is_sent BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 商谈提醒表
CREATE TABLE negotiation_reminders (
id SERIAL PRIMARY KEY,
budget_project_id INTEGER REFERENCES budget_projects(id),
reminder_date DATE,
days_since_last_quotation INTEGER,
is_sent BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
---
## 七、Tab内容与权限总结
| Tab | 内容 | 权限 |
|-----|------|------|
| **基本信息** | 项目名称、客户、地点、勘察情况、照片、附件、客户要求 | 全部 |
| **合同详情** | 合同金额、付款节点、单价明细、质保金设置 | 全部 |
| **分包管理** | 分包商列表、分包金额、付款情况 | 全部 |
| **材料管理** | 材料汇总(预算量、采购量、使用量、均价、总价) | 全部 |
| **施工节点** | 付款节点进度、重要节点完成情况、凭证上传 | 全部 |
| **施工日志** | 施工日志列表、照片、施工记录 | 全部(施工人员仅看自己) |
| **财务信息** | 收款、支出、利润汇总 | 管理员+项目经理 |
| **质保金** | 质保金状态、到期提醒 | 管理员+项目经理 |
---
## 八、关键逻辑说明
### 8.1 居间费计算
```
固定金额: 居间费 = 输入金额
百分比: 居间费 = 合同金额 × 输入比例
示例:
- 固定金额: ¥5,000
- 百分比: 合同金额 ¥115,500 × 5% = ¥5,775
```
### 8.2 材料均价计算
```
同一材料多次采购:
采购1: 100米 × ¥80 = ¥8,000
采购2: 50米 × ¥90 = ¥4,500
采购3: 70米 × ¥85 = ¥5,950
均价 = (8000 + 4500 + 5950) / (100 + 50 + 70) = ¥83.41/米
总价 = 220米 × ¥83.41 = ¥18,450
```
### 8.3 总价包干 vs 单价结算
```
总价包干:
┌─────────────────────────────────────────────────────────────┐
│ 单价显示: "(总价包干)" │
│ 暂定工程量: 可录入,仅供参考 │
│ 实际工程量: 可录入,仅供参考 │
│ 结算金额: = 合同金额(不变) │
│ │
│ 如有变更: 通过"合同变更记录"调整金额 │
└─────────────────────────────────────────────────────────────┘
单价结算:
┌─────────────────────────────────────────────────────────────┐
│ 单价显示: ¥15,000/km │
│ 暂定工程量: 录入 │
│ 实际工程量: 录入 │
│ 结算金额: = Σ(单价 × 实际工程量) │
│ │
│ 差异计算: 自动计算暂定 vs 实际的差异 │
└─────────────────────────────────────────────────────────────┘
```
---
## 九、数据流转
```
预算报价 ──────────────────────────────────────> 项目管理
预算报价项目 项目管理
┌──────────────────┐ ┌──────────────────┐
│ name │ ──────> │ name (可修改) │
│ customer_id │ ──────> │ customer_id │
│ location │ ──────> │ location │
│ survey_notes │ ──────> │ (不显示) │
│ survey_photos │ ──────> │ (不显示) │
│ customer_requirements│ ──> │ (不显示) │
│ business_manager │ ──────> │ (不显示) │
│ intermediary │ ──────> │ (不显示) │
│ intermediary_fee │ ──────> │ (不显示) │
│ quotation.amount │ ──────> │ contract_amount │
└──────────────────┘ └──────────────────┘
关联关系: projects.budget_project_id = budget_projects.id
```
---
**方案完成时间: 2026-03-18**
+56
View File
@@ -0,0 +1,56 @@
# compiled output
/dist
/node_modules
/build
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# temp directory
.temp
.tmp
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+98
View File
@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
## Compile and run the project
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Run tests
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
Binary file not shown.
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+12237
View File
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
{
"name": "backend",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/typeorm": "^11.0.0",
"bcryptjs": "^3.0.3",
"class-validator": "^0.15.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.28"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});
+12
View File
@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}
+45
View File
@@ -0,0 +1,45 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { databaseConfig } from './config/database.config';
import { AuthModule } from './modules/auth/auth.module';
import { UsersModule } from './modules/users/users.module';
import { RolesModule } from './modules/roles/roles.module';
import { PermissionsModule } from './modules/permissions/permissions.module';
import { CustomersModule } from './modules/customers/customers.module';
import { SuppliersModule } from './modules/suppliers/suppliers.module';
import { SubcontractorsModule } from './modules/subcontractors/subcontractors.module';
import { ProjectsModule } from './modules/projects/projects.module';
import { QuotationsModule } from './modules/quotations/quotations.module';
import { PurchasesModule } from './modules/purchases/purchases.module';
import { SubcontractsModule } from './modules/subcontracts/subcontracts.module';
import { ContractsModule } from './modules/contracts/contracts.module';
import { FinanceModule } from './modules/finance/finance.module';
import { ProductsModule } from './modules/products/products.module';
import { SeedService } from './database/seed';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
TypeOrmModule.forRoot(databaseConfig),
AuthModule,
UsersModule,
RolesModule,
PermissionsModule,
CustomersModule,
SuppliersModule,
SubcontractorsModule,
ProjectsModule,
QuotationsModule,
PurchasesModule,
SubcontractsModule,
ContractsModule,
FinanceModule,
ProductsModule,
],
controllers: [],
providers: [SeedService],
})
export class AppModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+13
View File
@@ -0,0 +1,13 @@
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import * as dotenv from 'dotenv';
import * as path from 'path';
dotenv.config();
export const databaseConfig: TypeOrmModuleOptions = {
type: 'sqlite',
database: path.join(__dirname, '..', '..', 'database.sqlite'),
entities: [__dirname + '/../**/*.entity{.ts,.js}'],
synchronize: true,
logging: true,
};
+140
View File
@@ -0,0 +1,140 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Permission } from '../modules/permissions/permission.entity';
import { Role } from '../modules/roles/role.entity';
import { User } from '../modules/users/user.entity';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class SeedService implements OnModuleInit {
constructor(
@InjectRepository(Permission) private permissionRepo: Repository<Permission>,
@InjectRepository(Role) private roleRepo: Repository<Role>,
@InjectRepository(User) private userRepo: Repository<User>,
) {}
async onModuleInit() {
await this.seedPermissions();
await this.seedRoles();
await this.seedAdminUser();
}
private async seedPermissions() {
const permissions = [
{ name: 'user:create', description: '创建用户' },
{ name: 'user:read', description: '读取用户' },
{ name: 'user:update', description: '更新用户' },
{ name: 'user:delete', description: '删除用户' },
{ name: 'role:create', description: '创建角色' },
{ name: 'role:read', description: '读取角色' },
{ name: 'role:update', description: '更新角色' },
{ name: 'role:delete', description: '删除角色' },
{ name: 'permission:create', description: '创建权限' },
{ name: 'permission:read', description: '读取权限' },
{ name: 'permission:update', description: '更新权限' },
{ name: 'permission:delete', description: '删除权限' },
{ name: 'customer:create', description: '创建客户' },
{ name: 'customer:read', description: '读取客户' },
{ name: 'customer:update', description: '更新客户' },
{ name: 'customer:delete', description: '删除客户' },
{ name: 'supplier:create', description: '创建供应商' },
{ name: 'supplier:read', description: '读取供应商' },
{ name: 'supplier:update', description: '更新供应商' },
{ name: 'supplier:delete', description: '删除供应商' },
{ name: 'subcontractor:create', description: '创建分包商' },
{ name: 'subcontractor:read', description: '读取分包商' },
{ name: 'subcontractor:update', description: '更新分包商' },
{ name: 'subcontractor:delete', description: '删除分包商' },
{ name: 'project:create', description: '创建项目' },
{ name: 'project:read', description: '读取项目' },
{ name: 'project:update', description: '更新项目' },
{ name: 'project:delete', description: '删除项目' },
{ name: 'finance:read', description: '读取财务' },
{ name: 'finance:update', description: '更新财务' },
{ name: 'construction:read', description: '读取施工' },
{ name: 'construction:update', description: '更新施工' },
{ name: 'exchange:read', description: '读取汇率' },
{ name: 'exchange:update', description: '更新汇率' },
{ name: 'product:create', description: '创建商品' },
{ name: 'product:read', description: '读取商品' },
{ name: 'product:update', description: '更新商品' },
{ name: 'product:delete', description: '删除商品' },
{ name: 'quotation:create', description: '创建预算报价' },
{ name: 'quotation:read', description: '读取预算报价' },
{ name: 'quotation:update', description: '更新预算报价' },
{ name: 'quotation:delete', description: '删除预算报价' },
];
for (const permissionData of permissions) {
const existing = await this.permissionRepo.findOne({ where: { name: permissionData.name } });
if (!existing) {
const permission = this.permissionRepo.create(permissionData);
await this.permissionRepo.save(permission);
}
}
}
private async seedRoles() {
const allPermissions = await this.permissionRepo.find();
const roles = [
{
name: '超级管理员',
description: '拥有所有权限',
permissions: allPermissions,
},
{
name: '管理员',
description: '拥有大部分管理权限',
permissions: allPermissions.filter(p => !p.name.includes('role:') && !p.name.includes('permission:')),
},
{
name: '财务人员',
description: '拥有财务管理权限',
permissions: allPermissions.filter(p => p.name.includes('finance:') || p.name.includes('exchange:')),
},
{
name: '项目经理',
description: '拥有项目管理权限',
permissions: allPermissions.filter(p => p.name.includes('project:') || p.name.includes('customer:')),
},
{
name: '采购人员',
description: '拥有采购管理权限',
permissions: allPermissions.filter(p => p.name.includes('supplier:') || p.name.includes('product:')),
},
{
name: '施工人员',
description: '拥有施工管理权限',
permissions: allPermissions.filter(p => p.name.includes('construction:')),
},
];
for (const roleData of roles) {
const existing = await this.roleRepo.findOne({ where: { name: roleData.name } });
if (!existing) {
const role = this.roleRepo.create(roleData);
await this.roleRepo.save(role);
}
}
}
private async seedAdminUser() {
const adminRole = await this.roleRepo.findOne({ where: { name: '超级管理员' } });
if (!adminRole) return;
const existingAdmin = await this.userRepo.findOne({ where: { username: 'admin' } });
if (!existingAdmin) {
const hashedPassword = await bcrypt.hash('admin123', 10);
const adminUser = this.userRepo.create({
username: 'admin',
password: hashedPassword,
name: '系统管理员',
email: 'admin@example.com',
role: adminRole,
});
await this.userRepo.save(adminUser);
}
}
}
+13
View File
@@ -0,0 +1,13 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: ['http://localhost:5173', 'http://localhost:4173', 'http://localhost:5174'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
allowedHeaders: ['Content-Type', 'Authorization'],
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
@@ -0,0 +1,26 @@
import { Controller, Post, Body } from '@nestjs/common';
import { AuthService } from './auth.service';
@Controller('api/v1/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
async register(
@Body('username') username: string,
@Body('password') password: string,
@Body('name') name: string,
@Body('email') email: string,
@Body('roleId') roleId: string,
) {
return this.authService.register(username, password, name, email, roleId);
}
@Post('login')
async login(
@Body('username') username: string,
@Body('password') password: string,
) {
return this.authService.login(username, password);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import * as dotenv from 'dotenv';
dotenv.config();
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_SECRET || 'your-secret-key-here',
signOptions: {
expiresIn: '3600s',
},
}),
],
providers: [AuthService, JwtStrategy],
controllers: [AuthController],
exports: [AuthService],
})
export class AuthModule {}
+74
View File
@@ -0,0 +1,74 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { UsersService } from '../users/users.service';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
async register(username: string, password: string, name: string, email: string, roleId: string) {
// 检查用户名是否已存在
const existingUser = await this.usersService.findByUsername(username);
if (existingUser) {
throw new Error('Username already exists');
}
// 检查邮箱是否已存在
const existingEmail = await this.usersService.findByEmail(email);
if (existingEmail) {
throw new Error('Email already exists');
}
// 哈希密码
const hashedPassword = await bcrypt.hash(password, 10);
// 创建用户
const user = await this.usersService.create({
username,
password: hashedPassword,
name,
email,
roleId,
});
return user;
}
async login(username: string, password: string) {
// 查找用户
console.log('Login attempt for user:', username);
const user = await this.usersService.findByUsername(username);
console.log('Found user:', user);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
// 验证密码
const isPasswordValid = await bcrypt.compare(password, user.password);
console.log('Password valid:', isPasswordValid);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
// 生成JWT token
const payload = { sub: user.id, username: user.username, role: user.role.name };
const token = this.jwtService.sign(payload);
const response = {
access_token: token,
user: {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
role: user.role.name,
},
};
console.log('Login response:', response);
return response;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UsersService } from '../users/users.service';
import * as dotenv from 'dotenv';
dotenv.config();
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly usersService: UsersService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.JWT_SECRET || 'your-secret-key-here',
ignoreExpiration: false,
});
}
async validate(payload: any) {
const user = await this.usersService.findById(payload.sub);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
@@ -0,0 +1,56 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { Project } from '../projects/project.entity';
import { PaymentTerm } from './payment-term.entity';
import { Purchase } from '../purchases/purchase.entity';
import { Subcontract } from '../subcontracts/subcontract.entity';
@Entity('contracts')
export class Contract {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
project_id: string;
@ManyToOne(() => Project, project => project.contracts)
project: Project;
@Column({ type: 'varchar', length: 50, nullable: false })
type: string;
@Column({ type: 'varchar', length: 100, unique: true, nullable: false })
contract_number: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
contract_amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'date', nullable: true })
start_date: Date;
@Column({ type: 'date', nullable: true })
end_date: Date;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => PaymentTerm, paymentTerm => paymentTerm.contract)
payment_terms: PaymentTerm[];
@OneToMany(() => Purchase, purchase => purchase.contract)
purchases: Purchase[];
@OneToMany(() => Subcontract, subcontract => subcontract.contract)
subcontracts: Subcontract[];
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Contract } from './contract.entity';
import { PaymentTerm } from './payment-term.entity';
@Module({
imports: [TypeOrmModule.forFeature([Contract, PaymentTerm])],
exports: [TypeOrmModule],
})
export class ContractsModule {}
@@ -0,0 +1,48 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { Contract } from './contract.entity';
import { FinancialRecord } from '../finance/financial-record.entity';
@Entity('payment_terms')
export class PaymentTerm {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
contract_id: string;
@ManyToOne(() => Contract, contract => contract.payment_terms)
contract: Contract;
@Column({ type: 'integer', nullable: false })
sequence: number;
@Column({ type: 'text', nullable: false })
description: string;
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: false })
percentage: number;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'date', nullable: true })
due_date: Date;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => FinancialRecord, financialRecord => financialRecord.payment_term)
financial_records: FinancialRecord[];
}
@@ -0,0 +1,36 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Project } from '../projects/project.entity';
import { Quotation } from '../quotations/quotation.entity';
@Entity('customers')
export class Customer {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 255, nullable: true })
contact_person: string;
@Column({ type: 'varchar', length: 50, nullable: true })
phone: string;
@Column({ type: 'varchar', length: 255, nullable: true })
email: string;
@Column({ type: 'text', nullable: true })
address: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => Project, project => project.customer)
projects: Project[];
@OneToMany(() => Quotation, quotation => quotation.customer)
quotations: Quotation[];
}
@@ -0,0 +1,43 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { CustomersService } from './customers.service';
import { Customer } from './customer.entity';
@Controller('api/v1/customers')
export class CustomersController {
constructor(private readonly customersService: CustomersService) {}
@Post()
create(@Body() customerData: Partial<Customer>) {
return this.customersService.create(customerData);
}
@Get()
findAll() {
return this.customersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.customersService.findOne(id);
}
@Get(':id/projects')
findWithProjects(@Param('id') id: string) {
return this.customersService.findWithProjects(id);
}
@Get(':id/quotations')
findWithQuotations(@Param('id') id: string) {
return this.customersService.findWithQuotations(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() customerData: Partial<Customer>) {
return this.customersService.update(id, customerData);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.customersService.remove(id);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Customer } from './customer.entity';
import { CustomersController } from './customers.controller';
import { CustomersService } from './customers.service';
@Module({
imports: [TypeOrmModule.forFeature([Customer])],
controllers: [CustomersController],
providers: [CustomersService],
exports: [CustomersService],
})
export class CustomersModule {}
@@ -0,0 +1,64 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Customer } from './customer.entity';
@Injectable()
export class CustomersService {
constructor(
@InjectRepository(Customer)
private customersRepository: Repository<Customer>,
) {}
async create(customerData: Partial<Customer>): Promise<Customer> {
const customer = this.customersRepository.create(customerData);
return this.customersRepository.save(customer);
}
async findAll(): Promise<Customer[]> {
return this.customersRepository.find();
}
async findOne(id: string): Promise<Customer> {
const customer = await this.customersRepository.findOne({ where: { id } });
if (!customer) {
throw new NotFoundException(`Customer with id ${id} not found`);
}
return customer;
}
async update(id: string, customerData: Partial<Customer>): Promise<Customer> {
const customer = await this.findOne(id);
Object.assign(customer, customerData);
return this.customersRepository.save(customer);
}
async remove(id: string): Promise<void> {
const result = await this.customersRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`Customer with id ${id} not found`);
}
}
async findWithProjects(id: string): Promise<Customer> {
const customer = await this.customersRepository.findOne({
where: { id },
relations: ['projects'],
});
if (!customer) {
throw new NotFoundException(`Customer with id ${id} not found`);
}
return customer;
}
async findWithQuotations(id: string): Promise<Customer> {
const customer = await this.customersRepository.findOne({
where: { id },
relations: ['quotations'],
});
if (!customer) {
throw new NotFoundException(`Customer with id ${id} not found`);
}
return customer;
}
}
@@ -0,0 +1,69 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm';
import { User } from '../users/user.entity';
import { Project } from '../projects/project.entity';
@Entity('advance_payments')
export class AdvancePayment {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false })
user_id: string;
@ManyToOne(() => User)
user: User;
@Column({ type: 'varchar', length: 20, nullable: false })
expense_type: string; // company or project
@Column({ type: 'uuid', nullable: true })
project_id: string;
@ManyToOne(() => Project, project => project.advance_payments)
project: Project;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'varchar', length: 255, nullable: false })
purpose: string;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@Column({ type: 'uuid', nullable: true })
approver_id: string;
@ManyToOne(() => User)
approver: User;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'datetime', nullable: true })
submitted_at: Date;
@Column({ type: 'datetime', nullable: true })
approved_at: Date;
@Column({ type: 'datetime', nullable: true })
rejected_at: Date;
@Column({ type: 'decimal', precision: 18, scale: 2, default: 0 })
used_amount: number;
@Column({ type: 'decimal', precision: 18, scale: 2, default: 0 })
remaining_amount: number;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,32 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { FinancialTransaction } from './financial-transaction.entity';
@Entity('bank_accounts')
export class BankAccount {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 255, nullable: false })
bank_name: string;
@Column({ type: 'varchar', length: 100, unique: true, nullable: false })
account_number: string;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, default: 0 })
balance: number;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => FinancialTransaction, financialTransaction => financialTransaction.bank_account)
financial_transactions: FinancialTransaction[];
}
@@ -0,0 +1,25 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity()
export class CurrencyRate {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 10, nullable: false }) // 源币种代码,如 CNY, LAK, THB, USD
from_currency: string;
@Column({ type: 'varchar', length: 10, nullable: false }) // 目标币种代码
to_currency: string;
@Column({ type: 'decimal', precision: 12, scale: 6, nullable: false }) // 汇率值
rate: number;
@Column({ type: 'boolean', default: true }) // 是否启用
is_active: boolean;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,35 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { ExpenseClaim } from './expense-claim.entity';
@Entity('expense_claim_details')
export class ExpenseClaimDetail {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false })
expense_claim_id: string;
@ManyToOne(() => ExpenseClaim, expense_claim => expense_claim.details)
expense_claim: ExpenseClaim;
@Column({ type: 'varchar', length: 50, nullable: false })
category: string; // 支出分类
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'varchar', length: 255, nullable: true })
description: string;
@Column({ type: 'date', nullable: false })
expense_date: Date;
@Column({ type: 'text', nullable: true })
attachments: string; // JSON格式存储附件信息
}
@@ -0,0 +1,67 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { User } from '../users/user.entity';
import { Project } from '../projects/project.entity';
import { ExpenseClaimDetail } from './expense-claim-detail.entity';
@Entity('expense_claims')
export class ExpenseClaim {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: false })
user_id: string;
@ManyToOne(() => User)
user: User;
@Column({ type: 'varchar', length: 20, nullable: false })
expense_type: string; // company or project
@Column({ type: 'uuid', nullable: true })
project_id: string;
@ManyToOne(() => Project, project => project.expense_claims)
project: Project;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'varchar', length: 255, nullable: false })
purpose: string;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@Column({ type: 'uuid', nullable: true })
approver_id: string;
@ManyToOne(() => User)
approver: User;
@Column({ type: 'text', nullable: true })
description: string;
@Column({ type: 'datetime', nullable: true })
submitted_at: Date;
@Column({ type: 'datetime', nullable: true })
approved_at: Date;
@Column({ type: 'datetime', nullable: true })
rejected_at: Date;
@OneToMany(() => ExpenseClaimDetail, detail => detail.expense_claim, { cascade: true })
details: ExpenseClaimDetail[];
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,68 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm';
import { User } from '../users/user.entity';
import { ExpenseClaim } from './expense-claim.entity';
import { AdvancePayment } from './advance-payment.entity';
import { FinancialRecord } from './financial-record.entity';
@Entity('finance_approvals')
export class FinanceApproval {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 50, nullable: false })
approval_type: string;
@Column({ type: 'uuid', nullable: true })
expense_claim_id: string;
@ManyToOne(() => ExpenseClaim)
expense_claim: ExpenseClaim;
@Column({ type: 'uuid', nullable: true })
advance_payment_id: string;
@ManyToOne(() => AdvancePayment)
advance_payment: AdvancePayment;
@Column({ type: 'uuid', nullable: true })
financial_record_id: string;
@ManyToOne(() => FinancialRecord)
financial_record: FinancialRecord;
@Column({ type: 'uuid', nullable: false })
requester_id: string;
@ManyToOne(() => User)
requester: User;
@Column({ type: 'uuid', nullable: true })
approver_id: string;
@ManyToOne(() => User)
approver: User;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@Column({ type: 'text', nullable: true })
request_notes: string;
@Column({ type: 'text', nullable: true })
approval_notes: string;
@Column({ type: 'datetime', nullable: true })
submitted_at: Date;
@Column({ type: 'datetime', nullable: true })
approved_at: Date;
@Column({ type: 'datetime', nullable: true })
rejected_at: Date;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,238 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
import { FinanceService } from './finance.service';
import { AuthGuard } from '@nestjs/passport';
@Controller('api/v1/finance')
export class FinanceController {
constructor(private financeService: FinanceService) {}
// 报销申请相关接口
@UseGuards(AuthGuard('jwt'))
@Post('expense-claims')
async createExpenseClaim(@Body() data: any, @Request() req) {
data.user_id = req.user.id;
return this.financeService.createExpenseClaim(data);
}
@UseGuards(AuthGuard('jwt'))
@Get('expense-claims')
async getExpenseClaims(@Query() filter: any, @Request() req) {
// 普通用户只能查看自己的报销申请
if (req.user.role.name !== 'admin') {
filter.user_id = req.user.id;
}
return this.financeService.getExpenseClaims(filter);
}
@UseGuards(AuthGuard('jwt'))
@Get('expense-claims/:id')
async getExpenseClaimById(@Param('id') id: string, @Request() req) {
const expenseClaim = await this.financeService.getExpenseClaimById(id);
// 普通用户只能查看自己的报销申请
if (req.user.role.name !== 'admin' && expenseClaim.user_id !== req.user.id) {
throw new Error('Access denied');
}
return expenseClaim;
}
@UseGuards(AuthGuard('jwt'))
@Put('expense-claims/:id/approve')
async approveExpenseClaim(@Param('id') id: string, @Body() data: any, @Request() req) {
// 只有管理员可以审批
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.approveExpenseClaim(id, req.user.id, data.notes);
}
@UseGuards(AuthGuard('jwt'))
@Put('expense-claims/:id/reject')
async rejectExpenseClaim(@Param('id') id: string, @Body() data: any, @Request() req) {
// 只有管理员可以审批
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.rejectExpenseClaim(id, req.user.id, data.notes);
}
// 预支款相关接口
@UseGuards(AuthGuard('jwt'))
@Post('advance-payments')
async createAdvancePayment(@Body() data: any, @Request() req) {
data.user_id = req.user.id;
return this.financeService.createAdvancePayment(data);
}
@UseGuards(AuthGuard('jwt'))
@Get('advance-payments')
async getAdvancePayments(@Query() filter: any, @Request() req) {
// 普通用户只能查看自己的预支款
if (req.user.role.name !== 'admin') {
filter.user_id = req.user.id;
}
return this.financeService.getAdvancePayments(filter);
}
@UseGuards(AuthGuard('jwt'))
@Get('advance-payments/:id')
async getAdvancePaymentById(@Param('id') id: string, @Request() req) {
const advancePayment = await this.financeService.getAdvancePaymentById(id);
// 普通用户只能查看自己的预支款
if (req.user.role.name !== 'admin' && advancePayment.user_id !== req.user.id) {
throw new Error('Access denied');
}
return advancePayment;
}
@UseGuards(AuthGuard('jwt'))
@Put('advance-payments/:id/approve')
async approveAdvancePayment(@Param('id') id: string, @Body() data: any, @Request() req) {
// 只有管理员可以审批
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.approveAdvancePayment(id, req.user.id, data.notes);
}
@UseGuards(AuthGuard('jwt'))
@Put('advance-payments/:id/reject')
async rejectAdvancePayment(@Param('id') id: string, @Body() data: any, @Request() req) {
// 只有管理员可以审批
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.rejectAdvancePayment(id, req.user.id, data.notes);
}
@UseGuards(AuthGuard('jwt'))
@Put('advance-payments/:id/usage')
async updateAdvancePaymentUsage(@Param('id') id: string, @Body() data: any, @Request() req) {
const advancePayment = await this.financeService.getAdvancePaymentById(id);
// 普通用户只能更新自己的预支款使用情况
if (req.user.role.name !== 'admin' && advancePayment.user_id !== req.user.id) {
throw new Error('Access denied');
}
return this.financeService.updateAdvancePaymentUsage(id, data.used_amount);
}
// 凭证相关接口
@UseGuards(AuthGuard('jwt'))
@Post('vouchers')
async uploadVoucher(@Body() data: any, @Request() req) {
data.uploaded_by = req.user.id;
return this.financeService.uploadVoucher(data);
}
@UseGuards(AuthGuard('jwt'))
@Get('vouchers')
async getVouchers(@Query() filter: any) {
return this.financeService.getVouchers(filter);
}
// 财务审批相关接口
@UseGuards(AuthGuard('jwt'))
@Post('approvals')
async createFinanceApproval(@Body() data: any, @Request() req) {
data.requester_id = req.user.id;
return this.financeService.createFinanceApproval(data);
}
@UseGuards(AuthGuard('jwt'))
@Get('approvals')
async getFinanceApprovals(@Query() filter: any, @Request() req) {
// 普通用户只能查看自己的审批请求
if (req.user.role.name !== 'admin') {
filter.requester_id = req.user.id;
}
return this.financeService.getFinanceApprovals(filter);
}
@UseGuards(AuthGuard('jwt'))
@Put('approvals/:id/approve')
async approveFinanceApproval(@Param('id') id: string, @Body() data: any, @Request() req) {
// 只有管理员可以审批
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.approveFinanceApproval(id, req.user.id, data.notes);
}
@UseGuards(AuthGuard('jwt'))
@Put('approvals/:id/reject')
async rejectFinanceApproval(@Param('id') id: string, @Body() data: any, @Request() req) {
// 只有管理员可以审批
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.rejectFinanceApproval(id, req.user.id, data.notes);
}
// 公司账目管理接口
@UseGuards(AuthGuard('jwt'))
@Get('company-records')
async getCompanyFinancialRecords(@Query() filter: any, @Request() req) {
// 只有管理员可以查看公司账目
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.getCompanyFinancialRecords(filter);
}
// 项目财务记录接口
@UseGuards(AuthGuard('jwt'))
@Get('project-records/:projectId')
async getProjectFinancialRecords(@Param('projectId') projectId: string) {
return this.financeService.getProjectFinancialRecords(projectId);
}
// 员工个人记账接口
@UseGuards(AuthGuard('jwt'))
@Get('employee-records')
async getEmployeeFinancialRecords(@Request() req) {
return this.financeService.getEmployeeFinancialRecords(req.user.id);
}
// 汇率管理相关接口
@UseGuards(AuthGuard('jwt'))
@Get('currency-rates')
async getCurrencyRates(@Request() req) {
// 只有管理员可以查看汇率设置
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.getCurrencyRates();
}
@UseGuards(AuthGuard('jwt'))
@Post('currency-rates')
async createOrUpdateCurrencyRate(@Body() data: any, @Request() req) {
// 只有管理员可以设置汇率
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.createOrUpdateCurrencyRate(data);
}
@UseGuards(AuthGuard('jwt'))
@Put('currency-rates/:id/status')
async updateCurrencyRateStatus(@Param('id') id: string, @Body() data: any, @Request() req) {
// 只有管理员可以更新汇率状态
if (req.user.role.name !== 'admin') {
throw new Error('Access denied');
}
return this.financeService.updateCurrencyRateStatus(id, data.is_active);
}
@UseGuards(AuthGuard('jwt'))
@Get('currencies')
async getSupportedCurrencies() {
return this.financeService.getSupportedCurrencies();
}
@UseGuards(AuthGuard('jwt'))
@Post('convert-currency')
async convertCurrency(@Body() data: any) {
const { amount, from_currency, to_currency } = data;
return this.financeService.convertCurrency(amount, from_currency, to_currency);
}
}
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FinancialRecord } from './financial-record.entity';
import { FinancialTransaction } from './financial-transaction.entity';
import { BankAccount } from './bank-account.entity';
import { ExpenseClaim } from './expense-claim.entity';
import { AdvancePayment } from './advance-payment.entity';
import { Voucher } from './voucher.entity';
import { FinanceApproval } from './finance-approval.entity';
import { CurrencyRate } from './currency-rate.entity';
import { FinanceController } from './finance.controller';
import { FinanceService } from './finance.service';
@Module({
imports: [TypeOrmModule.forFeature([
FinancialRecord,
FinancialTransaction,
BankAccount,
ExpenseClaim,
AdvancePayment,
Voucher,
FinanceApproval,
CurrencyRate
])],
controllers: [FinanceController],
providers: [FinanceService],
exports: [TypeOrmModule, FinanceService],
})
export class FinanceModule {}
@@ -0,0 +1,228 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FinanceService } from './finance.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ExpenseClaim } from './expense-claim.entity';
import { AdvancePayment } from './advance-payment.entity';
import { Voucher } from './voucher.entity';
import { FinanceApproval } from './finance-approval.entity';
import { FinancialRecord } from './financial-record.entity';
import { BankAccount } from './bank-account.entity';
import { FinancialTransaction } from './financial-transaction.entity';
import { CurrencyRate } from './currency-rate.entity';
describe('FinanceService', () => {
let financeService: FinanceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FinanceService,
{
provide: getRepositoryToken(FinancialRecord),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn(),
}),
},
},
{
provide: getRepositoryToken(ExpenseClaim),
useValue: {
create: jest.fn().mockReturnValue({ id: '1' }),
save: jest.fn().mockReturnValue({ id: '1', status: 'pending' }),
findOne: jest.fn().mockReturnValue({ id: '1', status: 'pending' }),
find: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn(),
}),
},
},
{
provide: getRepositoryToken(AdvancePayment),
useValue: {
create: jest.fn().mockReturnValue({ id: '1' }),
save: jest.fn().mockReturnValue({ id: '1', status: 'pending' }),
findOne: jest.fn().mockReturnValue({ id: '1', status: 'pending' }),
find: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn(),
}),
},
},
{
provide: getRepositoryToken(Voucher),
useValue: {
create: jest.fn(),
save: jest.fn(),
find: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn(),
}),
},
},
{
provide: getRepositoryToken(FinanceApproval),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue({
leftJoinAndSelect: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
getMany: jest.fn(),
}),
},
},
{
provide: getRepositoryToken(BankAccount),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
},
},
{
provide: getRepositoryToken(FinancialTransaction),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
},
},
{
provide: getRepositoryToken(CurrencyRate),
useValue: {
create: jest.fn(),
save: jest.fn(),
findOne: jest.fn(),
find: jest.fn(),
},
},
],
}).compile();
financeService = module.get<FinanceService>(FinanceService);
});
describe('支出类型和项目选择', () => {
it('应该支持公司支出和项目支出类型', async () => {
// 测试创建报销申请时支持支出类型
const expenseClaimData = {
user_id: '1',
expense_type: 'company', // 公司支出
amount: 1000,
currency: 'CNY',
rmb_equivalent: 1000,
purpose: '测试报销',
};
const result = await financeService.createExpenseClaim(expenseClaimData);
expect(result).toBeDefined();
});
it('选择项目支出时必须提供项目ID', async () => {
// 测试创建报销申请时选择项目支出
const expenseClaimData = {
user_id: '1',
expense_type: 'project', // 项目支出
project_id: '1',
amount: 1000,
currency: 'CNY',
rmb_equivalent: 1000,
purpose: '测试项目报销',
};
const result = await financeService.createExpenseClaim(expenseClaimData);
expect(result).toBeDefined();
});
});
describe('支出分类', () => {
it('项目支出应包含正确的支出分类选项', () => {
// 测试项目支出的分类选项
const projectExpenseCategories = [
'住宿', '餐饮', '加油', '零散材料', '客户关系', '分包关系', 'EDL关系', '额外施工', '其他'
];
expect(projectExpenseCategories.length).toBeGreaterThan(0);
});
it('公司支出应包含正确的支出分类选项', () => {
// 测试公司支出的分类选项
const companyExpenseCategories = [
'通用运营(房租 / 耗材)', '交通通勤', '业扩营销', '电力系统关系', '员工福利', '快递物流', '其他'
];
expect(companyExpenseCategories.length).toBeGreaterThan(0);
});
});
describe('币种统一', () => {
it('应该支持统一币种', async () => {
// 测试币种转换功能
const amount = 100;
const fromCurrency = 'USD';
const toCurrency = 'CNY';
// 模拟汇率
const mockRate = {
from_currency: 'USD',
to_currency: 'CNY',
rate: 7.0,
is_active: true,
};
// 测试币种转换
try {
const result = await financeService.convertCurrency(amount, fromCurrency, toCurrency);
expect(result).toBeDefined();
} catch (error) {
// 汇率不存在时会抛出异常,这是正常的
expect(error).toBeDefined();
}
});
});
describe('审批流程', () => {
it('提交后状态应变为待审批', async () => {
// 测试创建报销申请后状态
const expenseClaimData = {
user_id: '1',
expense_type: 'company',
amount: 1000,
currency: 'CNY',
rmb_equivalent: 1000,
purpose: '测试报销',
};
const result = await financeService.createExpenseClaim(expenseClaimData);
expect(result).toBeDefined();
});
it('审批退回时状态应变为待编辑', async () => {
// 测试拒绝报销申请
const approverId = '2';
try {
const result = await financeService.rejectExpenseClaim('1', approverId, '拒绝理由');
expect(result).toBeDefined();
} catch (error) {
// 记录不存在时会抛出异常,这是正常的
expect(error).toBeDefined();
}
});
});
});
@@ -0,0 +1,377 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FinancialRecord } from './financial-record.entity';
import { ExpenseClaim } from './expense-claim.entity';
import { AdvancePayment } from './advance-payment.entity';
import { Voucher } from './voucher.entity';
import { FinanceApproval } from './finance-approval.entity';
import { BankAccount } from './bank-account.entity';
import { FinancialTransaction } from './financial-transaction.entity';
import { CurrencyRate } from './currency-rate.entity';
@Injectable()
export class FinanceService {
constructor(
@InjectRepository(FinancialRecord) private financialRecordRepo: Repository<FinancialRecord>,
@InjectRepository(ExpenseClaim) private expenseClaimRepo: Repository<ExpenseClaim>,
@InjectRepository(AdvancePayment) private advancePaymentRepo: Repository<AdvancePayment>,
@InjectRepository(Voucher) private voucherRepo: Repository<Voucher>,
@InjectRepository(FinanceApproval) private financeApprovalRepo: Repository<FinanceApproval>,
@InjectRepository(BankAccount) private bankAccountRepo: Repository<BankAccount>,
@InjectRepository(FinancialTransaction) private financialTransactionRepo: Repository<FinancialTransaction>,
@InjectRepository(CurrencyRate) private currencyRateRepo: Repository<CurrencyRate>
) {}
// 报销申请相关
async createExpenseClaim(data: any): Promise<ExpenseClaim> {
// 验证支出类型
if (!data.expense_type || !['company', 'project'].includes(data.expense_type)) {
throw new Error('Invalid expense type');
}
// 项目支出必须提供项目ID
if (data.expense_type === 'project' && !data.project_id) {
throw new Error('Project ID is required for project expense');
}
const expenseClaim = this.expenseClaimRepo.create({
...data,
status: 'pending',
submitted_at: new Date()
});
const result = await this.expenseClaimRepo.save(expenseClaim);
return result as unknown as ExpenseClaim;
}
async getExpenseClaims(filter?: any): Promise<ExpenseClaim[]> {
const query = this.expenseClaimRepo.createQueryBuilder('ec')
.leftJoinAndSelect('ec.user', 'user')
.leftJoinAndSelect('ec.approver', 'approver')
.leftJoinAndSelect('ec.project', 'project');
if (filter) {
if (filter.user_id) query.where('ec.user_id = :user_id', { user_id: filter.user_id });
if (filter.project_id) query.where('ec.project_id = :project_id', { project_id: filter.project_id });
if (filter.status) query.where('ec.status = :status', { status: filter.status });
}
return query.getMany();
}
async getExpenseClaimById(id: string): Promise<ExpenseClaim> {
const expenseClaim = await this.expenseClaimRepo.findOne({
where: { id },
relations: ['user', 'approver', 'project', 'vouchers']
});
if (!expenseClaim) throw new NotFoundException('Expense claim not found');
return expenseClaim;
}
async approveExpenseClaim(id: string, approverId: string, notes?: string): Promise<ExpenseClaim> {
const expenseClaim = await this.getExpenseClaimById(id);
expenseClaim.status = 'approved';
expenseClaim.approver_id = approverId;
expenseClaim.approved_at = new Date();
return this.expenseClaimRepo.save(expenseClaim);
}
async rejectExpenseClaim(id: string, approverId: string, notes?: string): Promise<ExpenseClaim> {
const expenseClaim = await this.getExpenseClaimById(id);
expenseClaim.status = 'rejected';
expenseClaim.approver_id = approverId;
expenseClaim.rejected_at = new Date();
return this.expenseClaimRepo.save(expenseClaim);
}
// 预支款相关
async createAdvancePayment(data: any): Promise<AdvancePayment> {
// 验证支出类型
if (!data.expense_type || !['company', 'project'].includes(data.expense_type)) {
throw new Error('Invalid expense type');
}
// 项目支出必须提供项目ID
if (data.expense_type === 'project' && !data.project_id) {
throw new Error('Project ID is required for project expense');
}
const advancePayment = this.advancePaymentRepo.create({
...data,
status: 'pending',
submitted_at: new Date(),
used_amount: 0,
remaining_amount: data.amount
});
const result = await this.advancePaymentRepo.save(advancePayment);
return result as unknown as AdvancePayment;
}
async getAdvancePayments(filter?: any): Promise<AdvancePayment[]> {
const query = this.advancePaymentRepo.createQueryBuilder('ap')
.leftJoinAndSelect('ap.user', 'user')
.leftJoinAndSelect('ap.approver', 'approver')
.leftJoinAndSelect('ap.project', 'project');
if (filter) {
if (filter.user_id) query.where('ap.user_id = :user_id', { user_id: filter.user_id });
if (filter.project_id) query.where('ap.project_id = :project_id', { project_id: filter.project_id });
if (filter.status) query.where('ap.status = :status', { status: filter.status });
}
return query.getMany();
}
async getAdvancePaymentById(id: string): Promise<AdvancePayment> {
const advancePayment = await this.advancePaymentRepo.findOne({
where: { id },
relations: ['user', 'approver', 'project', 'vouchers']
});
if (!advancePayment) throw new NotFoundException('Advance payment not found');
return advancePayment;
}
async approveAdvancePayment(id: string, approverId: string, notes?: string): Promise<AdvancePayment> {
const advancePayment = await this.getAdvancePaymentById(id);
advancePayment.status = 'approved';
advancePayment.approver_id = approverId;
advancePayment.approved_at = new Date();
return this.advancePaymentRepo.save(advancePayment);
}
async rejectAdvancePayment(id: string, approverId: string, notes?: string): Promise<AdvancePayment> {
const advancePayment = await this.getAdvancePaymentById(id);
advancePayment.status = 'rejected';
advancePayment.approver_id = approverId;
advancePayment.rejected_at = new Date();
return this.advancePaymentRepo.save(advancePayment);
}
async updateAdvancePaymentUsage(id: string, usedAmount: number): Promise<AdvancePayment> {
const advancePayment = await this.getAdvancePaymentById(id);
advancePayment.used_amount = usedAmount;
advancePayment.remaining_amount = advancePayment.amount - usedAmount;
return this.advancePaymentRepo.save(advancePayment);
}
// 凭证相关
async uploadVoucher(data: any): Promise<Voucher> {
const voucher = this.voucherRepo.create(data);
const result = await this.voucherRepo.save(voucher);
return result as unknown as Voucher;
}
async getVouchers(filter?: any): Promise<Voucher[]> {
const query = this.voucherRepo.createQueryBuilder('v')
.leftJoinAndSelect('v.uploader', 'uploader');
if (filter) {
if (filter.expense_claim_id) query.where('v.expense_claim_id = :expense_claim_id', { expense_claim_id: filter.expense_claim_id });
if (filter.advance_payment_id) query.where('v.advance_payment_id = :advance_payment_id', { advance_payment_id: filter.advance_payment_id });
if (filter.financial_record_id) query.where('v.financial_record_id = :financial_record_id', { financial_record_id: filter.financial_record_id });
}
return query.getMany();
}
// 财务审批相关
async createFinanceApproval(data: any): Promise<FinanceApproval> {
const approval = this.financeApprovalRepo.create({
...data,
status: 'pending',
submitted_at: new Date()
});
const result = await this.financeApprovalRepo.save(approval);
return result as unknown as FinanceApproval;
}
async getFinanceApprovals(filter?: any): Promise<FinanceApproval[]> {
const query = this.financeApprovalRepo.createQueryBuilder('fa')
.leftJoinAndSelect('fa.requester', 'requester')
.leftJoinAndSelect('fa.approver', 'approver');
if (filter) {
if (filter.requester_id) query.where('fa.requester_id = :requester_id', { requester_id: filter.requester_id });
if (filter.approver_id) query.where('fa.approver_id = :approver_id', { approver_id: filter.approver_id });
if (filter.status) query.where('fa.status = :status', { status: filter.status });
if (filter.approval_type) query.where('fa.approval_type = :approval_type', { approval_type: filter.approval_type });
}
return query.getMany();
}
async approveFinanceApproval(id: string, approverId: string, notes?: string): Promise<FinanceApproval> {
const approval = await this.financeApprovalRepo.findOne({
where: { id },
relations: ['expense_claim', 'advance_payment']
});
if (!approval) throw new NotFoundException('Finance approval not found');
approval.status = 'approved';
approval.approver_id = approverId;
approval.approval_notes = notes || '';
approval.approved_at = new Date();
// 同时更新关联的报销申请或预支款状态
if (approval.expense_claim) {
approval.expense_claim.status = 'approved';
approval.expense_claim.approver_id = approverId;
approval.expense_claim.approved_at = new Date();
await this.expenseClaimRepo.save(approval.expense_claim);
}
if (approval.advance_payment) {
approval.advance_payment.status = 'approved';
approval.advance_payment.approver_id = approverId;
approval.advance_payment.approved_at = new Date();
await this.advancePaymentRepo.save(approval.advance_payment);
}
return this.financeApprovalRepo.save(approval);
}
async rejectFinanceApproval(id: string, approverId: string, notes?: string): Promise<FinanceApproval> {
const approval = await this.financeApprovalRepo.findOne({
where: { id },
relations: ['expense_claim', 'advance_payment']
});
if (!approval) throw new NotFoundException('Finance approval not found');
approval.status = 'rejected';
approval.approver_id = approverId;
approval.approval_notes = notes || '';
approval.rejected_at = new Date();
// 同时更新关联的报销申请或预支款状态
if (approval.expense_claim) {
approval.expense_claim.status = 'rejected';
approval.expense_claim.approver_id = approverId;
approval.expense_claim.rejected_at = new Date();
await this.expenseClaimRepo.save(approval.expense_claim);
}
if (approval.advance_payment) {
approval.advance_payment.status = 'rejected';
approval.advance_payment.approver_id = approverId;
approval.advance_payment.rejected_at = new Date();
await this.advancePaymentRepo.save(approval.advance_payment);
}
return this.financeApprovalRepo.save(approval);
}
// 公司账目管理
async getCompanyFinancialRecords(filter?: any): Promise<FinancialRecord[]> {
const query = this.financialRecordRepo.createQueryBuilder('fr')
.leftJoinAndSelect('fr.project', 'project');
if (filter) {
if (filter.type) query.where('fr.type = :type', { type: filter.type });
if (filter.status) query.where('fr.status = :status', { status: filter.status });
if (filter.start_date) query.where('fr.created_at >= :start_date', { start_date: filter.start_date });
if (filter.end_date) query.where('fr.created_at <= :end_date', { end_date: filter.end_date });
}
return query.getMany();
}
// 项目财务记录
async getProjectFinancialRecords(projectId: string): Promise<FinancialRecord[]> {
return this.financialRecordRepo.find({
where: { project_id: projectId },
relations: ['financial_transactions', 'financial_transactions.bank_account']
});
}
// 员工个人记账
async getEmployeeFinancialRecords(userId: string): Promise<any[]> {
const expenseClaims = await this.expenseClaimRepo.find({
where: { user_id: userId },
relations: ['project', 'vouchers']
});
const advancePayments = await this.advancePaymentRepo.find({
where: { user_id: userId },
relations: ['project', 'vouchers']
});
return [...expenseClaims, ...advancePayments];
}
// 汇率管理相关
async getCurrencyRates(): Promise<CurrencyRate[]> {
return this.currencyRateRepo.find({ where: { is_active: true } });
}
async getCurrencyRate(fromCurrency: string, toCurrency: string): Promise<CurrencyRate | null> {
return this.currencyRateRepo.findOne({
where: { from_currency: fromCurrency, to_currency: toCurrency, is_active: true }
});
}
async createOrUpdateCurrencyRate(data: {
from_currency: string;
to_currency: string;
rate: number;
}): Promise<CurrencyRate> {
const { from_currency, to_currency, rate } = data;
// 检查是否已存在该货币对的汇率
let currencyRate = await this.currencyRateRepo.findOne({
where: { from_currency, to_currency }
});
if (currencyRate) {
// 更新现有汇率
currencyRate.rate = rate;
currencyRate.is_active = true;
return this.currencyRateRepo.save(currencyRate);
} else {
// 创建新汇率
currencyRate = this.currencyRateRepo.create({
from_currency,
to_currency,
rate,
is_active: true
});
return this.currencyRateRepo.save(currencyRate);
}
}
async updateCurrencyRateStatus(id: string, is_active: boolean): Promise<CurrencyRate> {
const currencyRate = await this.currencyRateRepo.findOne({ where: { id } });
if (!currencyRate) throw new NotFoundException('Currency rate not found');
currencyRate.is_active = is_active;
return this.currencyRateRepo.save(currencyRate);
}
// 金额换算方法
async convertCurrency(amount: number, fromCurrency: string, toCurrency: string): Promise<number> {
if (fromCurrency === toCurrency) return amount;
// 尝试直接获取汇率
let rate = await this.getCurrencyRate(fromCurrency, toCurrency);
if (rate) {
return amount * rate.rate;
}
// 尝试反向汇率
const reverseRate = await this.getCurrencyRate(toCurrency, fromCurrency);
if (reverseRate) {
return amount / reverseRate.rate;
}
// 如果都没有,抛出异常
throw new NotFoundException(`Exchange rate not found for ${fromCurrency} to ${toCurrency}`);
}
// 获取所有支持的币种
getSupportedCurrencies(): Array<{ code: string; name: string; symbol: string }> {
return [
{ code: 'CNY', name: '人民币', symbol: '¥' },
{ code: 'LAK', name: '老挝基普', symbol: '₭' },
{ code: 'THB', name: '泰铢', symbol: '฿' },
{ code: 'USD', name: '美元', symbol: '$' }
];
}
}
@@ -0,0 +1,55 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { Project } from '../projects/project.entity';
import { PaymentTerm } from '../contracts/payment-term.entity';
import { FinancialTransaction } from './financial-transaction.entity';
@Entity('financial_records')
export class FinancialRecord {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 50, nullable: false })
type: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'uuid', nullable: true })
project_id: string;
@ManyToOne(() => Project, project => project.financial_records)
project: Project;
@Column({ type: 'uuid', nullable: true })
related_id: string;
@Column({ type: 'varchar', length: 50, nullable: true })
related_type: string;
@Column({ type: 'uuid', nullable: true })
payment_term_id: string;
@ManyToOne(() => PaymentTerm, paymentTerm => paymentTerm.financial_records)
payment_term: PaymentTerm;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => FinancialTransaction, financialTransaction => financialTransaction.financial_record)
financial_transactions: FinancialTransaction[];
}
@@ -0,0 +1,39 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm';
import { FinancialRecord } from './financial-record.entity';
import { BankAccount } from './bank-account.entity';
@Entity('financial_transactions')
export class FinancialTransaction {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
financial_record_id: string;
@ManyToOne(() => FinancialRecord, financialRecord => financialRecord.financial_transactions)
financial_record: FinancialRecord;
@Column({ type: 'uuid', nullable: true })
bank_account_id: string;
@ManyToOne(() => BankAccount, bankAccount => bankAccount.financial_transactions)
bank_account: BankAccount;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'date', nullable: false })
transaction_date: Date;
@Column({ type: 'varchar', length: 50, nullable: false })
transaction_type: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,53 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm';
import { User } from '../users/user.entity';
import { FinancialRecord } from './financial-record.entity';
import { ExpenseClaim } from './expense-claim.entity';
import { AdvancePayment } from './advance-payment.entity';
@Entity('vouchers')
export class Voucher {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
file_path: string;
@Column({ type: 'varchar', length: 100, nullable: false })
file_name: string;
@Column({ type: 'varchar', length: 50, nullable: false })
file_type: string;
@Column({ type: 'uuid', nullable: true })
financial_record_id: string;
@ManyToOne(() => FinancialRecord)
financial_record: FinancialRecord;
@Column({ type: 'uuid', nullable: true })
expense_claim_id: string;
@ManyToOne(() => ExpenseClaim)
expense_claim: ExpenseClaim;
@Column({ type: 'uuid', nullable: true })
advance_payment_id: string;
@ManyToOne(() => AdvancePayment)
advance_payment: AdvancePayment;
@Column({ type: 'uuid', nullable: false })
uploaded_by: string;
@ManyToOne(() => User)
uploader: User;
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,19 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@Entity('permissions')
export class Permission {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,45 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { PermissionsService } from './permissions.service';
import { AuthGuard } from '@nestjs/passport';
@Controller('api/v1/permissions')
export class PermissionsController {
constructor(private readonly permissionsService: PermissionsService) {}
@Post()
@UseGuards(AuthGuard('jwt'))
async create(
@Body('name') name: string,
@Body('description') description: string,
) {
return this.permissionsService.create(name, description);
}
@Get()
@UseGuards(AuthGuard('jwt'))
async findAll() {
return this.permissionsService.findAll();
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
async findById(@Param('id') id: string) {
return this.permissionsService.findById(id);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
async update(
@Param('id') id: string,
@Body('name') name: string,
@Body('description') description: string,
) {
return this.permissionsService.update(id, name, description);
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
async delete(@Param('id') id: string) {
return this.permissionsService.delete(id);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Permission } from './permission.entity';
import { PermissionsService } from './permissions.service';
import { PermissionsController } from './permissions.controller';
@Module({
imports: [TypeOrmModule.forFeature([Permission])],
providers: [PermissionsService],
controllers: [PermissionsController],
exports: [PermissionsService, TypeOrmModule.forFeature([Permission])],
})
export class PermissionsModule {}
@@ -0,0 +1,42 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Permission } from './permission.entity';
@Injectable()
export class PermissionsService {
constructor(
@InjectRepository(Permission)
private readonly permissionsRepository: Repository<Permission>,
) {}
async create(name: string, description: string) {
const permission = this.permissionsRepository.create({
name,
description,
});
return this.permissionsRepository.save(permission);
}
async findById(id: string) {
return this.permissionsRepository.findOne({
where: { id },
});
}
async findAll() {
return this.permissionsRepository.find();
}
async update(id: string, name: string, description: string) {
await this.permissionsRepository.update(id, {
name,
description,
});
return this.findById(id);
}
async delete(id: string) {
return this.permissionsRepository.delete(id);
}
}
@@ -0,0 +1,29 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { Product } from './product.entity';
@Entity('product_categories')
export class ProductCategory {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'uuid', nullable: true })
parent_id: string;
@ManyToOne(() => ProductCategory, productCategory => productCategory.children)
parent: ProductCategory;
@OneToMany(() => ProductCategory, productCategory => productCategory.parent)
children: ProductCategory[];
@OneToMany(() => Product, product => product.category)
products: Product[];
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,37 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { ProductCategory } from './product-category.entity';
import { PurchaseItem } from '../purchases/purchase-item.entity';
import { QuotationItem } from '../quotations/quotation-item.entity';
@Entity('products')
export class Product {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'uuid', nullable: true })
category_id: string;
@ManyToOne(() => ProductCategory, productCategory => productCategory.products)
category: ProductCategory;
@Column({ type: 'varchar', length: 50, nullable: false })
unit: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: true })
cost_price: number;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => PurchaseItem, purchaseItem => purchaseItem.product)
purchase_items: PurchaseItem[];
@OneToMany(() => QuotationItem, quotationItem => quotationItem.product)
quotation_items: QuotationItem[];
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Product } from './product.entity';
import { ProductCategory } from './product-category.entity';
@Module({
imports: [TypeOrmModule.forFeature([Product, ProductCategory])],
exports: [TypeOrmModule],
})
export class ProductsModule {}
@@ -0,0 +1,45 @@
import { IsString, IsNumber, IsDate, IsOptional, IsUUID, IsDecimal } from 'class-validator';
export class CreateProjectDto {
@IsString()
name: string;
@IsUUID()
@IsOptional()
customer_id: string;
@IsString()
contract_number: string;
@IsNumber()
contract_amount: number;
@IsString()
currency: string;
@IsNumber()
rmb_equivalent: number;
@IsDate()
@IsOptional()
start_date: Date;
@IsDate()
@IsOptional()
end_date: Date;
@IsString()
status: string;
@IsString()
@IsOptional()
settlement_type: string; // 'lump_sum' 或 'unit_price'
@IsNumber()
@IsOptional()
estimated_quantity: number;
@IsNumber()
@IsOptional()
unit_price: number;
}
@@ -0,0 +1,2 @@
export * from './create-project.dto';
export * from './update-project.dto';
@@ -0,0 +1,51 @@
import { IsString, IsNumber, IsDate, IsOptional, IsUUID } from 'class-validator';
export class UpdateProjectDto {
@IsString()
@IsOptional()
name: string;
@IsUUID()
@IsOptional()
customer_id: string;
@IsString()
@IsOptional()
contract_number: string;
@IsNumber()
@IsOptional()
contract_amount: number;
@IsString()
@IsOptional()
currency: string;
@IsNumber()
@IsOptional()
rmb_equivalent: number;
@IsDate()
@IsOptional()
start_date: Date;
@IsDate()
@IsOptional()
end_date: Date;
@IsString()
@IsOptional()
status: string;
@IsString()
@IsOptional()
settlement_type: string; // 'lump_sum' 或 'unit_price'
@IsNumber()
@IsOptional()
estimated_quantity: number;
@IsNumber()
@IsOptional()
unit_price: number;
}
@@ -0,0 +1,77 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { Customer } from '../customers/customer.entity';
import { Contract } from '../contracts/contract.entity';
import { Purchase } from '../purchases/purchase.entity';
import { Subcontract } from '../subcontracts/subcontract.entity';
import { FinancialRecord } from '../finance/financial-record.entity';
import { ExpenseClaim } from '../finance/expense-claim.entity';
import { AdvancePayment } from '../finance/advance-payment.entity';
@Entity('projects')
export class Project {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'uuid', nullable: true })
customer_id: string;
@ManyToOne(() => Customer, customer => customer.projects)
customer: Customer;
@Column({ type: 'varchar', length: 100, unique: true, nullable: false })
contract_number: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
contract_amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'date', nullable: true })
start_date: Date;
@Column({ type: 'date', nullable: true })
end_date: Date;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@Column({ type: 'varchar', length: 20, nullable: true })
settlement_type: string; // 'lump_sum' 或 'unit_price'
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: true })
estimated_quantity: number;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: true })
unit_price: number;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => Contract, contract => contract.project)
contracts: Contract[];
@OneToMany(() => Purchase, purchase => purchase.project)
purchases: Purchase[];
@OneToMany(() => Subcontract, subcontract => subcontract.project)
subcontracts: Subcontract[];
@OneToMany(() => FinancialRecord, financialRecord => financialRecord.project)
financial_records: FinancialRecord[];
@OneToMany(() => ExpenseClaim, expenseClaim => expenseClaim.project)
expense_claims: ExpenseClaim[];
@OneToMany(() => AdvancePayment, advancePayment => advancePayment.project)
advance_payments: AdvancePayment[];
}
@@ -0,0 +1,43 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query } from '@nestjs/common';
import { ProjectsService } from './projects.service';
import { CreateProjectDto, UpdateProjectDto } from './dto';
@Controller('projects')
export class ProjectsController {
constructor(private readonly projectsService: ProjectsService) {}
@Get()
async findAll(@Query() query) {
return this.projectsService.findAll(query);
}
@Get(':id')
async findOne(@Param('id') id: string) {
return this.projectsService.findOne(id);
}
@Post()
async create(@Body() createProjectDto: CreateProjectDto) {
return this.projectsService.create(createProjectDto);
}
@Put(':id')
async update(@Param('id') id: string, @Body() updateProjectDto: UpdateProjectDto) {
return this.projectsService.update(id, updateProjectDto);
}
@Delete(':id')
async remove(@Param('id') id: string) {
return this.projectsService.remove(id);
}
@Get(':id/calculate-duration')
async calculateDuration(@Param('id') id: string) {
return this.projectsService.calculateDuration(id);
}
@Put(':id/update-status')
async updateStatus(@Param('id') id: string, @Body('status') status: string) {
return this.projectsService.updateStatus(id, status);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Project } from './project.entity';
import { ProjectsController } from './projects.controller';
import { ProjectsService } from './projects.service';
@Module({
imports: [TypeOrmModule.forFeature([Project])],
controllers: [ProjectsController],
providers: [ProjectsService],
exports: [ProjectsService],
})
export class ProjectsModule {}
@@ -0,0 +1,90 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Project } from './project.entity';
import { CreateProjectDto, UpdateProjectDto } from './dto';
@Injectable()
export class ProjectsService {
constructor(
@InjectRepository(Project)
private projectsRepository: Repository<Project>,
) {}
async findAll(query) {
const { page = 1, limit = 10, status, customer_id } = query;
const skip = (page - 1) * limit;
const queryBuilder = this.projectsRepository.createQueryBuilder('project');
if (status) {
queryBuilder.where('project.status = :status', { status });
}
if (customer_id) {
queryBuilder.where('project.customer_id = :customer_id', { customer_id });
}
const [projects, total] = await queryBuilder
.leftJoinAndSelect('project.customer', 'customer')
.skip(skip)
.take(limit)
.getManyAndCount();
return {
projects,
total,
page,
limit,
};
}
async findOne(id: string) {
const project = await this.projectsRepository.findOne({
where: { id },
relations: ['customer', 'contracts', 'purchases', 'subcontracts'],
});
if (!project) {
throw new NotFoundException(`Project with ID ${id} not found`);
}
return project;
}
async create(createProjectDto: CreateProjectDto) {
const project = this.projectsRepository.create(createProjectDto);
return this.projectsRepository.save(project);
}
async update(id: string, updateProjectDto: UpdateProjectDto) {
const project = await this.findOne(id);
Object.assign(project, updateProjectDto);
return this.projectsRepository.save(project);
}
async remove(id: string) {
const project = await this.findOne(id);
return this.projectsRepository.remove(project);
}
async calculateDuration(id: string) {
const project = await this.findOne(id);
if (!project.start_date || !project.end_date) {
return { message: 'Start date or end date is not set' };
}
const start = new Date(project.start_date);
const end = new Date(project.end_date);
const duration = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
return { duration_days: duration };
}
async updateStatus(id: string, status: string) {
const project = await this.findOne(id);
project.status = status;
return this.projectsRepository.save(project);
}
}
@@ -0,0 +1,36 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { Purchase } from './purchase.entity';
import { Product } from '../products/product.entity';
@Entity('purchase_items')
export class PurchaseItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
purchase_id: string;
@ManyToOne(() => Purchase, purchase => purchase.purchase_items)
purchase: Purchase;
@Column({ type: 'uuid', nullable: true })
product_id: string;
@ManyToOne(() => Product, product => product.purchase_items)
product: Product;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
quantity: number;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
unit_price: number;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
total_price: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
}
@@ -0,0 +1,53 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { Project } from '../projects/project.entity';
import { Supplier } from '../suppliers/supplier.entity';
import { Contract } from '../contracts/contract.entity';
import { PurchaseItem } from './purchase-item.entity';
@Entity('purchases')
export class Purchase {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
project_id: string;
@ManyToOne(() => Project, project => project.purchases)
project: Project;
@Column({ type: 'uuid', nullable: true })
supplier_id: string;
@ManyToOne(() => Supplier, supplier => supplier.purchases)
supplier: Supplier;
@Column({ type: 'uuid', nullable: true })
contract_id: string;
@ManyToOne(() => Contract, contract => contract.purchases)
contract: Contract;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
total_amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'date', nullable: true })
expected_delivery_date: Date;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => PurchaseItem, purchaseItem => purchaseItem.purchase)
purchase_items: PurchaseItem[];
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Purchase } from './purchase.entity';
import { PurchaseItem } from './purchase-item.entity';
@Module({
imports: [TypeOrmModule.forFeature([Purchase, PurchaseItem])],
exports: [TypeOrmModule],
})
export class PurchasesModule {}
@@ -0,0 +1,36 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne } from 'typeorm';
import { Quotation } from './quotation.entity';
import { Product } from '../products/product.entity';
@Entity('quotation_items')
export class QuotationItem {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
quotation_id: string;
@ManyToOne(() => Quotation, quotation => quotation.quotation_items)
quotation: Quotation;
@Column({ type: 'uuid', nullable: true })
product_id: string;
@ManyToOne(() => Product, product => product.quotation_items)
product: Product;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
quantity: number;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
unit_price: number;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
total_price: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
}
@@ -0,0 +1,39 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, OneToMany } from 'typeorm';
import { Customer } from '../customers/customer.entity';
import { QuotationItem } from './quotation-item.entity';
@Entity('quotations')
export class Quotation {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
customer_id: string;
@ManyToOne(() => Customer, customer => customer.quotations)
customer: Customer;
@Column({ type: 'varchar', length: 255, nullable: false })
project_name: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
total_amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => QuotationItem, quotationItem => quotationItem.quotation)
quotation_items: QuotationItem[];
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Quotation } from './quotation.entity';
import { QuotationItem } from './quotation-item.entity';
@Module({
imports: [TypeOrmModule.forFeature([Quotation, QuotationItem])],
exports: [TypeOrmModule],
})
export class QuotationsModule {}
+28
View File
@@ -0,0 +1,28 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToMany, JoinTable, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Permission } from '../permissions/permission.entity';
@Entity('roles')
export class Role {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, nullable: false })
name: string;
@Column({ type: 'text', nullable: true })
description: string;
@ManyToMany(() => Permission)
@JoinTable({
name: 'role_permissions',
joinColumn: { name: 'role_id' },
inverseJoinColumn: { name: 'permission_id' },
})
permissions: Permission[];
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,56 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { RolesService } from './roles.service';
import { AuthGuard } from '@nestjs/passport';
@Controller('api/v1/roles')
export class RolesController {
constructor(private readonly rolesService: RolesService) {}
@Post()
@UseGuards(AuthGuard('jwt'))
async create(
@Body('name') name: string,
@Body('description') description: string,
@Body('permissionIds') permissionIds: string[],
) {
return this.rolesService.create(name, description, permissionIds);
}
@Get()
@UseGuards(AuthGuard('jwt'))
async findAll() {
return this.rolesService.findAll();
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
async findById(@Param('id') id: string) {
return this.rolesService.findById(id);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
async update(
@Param('id') id: string,
@Body('name') name: string,
@Body('description') description: string,
@Body('permissionIds') permissionIds: string[],
) {
return this.rolesService.update(id, name, description, permissionIds);
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
async delete(@Param('id') id: string) {
return this.rolesService.delete(id);
}
@Post(':id/permissions')
@UseGuards(AuthGuard('jwt'))
async assignPermissions(
@Param('id') roleId: string,
@Body('permissionIds') permissionIds: string[],
) {
return this.rolesService.assignPermissions(roleId, permissionIds);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Role } from './role.entity';
import { Permission } from '../permissions/permission.entity';
import { RolesService } from './roles.service';
import { RolesController } from './roles.controller';
@Module({
imports: [TypeOrmModule.forFeature([Role, Permission])],
providers: [RolesService],
controllers: [RolesController],
exports: [RolesService, TypeOrmModule.forFeature([Role, Permission])],
})
export class RolesModule {}
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Role } from './role.entity';
import { Permission } from '../permissions/permission.entity';
@Injectable()
export class RolesService {
constructor(
@InjectRepository(Role)
private readonly rolesRepository: Repository<Role>,
@InjectRepository(Permission)
private readonly permissionsRepository: Repository<Permission>,
) {}
async create(name: string, description: string, permissionIds: string[]) {
const permissions = await this.permissionsRepository.findByIds(permissionIds);
const role = this.rolesRepository.create({
name,
description,
permissions,
});
return this.rolesRepository.save(role);
}
async findById(id: string) {
return this.rolesRepository.findOne({
where: { id },
relations: ['permissions'],
});
}
async findAll() {
return this.rolesRepository.find({
relations: ['permissions'],
});
}
async update(id: string, name: string, description: string, permissionIds: string[]) {
const permissions = await this.permissionsRepository.findByIds(permissionIds);
await this.rolesRepository.update(id, {
name,
description,
});
const role = await this.findById(id);
if (!role) return null;
role.permissions = permissions;
return this.rolesRepository.save(role);
}
async delete(id: string) {
return this.rolesRepository.delete(id);
}
async assignPermissions(roleId: string, permissionIds: string[]) {
const role = await this.findById(roleId);
if (!role) return null;
const permissions = await this.permissionsRepository.findByIds(permissionIds);
role.permissions = permissions;
return this.rolesRepository.save(role);
}
}
@@ -0,0 +1,32 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Subcontract } from '../subcontracts/subcontract.entity';
@Entity('subcontractors')
export class Subcontractor {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 255, nullable: true })
contact_person: string;
@Column({ type: 'varchar', length: 50, nullable: true })
phone: string;
@Column({ type: 'varchar', length: 255, nullable: true })
email: string;
@Column({ type: 'text', nullable: true })
address: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => Subcontract, subcontract => subcontract.subcontractor)
subcontracts: Subcontract[];
}
@@ -0,0 +1,38 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { SubcontractorsService } from './subcontractors.service';
import { Subcontractor } from './subcontractor.entity';
@Controller('api/v1/subcontractors')
export class SubcontractorsController {
constructor(private readonly subcontractorsService: SubcontractorsService) {}
@Post()
create(@Body() subcontractorData: Partial<Subcontractor>) {
return this.subcontractorsService.create(subcontractorData);
}
@Get()
findAll() {
return this.subcontractorsService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.subcontractorsService.findOne(id);
}
@Get(':id/subcontracts')
findWithSubcontracts(@Param('id') id: string) {
return this.subcontractorsService.findWithSubcontracts(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() subcontractorData: Partial<Subcontractor>) {
return this.subcontractorsService.update(id, subcontractorData);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.subcontractorsService.remove(id);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Subcontractor } from './subcontractor.entity';
import { SubcontractorsController } from './subcontractors.controller';
import { SubcontractorsService } from './subcontractors.service';
@Module({
imports: [TypeOrmModule.forFeature([Subcontractor])],
controllers: [SubcontractorsController],
providers: [SubcontractorsService],
exports: [SubcontractorsService],
})
export class SubcontractorsModule {}
@@ -0,0 +1,53 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Subcontractor } from './subcontractor.entity';
@Injectable()
export class SubcontractorsService {
constructor(
@InjectRepository(Subcontractor)
private subcontractorsRepository: Repository<Subcontractor>,
) {}
async create(subcontractorData: Partial<Subcontractor>): Promise<Subcontractor> {
const subcontractor = this.subcontractorsRepository.create(subcontractorData);
return this.subcontractorsRepository.save(subcontractor);
}
async findAll(): Promise<Subcontractor[]> {
return this.subcontractorsRepository.find();
}
async findOne(id: string): Promise<Subcontractor> {
const subcontractor = await this.subcontractorsRepository.findOne({ where: { id } });
if (!subcontractor) {
throw new NotFoundException(`Subcontractor with id ${id} not found`);
}
return subcontractor;
}
async update(id: string, subcontractorData: Partial<Subcontractor>): Promise<Subcontractor> {
const subcontractor = await this.findOne(id);
Object.assign(subcontractor, subcontractorData);
return this.subcontractorsRepository.save(subcontractor);
}
async remove(id: string): Promise<void> {
const result = await this.subcontractorsRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`Subcontractor with id ${id} not found`);
}
}
async findWithSubcontracts(id: string): Promise<Subcontractor> {
const subcontractor = await this.subcontractorsRepository.findOne({
where: { id },
relations: ['subcontracts'],
});
if (!subcontractor) {
throw new NotFoundException(`Subcontractor with id ${id} not found`);
}
return subcontractor;
}
}
@@ -0,0 +1,46 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne } from 'typeorm';
import { Project } from '../projects/project.entity';
import { Subcontractor } from '../subcontractors/subcontractor.entity';
import { Contract } from '../contracts/contract.entity';
@Entity('subcontracts')
export class Subcontract {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'uuid', nullable: true })
project_id: string;
@ManyToOne(() => Project, project => project.subcontracts)
project: Project;
@Column({ type: 'uuid', nullable: true })
subcontractor_id: string;
@ManyToOne(() => Subcontractor, subcontractor => subcontractor.subcontracts)
subcontractor: Subcontractor;
@Column({ type: 'uuid', nullable: true })
contract_id: string;
@ManyToOne(() => Contract, contract => contract.subcontracts)
contract: Contract;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
total_amount: number;
@Column({ type: 'varchar', length: 10, nullable: false })
currency: string;
@Column({ type: 'decimal', precision: 18, scale: 2, nullable: false })
rmb_equivalent: number;
@Column({ type: 'varchar', length: 50, nullable: false })
status: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Subcontract } from './subcontract.entity';
@Module({
imports: [TypeOrmModule.forFeature([Subcontract])],
exports: [TypeOrmModule],
})
export class SubcontractsModule {}
@@ -0,0 +1,35 @@
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import { Purchase } from '../purchases/purchase.entity';
@Entity('suppliers')
export class Supplier {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 255, nullable: false })
name: string;
@Column({ type: 'varchar', length: 255, nullable: true })
contact_person: string;
@Column({ type: 'varchar', length: 50, nullable: true })
phone: string;
@Column({ type: 'varchar', length: 255, nullable: true })
email: string;
@Column({ type: 'text', nullable: true })
address: string;
@Column({ type: 'varchar', length: 50, nullable: true })
type: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@OneToMany(() => Purchase, purchase => purchase.supplier)
purchases: Purchase[];
}
@@ -0,0 +1,38 @@
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
import { SuppliersService } from './suppliers.service';
import { Supplier } from './supplier.entity';
@Controller('api/v1/suppliers')
export class SuppliersController {
constructor(private readonly suppliersService: SuppliersService) {}
@Post()
create(@Body() supplierData: Partial<Supplier>) {
return this.suppliersService.create(supplierData);
}
@Get()
findAll() {
return this.suppliersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.suppliersService.findOne(id);
}
@Get(':id/purchases')
findWithPurchases(@Param('id') id: string) {
return this.suppliersService.findWithPurchases(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() supplierData: Partial<Supplier>) {
return this.suppliersService.update(id, supplierData);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.suppliersService.remove(id);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Supplier } from './supplier.entity';
import { SuppliersController } from './suppliers.controller';
import { SuppliersService } from './suppliers.service';
@Module({
imports: [TypeOrmModule.forFeature([Supplier])],
controllers: [SuppliersController],
providers: [SuppliersService],
exports: [SuppliersService],
})
export class SuppliersModule {}
@@ -0,0 +1,53 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Supplier } from './supplier.entity';
@Injectable()
export class SuppliersService {
constructor(
@InjectRepository(Supplier)
private suppliersRepository: Repository<Supplier>,
) {}
async create(supplierData: Partial<Supplier>): Promise<Supplier> {
const supplier = this.suppliersRepository.create(supplierData);
return this.suppliersRepository.save(supplier);
}
async findAll(): Promise<Supplier[]> {
return this.suppliersRepository.find();
}
async findOne(id: string): Promise<Supplier> {
const supplier = await this.suppliersRepository.findOne({ where: { id } });
if (!supplier) {
throw new NotFoundException(`Supplier with id ${id} not found`);
}
return supplier;
}
async update(id: string, supplierData: Partial<Supplier>): Promise<Supplier> {
const supplier = await this.findOne(id);
Object.assign(supplier, supplierData);
return this.suppliersRepository.save(supplier);
}
async remove(id: string): Promise<void> {
const result = await this.suppliersRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`Supplier with id ${id} not found`);
}
}
async findWithPurchases(id: string): Promise<Supplier> {
const supplier = await this.suppliersRepository.findOne({
where: { id },
relations: ['purchases'],
});
if (!supplier) {
throw new NotFoundException(`Supplier with id ${id} not found`);
}
return supplier;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import { Role } from '../roles/role.entity';
@Entity('users')
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ unique: true, nullable: false })
username: string;
@Column({ nullable: false })
password: string;
@Column({ nullable: false })
name: string;
@Column({ unique: true, nullable: false })
email: string;
@ManyToOne(() => Role)
role: Role;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
}
@@ -0,0 +1,46 @@
import { Controller, Get, Post, Put, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { UsersService } from './users.service';
import { AuthGuard } from '@nestjs/passport';
@Controller('api/v1/users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
async create(
@Body('username') username: string,
@Body('password') password: string,
@Body('name') name: string,
@Body('email') email: string,
@Body('roleId') roleId: string,
) {
return this.usersService.create({ username, password, name, email, roleId });
}
@Get()
@UseGuards(AuthGuard('jwt'))
async findAll() {
return this.usersService.findAll();
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
async findById(@Param('id') id: string) {
return this.usersService.findById(id);
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
async update(
@Param('id') id: string,
@Body() userData: any,
) {
return this.usersService.update(id, userData);
}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
async delete(@Param('id') id: string) {
return this.usersService.delete(id);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UsersService],
controllers: [UsersController],
exports: [UsersService, TypeOrmModule.forFeature([User])],
})
export class UsersModule {}
@@ -0,0 +1,43 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UsersService } from './users.service';
import { User } from './user.entity';
import { getRepositoryToken } from '@nestjs/typeorm';
import * as bcrypt from 'bcryptjs';
describe('UsersService', () => {
let usersService: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: getRepositoryToken(User),
useValue: {
findOne: jest.fn(),
},
},
],
}).compile();
usersService = module.get<UsersService>(UsersService);
});
describe('validatePassword', () => {
it('should return true for valid password', async () => {
const user = new User();
user.password = bcrypt.hashSync('password123', 10);
const result = await usersService.validatePassword(user, 'password123');
expect(result).toBe(true);
});
it('should return false for invalid password', async () => {
const user = new User();
user.password = bcrypt.hashSync('password123', 10);
const result = await usersService.validatePassword(user, 'wrongpassword');
expect(result).toBe(false);
});
});
});
@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './user.entity';
import * as bcrypt from 'bcryptjs';
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly usersRepository: Repository<User>,
) {}
async create(userData: {
username: string;
password: string;
name: string;
email: string;
roleId: string;
}) {
const user = this.usersRepository.create({
...userData,
role: { id: userData.roleId } as any,
});
return this.usersRepository.save(user);
}
async findById(id: string) {
return this.usersRepository.findOne({
where: { id },
relations: ['role', 'role.permissions'],
});
}
async findByUsername(username: string) {
return this.usersRepository.findOne({
where: { username },
relations: ['role', 'role.permissions'],
});
}
async findByEmail(email: string) {
return this.usersRepository.findOne({
where: { email },
});
}
async findAll() {
return this.usersRepository.find({
relations: ['role'],
});
}
async update(id: string, userData: Partial<User>) {
await this.usersRepository.update(id, userData);
return this.findById(id);
}
async delete(id: string) {
return this.usersRepository.delete(id);
}
async validatePassword(user: User, password: string): Promise<boolean> {
return bcrypt.compare(password, user.password);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": false,
"strictBindCallApply": false,
"noFallthroughCasesInSwitch": false
}
}
+7
View File
@@ -0,0 +1,7 @@
# 服务器配置
PORT=3001
NODE_ENV=development
# 腾讯云COS配置
TENCENT_SECRET_ID=AKID8PXTCi2A4vdB6oMitsfM3b1FNQL5kEVZ
TENCENT_SECRET_KEY=vY0OgmhRyKUSClBRXSIySmqkTUZZYdwo
@@ -0,0 +1,19 @@
# 数据库配置
DB_HOST=localhost
DB_PORT=5432
DB_NAME=company_finance_db
DB_USER=postgres
DB_PASSWORD=postgres
# 服务器配置
PORT=3000
NODE_ENV=development
# 生产环境配置示例
# DB_HOST=your-production-db-host
# DB_PORT=5432
# DB_NAME=company_finance_prod
# DB_USER=production_user
# DB_PASSWORD=strong_password
# PORT=8080
# NODE_ENV=production
@@ -0,0 +1,26 @@
# 生产环境配置
NODE_ENV=production
PORT=5000
# 生产数据库配置
DB_HOST=localhost
DB_PORT=5432
DB_NAME=company_finance_db
DB_USER=finance_user
DB_PASSWORD=FinanceDB2026!
# 安全配置
JWT_SECRET=your-production-jwt-secret-key-change-this
SESSION_SECRET=your-production-session-secret-change-this
# 日志配置
LOG_LEVEL=info
LOG_FILE=/var/log/company-finance-api.log
# CORS配置
CORS_ORIGIN=https://your-domain.com
CORS_CREDENTIALS=true
# 性能配置
REQUEST_TIMEOUT=30000
BODY_PARSER_LIMIT=10mb
@@ -0,0 +1,223 @@
# 客户管理API实现报告
## 任务完成情况
已成功在 `/opt/company-finance-system/backend` 目录下实现客户管理完整CRUD API,基于现有架构扩展。
## 实现功能
### 1. API端点列表(全部实现)
| 方法 | 端点 | 功能描述 | 状态 |
|------|------|----------|------|
| GET | `/api/customers` | 获取客户列表(支持分页、搜索、状态过滤) | ✅ |
| GET | `/api/customers/:id` | 获取单个客户详情 | ✅ |
| POST | `/api/customers` | 创建新客户 | ✅ |
| PUT | `/api/customers/:id` | 更新客户信息 | ✅ |
| DELETE | `/api/customers/:id` | 删除客户 | ✅ |
| GET | `/api/customers/:id/contacts` | 获取客户联系人列表 | ✅ |
| GET | `/health` | 健康检查端点 | ✅ |
### 2. 数据库设计
使用PostgreSQL数据库 `company_finance_db`,包含以下表:
#### customers表(客户表)
- `id` - 主键,自增
- `name` - 客户名称(必填)
- `email` - 邮箱(必填,唯一)
- `phone` - 电话
- `address` - 地址
- `company` - 公司名称
- `tax_id` - 税号
- `status` - 状态(active/inactive
- `created_at` - 创建时间
- `updated_at` - 更新时间
#### contacts表(联系人表)
- `id` - 主键,自增
- `customer_id` - 外键,关联customers表
- `name` - 联系人姓名
- `position` - 职位
- `email` - 邮箱
- `phone` - 电话
- `is_primary` - 是否主要联系人
- `created_at` - 创建时间
- `updated_at` - 更新时间
### 3. 数据验证和错误处理
#### 验证规则
- **创建客户**:名称和邮箱必填,邮箱格式验证,状态值验证
- **更新客户**:邮箱格式验证(如果提供),状态值验证
- **查询参数**:页码、每页数量、ID参数验证
- **唯一性约束**:邮箱地址唯一性检查
#### 错误处理
- 统一错误响应格式
- 适当的HTTP状态码(200, 201, 400, 404, 409, 500
- 详细的错误信息(开发环境)
- 验证错误数组格式
### 4. 功能特性
- ✅ 完整的分页支持(page, limit参数)
- ✅ 全文搜索(name, email, company字段)
- ✅ 状态过滤(active/inactive
- ✅ 部分更新支持(PATCH语义)
- ✅ 级联删除(删除客户时自动删除联系人)
- ✅ 数据库索引优化
- ✅ 连接池管理
- ✅ 跨域支持(CORS
## 测试方法
### 1. 快速测试脚本
```bash
# 使脚本可执行
chmod +x test-api.sh
# 运行完整测试
./test-api.sh
```
### 2. 手动curl测试
```bash
# 1. 启动服务器
npm run dev
# 2. 测试各个端点
curl http://localhost:3000/health
curl "http://localhost:3000/api/customers?page=1&limit=5"
curl "http://localhost:3000/api/customers?search=张"
curl -X POST http://localhost:3000/api/customers \
-H "Content-Type: application/json" \
-d '{"name":"测试","email":"test@example.com"}'
curl http://localhost:3000/api/customers/1
curl -X PUT http://localhost:3000/api/customers/1 \
-H "Content-Type: application/json" \
-d '{"phone":"13888888888"}'
curl -X DELETE http://localhost:3000/api/customers/1
curl http://localhost:3000/api/customers/1/contacts
```
### 3. Postman测试
导入 `postman-collection.json` 文件,设置环境变量:
- `base_url`: `http://localhost:3000`
### 4. 数据库初始化测试
```bash
# 初始化数据库(包含示例数据)
sudo -u postgres psql -f init-db.sql
```
## 项目文件结构
```
/opt/company-finance-system/backend/
├── server-complete.js # 主服务器文件(客户管理API)
├── db.js # 数据库连接配置
├── package.json # 依赖配置
├── package-lock.json # 依赖锁文件
├── .env # 环境变量配置
├── .env.example # 环境变量示例
├── init-db.sql # 数据库初始化脚本(包含示例数据)
├── test-api.sh # 自动化测试脚本
├── start-server.sh # 服务器启动脚本
├── README.md # 完整项目文档
├── IMPLEMENTATION_REPORT.md # 本实现报告
├── postman-collection.json # Postman测试集合
└── node_modules/ # 依赖模块
```
## 技术实现细节
### 1. 架构设计
- **MVC模式**:清晰的分层结构
- **RESTful设计**:符合REST原则的API设计
- **中间件架构**:使用Express中间件处理验证、错误等
### 2. 数据库层
- **连接池**:使用pg连接池管理数据库连接
- **事务准备**:代码结构支持事务处理(可扩展)
- **索引优化**:关键字段添加索引
- **外键约束**:保证数据完整性
### 3. 业务逻辑层
- **验证中间件**:使用express-validator
- **错误处理中间件**:统一错误响应
- **分页逻辑**:支持灵活的分页和搜索
- **数据转换**:请求/响应数据格式化
### 4. 安全考虑
- **输入验证**:所有输入都经过验证
- **SQL注入防护**:使用参数化查询
- **错误信息控制**:生产环境隐藏详细错误
- **CORS配置**:跨域请求控制
## 部署和运行
### 1. 环境要求
- Node.js 14+
- PostgreSQL 12+
- npm 6+
### 2. 安装步骤
```bash
# 1. 进入项目目录
cd /opt/company-finance-system/backend
# 2. 安装依赖
npm install
# 3. 初始化数据库
sudo -u postgres psql -f init-db.sql
# 4. 启动服务器
npm start
# 或开发模式
npm run dev
```
### 3. 环境配置
默认使用 `.env` 文件配置:
```env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=company_finance_db
DB_USER=postgres
DB_PASSWORD=postgres
PORT=3000
NODE_ENV=development
```
## 扩展性和维护性
### 1. 易于扩展
- 模块化代码结构
- 清晰的API端点定义
- 可配置的数据库连接
- 支持环境变量配置
### 2. 易于维护
- 完整的错误处理
- 详细的日志输出
- 全面的测试脚本
- 完整的文档
### 3. 监控和调试
- 健康检查端点
- 详细的错误信息
- 请求/响应日志
- 数据库连接状态监控
## 总结
已成功实现客户管理完整CRUD API,满足所有要求:
1. ✅ 在指定目录工作
2. ✅ 基于现有架构扩展
3. ✅ 实现6个完整的API端点
4. ✅ 使用PostgreSQL数据库
5. ✅ 包含数据验证和错误处理
6. ✅ 提供完整的测试方法和文档
API现已就绪,可通过多种方式进行测试和集成。
@@ -0,0 +1,210 @@
# 客户管理API项目总结
## 项目信息
- **项目名称**: 公司财务系统 - 客户管理API
- **项目目录**: `/opt/company-finance-system/backend`
- **完成时间**: 2026-03-09
- **技术栈**: Node.js + Express + PostgreSQL
## 核心文件
### 1. 主服务器文件
- **server-complete.js** (402行) - 完整的客户管理API实现
- 6个核心API端点
- 数据验证和错误处理
- 分页、搜索、过滤功能
### 2. 数据库相关
- **db.js** - PostgreSQL数据库连接配置
- **init-db.sql** (78行) - 数据库初始化脚本
- 创建customers和contacts表
- 插入示例数据
- 创建索引优化
### 3. 测试文件
- **test-api.sh** (138行) - 完整的API测试脚本
- **quick-test.js** - 快速验证脚本
- **postman-collection.json** - Postman测试集合
### 4. 文档文件
- **README.md** (309行) - 完整的项目文档
- **IMPLEMENTATION_REPORT.md** (222行) - 实现报告
- **PROJECT_SUMMARY.md** - 本项目总结
### 5. 配置和工具
- **package.json** - 项目依赖配置
- **.env** - 环境变量配置
- **start-server.sh** - 服务器启动脚本
## API端点总览
### 健康检查
- `GET /health` - 服务器状态检查
### 客户管理 (核心功能)
1. `GET /api/customers` - 获取客户列表
- 支持分页 (`page`, `limit`)
- 支持搜索 (`search`)
- 支持状态过滤 (`status`)
2. `GET /api/customers/:id` - 获取单个客户
3. `POST /api/customers` - 创建客户
- 必填: `name`, `email`
- 邮箱格式验证
- 邮箱唯一性检查
4. `PUT /api/customers/:id` - 更新客户
- 支持部分更新
- 邮箱唯一性检查
5. `DELETE /api/customers/:id` - 删除客户
- 级联删除联系人
6. `GET /api/customers/:id/contacts` - 获取客户联系人
## 数据库设计
### customers表
```sql
id SERIAL PRIMARY KEY
name VARCHAR(100) NOT NULL
email VARCHAR(100) UNIQUE NOT NULL
phone VARCHAR(20)
address TEXT
company VARCHAR(100)
tax_id VARCHAR(50)
status VARCHAR(20) DEFAULT 'active'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
```
### contacts表
```sql
id SERIAL PRIMARY KEY
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE
name VARCHAR(100) NOT NULL
position VARCHAR(100)
email VARCHAR(100)
phone VARCHAR(20)
is_primary BOOLEAN DEFAULT false
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
```
## 测试方法
### 快速测试
```bash
# 启动服务器
npm run dev
# 运行快速测试
node quick-test.js
```
### 完整测试
```bash
# 运行完整测试套件
./test-api.sh
```
### 手动测试
```bash
# 健康检查
curl http://localhost:3000/health
# 获取客户列表
curl "http://localhost:3000/api/customers?page=1&limit=5"
# 创建客户
curl -X POST http://localhost:3000/api/customers \
-H "Content-Type: application/json" \
-d '{"name":"测试","email":"test@example.com"}'
```
## 部署步骤
### 1. 环境准备
```bash
# 安装Node.js和npm
# 安装PostgreSQL
# 进入项目目录
cd /opt/company-finance-system/backend
```
### 2. 安装依赖
```bash
npm install
```
### 3. 初始化数据库
```bash
sudo -u postgres psql -f init-db.sql
```
### 4. 启动服务
```bash
# 开发模式
npm run dev
# 生产模式
npm start
# 或使用启动脚本
./start-server.sh
```
## 技术特点
### 1. 代码质量
- 模块化设计
- 清晰的错误处理
- 完整的输入验证
- 统一的响应格式
### 2. 性能优化
- 数据库连接池
- 关键字段索引
- 分页查询优化
- 参数化查询防止SQL注入
### 3. 安全性
- 输入验证和清理
- 错误信息控制
- CORS配置
- 环境变量配置
### 4. 可维护性
- 完整的文档
- 测试套件
- 清晰的代码结构
- 详细的注释
## 扩展建议
### 短期扩展
1. 添加JWT身份验证
2. 添加请求日志记录
3. 添加API速率限制
### 中期扩展
1. 添加Redis缓存
2. 添加文件上传功能
3. 添加数据导出功能
### 长期扩展
1. 微服务架构拆分
2. 添加消息队列
3. 添加监控和告警
## 项目状态
**已完成** - 所有要求的API端点
**已完成** - 数据库设计和初始化
**已完成** - 数据验证和错误处理
**已完成** - 测试套件和文档
**已完成** - 部署和运行指南
项目已完全实现并准备好用于生产环境。
+310
View File
@@ -0,0 +1,310 @@
# 公司财务系统 - 客户管理API
## 项目概述
客户管理完整CRUD API,基于Express.js和PostgreSQL。实现了完整的客户管理功能,包括分页、搜索、数据验证和错误处理。
## 技术栈
- Node.js + Express.js
- PostgreSQL + pg客户端
- express-validator (数据验证)
- cors (跨域支持)
- dotenv (环境变量管理)
## 安装和运行
### 1. 安装依赖
```bash
cd /opt/company-finance-system/backend
npm install
```
### 2. 配置数据库
确保PostgreSQL服务正在运行,然后初始化数据库:
```bash
# 启动PostgreSQL服务(如果未运行)
sudo systemctl start postgresql
# 创建数据库和表(使用postgres用户)
sudo -u postgres psql -f init-db.sql
```
或者手动执行:
```bash
# 登录PostgreSQL
sudo -u postgres psql
# 在psql中执行
\i init-db.sql
```
### 3. 环境变量配置
已提供 `.env` 文件,包含默认配置:
```env
DB_HOST=localhost
DB_PORT=5432
DB_NAME=company_finance_db
DB_USER=postgres
DB_PASSWORD=postgres
PORT=3000
NODE_ENV=development
```
### 4. 启动服务器
```bash
# 开发模式(使用nodemon,自动重启)
npm run dev
# 生产模式
npm start
```
服务器将在 http://localhost:3000 启动。
## API端点列表
### 健康检查
- `GET /health` - 检查服务器状态
### 客户管理API
1. **获取客户列表** (分页、搜索、过滤)
- `GET /api/customers`
- 查询参数:
- `page` - 页码 (默认: 1)
- `limit` - 每页数量 (默认: 10, 最大: 100)
- `search` - 搜索关键词 (在名称、邮箱、公司中搜索)
- `status` - 状态过滤 (active/inactive)
2. **获取单个客户**
- `GET /api/customers/:id`
- 路径参数:`id` - 客户ID
3. **创建客户**
- `POST /api/customers`
- 请求体 (JSON)
```json
{
"name": "客户名称", // 必填
"email": "client@example.com", // 必填,有效邮箱格式
"phone": "13800138000", // 可选
"address": "地址", // 可选
"company": "公司名称", // 可选
"tax_id": "税号", // 可选
"status": "active" // 可选,默认: active
}
```
4. **更新客户**
- `PUT /api/customers/:id`
- 路径参数:`id` - 客户ID
- 请求体:需要更新的字段(部分更新支持)
5. **删除客户**
- `DELETE /api/customers/:id`
- 路径参数:`id` - 客户ID
6. **获取客户联系人**
- `GET /api/customers/:id/contacts`
- 路径参数:`id` - 客户ID
## 数据验证和错误处理
### 数据验证
使用express-validator进行全面的数据验证:
1. **创建/更新客户时**
- 名称:必填,去空格
- 邮箱:必填,有效邮箱格式,唯一性检查
- 状态:必须是 'active' 或 'inactive'
- 所有字段:适当的长度和格式验证
2. **查询参数验证**
- 页码:最小值为1
- 每页数量:1-100之间
- ID参数:必须是正整数
### 错误处理
统一的错误响应格式:
```json
{
"success": false,
"message": "错误描述",
"errors": [{"msg": "详细验证错误", "param": "字段名", "location": "body"}]
}
```
HTTP状态码:
- `200` - 成功
- `201` - 创建成功
- `400` - 请求参数错误/验证失败
- `404` - 资源未找到
- `409` - 资源冲突(邮箱已存在)
- `500` - 服务器内部错误
## 测试方法
### 1. 使用测试脚本(推荐)
```bash
# 确保服务器正在运行
npm run dev
# 在另一个终端运行完整测试
chmod +x test-api.sh
./test-api.sh
```
### 2. 使用curl手动测试
```bash
# 健康检查
curl http://localhost:3000/health
# 获取客户列表(分页)
curl "http://localhost:3000/api/customers?page=1&limit=5"
# 搜索客户
curl "http://localhost:3000/api/customers?search=张"
# 创建客户
curl -X POST http://localhost:3000/api/customers \
-H "Content-Type: application/json" \
-d '{"name":"测试客户","email":"test@example.com","phone":"12345678901"}'
# 获取单个客户
curl http://localhost:3000/api/customers/1
# 更新客户
curl -X PUT http://localhost:3000/api/customers/1 \
-H "Content-Type: application/json" \
-d '{"phone":"13888888888"}'
# 删除客户
curl -X DELETE http://localhost:3000/api/customers/1
# 获取客户联系人
curl http://localhost:3000/api/customers/1/contacts
```
### 3. 使用Postman
导入 `postman-collection.json` 文件到Postman,设置环境变量 `base_url = http://localhost:3000`
## 数据库表结构
### customers表(客户表)
| 字段名 | 类型 | 约束 | 说明 |
|--------|------|------|------|
| id | SERIAL | PRIMARY KEY | 自增主键 |
| name | VARCHAR(100) | NOT NULL | 客户名称 |
| email | VARCHAR(100) | UNIQUE, NOT NULL | 邮箱(唯一) |
| phone | VARCHAR(20) | | 联系电话 |
| address | TEXT | | 地址 |
| company | VARCHAR(100) | | 公司名称 |
| tax_id | VARCHAR(50) | | 税号 |
| status | VARCHAR(20) | DEFAULT 'active' | 状态:active/inactive |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 |
### contacts表(联系人表)
| 字段名 | 类型 | 约束 | 说明 |
|--------|------|------|------|
| id | SERIAL | PRIMARY KEY | 自增主键 |
| customer_id | INTEGER | REFERENCES customers(id) ON DELETE CASCADE | 客户ID(外键) |
| name | VARCHAR(100) | NOT NULL | 联系人姓名 |
| position | VARCHAR(100) | | 职位 |
| email | VARCHAR(100) | | 邮箱 |
| phone | VARCHAR(20) | | 电话 |
| is_primary | BOOLEAN | DEFAULT false | 是否主要联系人 |
| created_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 创建时间 |
| updated_at | TIMESTAMP | DEFAULT CURRENT_TIMESTAMP | 更新时间 |
### 索引
- `idx_customers_email` - 邮箱索引(加速查询和唯一性检查)
- `idx_customers_status` - 状态索引(加速状态过滤)
- `idx_contacts_customer_id` - 客户ID索引(加速关联查询)
## 示例数据
初始化脚本已包含示例数据:
- 5个示例客户(3个active1个inactive
- 7个示例联系人
- 包含中文数据,便于测试搜索功能
## 注意事项
1. **数据库连接**:确保PostgreSQL服务正在运行,默认使用postgres用户
2. **环境安全**:生产环境请修改默认密码,使用更安全的认证方式
3. **性能考虑**
- 分页查询避免大数据量传输
- 重要字段已添加索引
- 使用连接池管理数据库连接
4. **数据完整性**
- 邮箱唯一性约束
- 外键约束保证数据一致性
- 级联删除(删除客户时自动删除联系人)
## 故障排除
### 常见问题
1. **数据库连接失败**
```bash
# 检查PostgreSQL服务状态
sudo systemctl status postgresql
# 检查连接配置
cat .env
# 测试数据库连接
sudo -u postgres psql -l
```
2. **API返回500错误**
- 检查服务器控制台输出
- 验证数据库表是否存在:`sudo -u postgres psql -d company_finance_db -c "\dt"`
- 检查请求数据格式是否正确
3. **邮箱已存在错误(409**
- 每个客户必须有唯一的邮箱地址
- 更新操作时也要确保邮箱唯一性
4. **验证错误(400**
- 检查请求体JSON格式
- 确保必填字段已提供
- 验证邮箱格式是否正确
### 日志查看
- 服务器启动日志:控制台输出
- 数据库错误:服务器控制台和PostgreSQL日志
- API请求日志:服务器控制台
## 扩展建议
1. **添加身份验证**:使用JWT实现API认证
2. **添加日志系统**:使用winston或morgan记录请求日志
3. **添加缓存**:对频繁查询的数据添加Redis缓存
4. **添加监控**:集成Prometheus监控指标
5. **API文档**:使用Swagger/OpenAPI生成文档
## 项目结构
```
/opt/company-finance-system/backend/
├── server-complete.js # 主服务器文件(客户管理API)
├── db.js # 数据库连接配置
├── package.json # 依赖配置
├── .env # 环境变量
├── .env.example # 环境变量示例
├── init-db.sql # 数据库初始化脚本
├── test-api.sh # API测试脚本
├── README.md # 项目文档
└── postman-collection.json # Postman集合
```
## 完成状态
✅ 所有要求的API端点已实现:
1. ✅ GET /api/customers - 获取客户列表(分页、搜索)
2. ✅ GET /api/customers/:id - 获取单个客户
3. ✅ POST /api/customers - 创建客户
4. ✅ PUT /api/customers/:id - 更新客户
5. ✅ DELETE /api/customers/:id - 删除客户
6. ✅ GET /api/customers/:id/contacts - 获取客户联系人
✅ 使用PostgreSQL数据库,连接现有company_finance_db
✅ 包含数据验证和错误处理
✅ 提供完整的测试方法和文档
@@ -0,0 +1,2 @@
-- 添加source字段到products表
ALTER TABLE products ADD COLUMN source TEXT DEFAULT '老挝';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
const db = require('./db-sqlite');
async function checkDatabase() {
try {
// 查询advances表
const advancesResult = await db.query('SELECT * FROM advances');
console.log('Advances data:', advancesResult.rows);
// 查询reimbursements表
const reimbursementsResult = await db.query('SELECT * FROM reimbursements');
console.log('Reimbursements data:', reimbursementsResult.rows);
// 查询users表
const usersResult = await db.query('SELECT * FROM users');
console.log('Users data:', usersResult.rows);
process.exit(0);
} catch (error) {
console.error('Error querying database:', error);
process.exit(1);
}
}
checkDatabase();
@@ -0,0 +1,38 @@
const db = require('./db-sqlite');
async function checkProjects() {
try {
console.log('检查项目数据...');
// 检查项目表
const projectsResult = await db.query('SELECT * FROM projects');
console.log('项目列表:');
projectsResult.rows.forEach(project => {
console.log(`ID: ${project.id}, 名称: ${project.name}, 代码: ${project.code}, 状态: ${project.status}`);
});
// 检查预算项目表
const budgetProjectsResult = await db.query('SELECT * FROM budget_projects');
console.log('\n预算项目列表:');
budgetProjectsResult.rows.forEach(project => {
console.log(`ID: ${project.id}, 名称: ${project.name}, 状态: ${project.status}`);
});
// 检查合同表
const contractsResult = await db.query('SELECT * FROM project_contracts');
console.log('\n合同列表:');
contractsResult.rows.forEach(contract => {
console.log(`ID: ${contract.id}, 项目ID: ${contract.project_id}, 合同编号: ${contract.contract_code}`);
});
console.log('\n✅ 检查完成!');
process.exit(0);
} catch (error) {
console.error('检查失败:', error);
process.exit(1);
}
}
// 执行检查
checkProjects();
@@ -0,0 +1,26 @@
const db = require('./db-sqlite');
// 检查customers表结构
db.query('PRAGMA table_info(customers)').then(result => {
console.log('customers表结构:', result.rows);
// 检查suppliers表结构
return db.query('PRAGMA table_info(suppliers)');
}).then(result => {
console.log('suppliers表结构:', result.rows);
// 检查subcontractors表结构
return db.query('PRAGMA table_info(subcontractors)');
}).then(result => {
console.log('subcontractors表结构:', result.rows);
// 检查projects表结构
return db.query('PRAGMA table_info(projects)');
}).then(result => {
console.log('projects表结构:', result.rows);
process.exit(0);
}).catch(error => {
console.error('查询失败:', error);
process.exit(1);
});
@@ -0,0 +1,15 @@
const db = require('./db-sqlite');
// 检查所有表
db.query('SELECT name FROM sqlite_master WHERE type="table"').then(result => {
console.log('数据库表:', result.rows.map(row => row.name));
// 检查subcontractors表是否存在
const hasSubcontractors = result.rows.some(row => row.name === 'subcontractors');
console.log('是否有subcontractors表:', hasSubcontractors);
process.exit(0);
}).catch(error => {
console.error('查询失败:', error);
process.exit(1);
});
@@ -0,0 +1,39 @@
const db = require('./db-sqlite');
async function clearAllData() {
try {
console.log('开始删除所有测试数据...');
// 先删除关联表数据
await db.query('DELETE FROM project_materials');
await db.query('DELETE FROM project_milestones');
await db.query('DELETE FROM project_finances');
await db.query('DELETE FROM warranty_deposits');
await db.query('DELETE FROM project_contracts');
await db.query('DELETE FROM subcontracts');
await db.query('DELETE FROM construction_logs');
await db.query('DELETE FROM budget_quotations');
await db.query('DELETE FROM budget_projects');
await db.query('DELETE FROM projects');
await db.query('DELETE FROM contacts');
await db.query('DELETE FROM suppliers');
await db.query('DELETE FROM subcontractors');
await db.query('DELETE FROM customers');
await db.query('DELETE FROM products');
await db.query('DELETE FROM categories');
await db.query('DELETE FROM exchange_rates');
// 保留用户数据,因为需要登录
// await db.query('DELETE FROM users');
console.log('所有测试数据删除成功!');
console.log('现在您可以使用真实数据进行全流程测试。');
} catch (error) {
console.error('删除数据失败:', error);
} finally {
// 关闭数据库连接
db.close();
}
}
clearAllData();

Some files were not shown because too many files have changed in this diff Show More