后端第一步开发完成,第一版回归测试完成
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
# MySQL(与 schema.sql 中 ocean 库一致)
|
||||||
|
# 建议使用专用用户 ocean,见 README-DB.md「创建专用用户」
|
||||||
|
MYSQL_HOST=127.0.0.1
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=ocean
|
||||||
|
MYSQL_PASSWORD=ocean123
|
||||||
|
MYSQL_DATABASE=ocean
|
||||||
|
|
||||||
|
# 可选
|
||||||
|
# TOKEN_EXPIRE_SECONDS=86400
|
||||||
+11
-7
@@ -58,7 +58,7 @@
|
|||||||
| 项目 | 说明 |
|
| 项目 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **接口** | `POST /api/projects/list` |
|
| **接口** | `POST /api/projects/list` |
|
||||||
| **说明** | 分页获取项目列表,支持按名称/编号搜索、按进度/费用筛选 |
|
| **说明** | 分页获取项目列表,支持按名称/编号搜索、按进度/费用筛选、时间筛选、日期异常筛选 |
|
||||||
| **权限** | 已登录,所有角色可访问 |
|
| **权限** | 已登录,所有角色可访问 |
|
||||||
|
|
||||||
#### 请求参数
|
#### 请求参数
|
||||||
@@ -71,6 +71,10 @@
|
|||||||
| keyword | string | 否 | 搜索关键词,与 searchType 配合使用 |
|
| keyword | string | 否 | 搜索关键词,与 searchType 配合使用 |
|
||||||
| progress | string | 否 | 项目进度筛选,取值与业务枚举一致 |
|
| progress | string | 否 | 项目进度筛选,取值与业务枚举一致 |
|
||||||
| cost | string | 否 | 项目费用筛选,取值与业务枚举一致 |
|
| cost | string | 否 | 项目费用筛选,取值与业务枚举一致 |
|
||||||
|
| dateFilterType | string | 否 | 时间筛选维度:`signDate`(签订日期)、`startDate`(开工日期)、`plannedCompletionDate`(计划竣工日期)、`actualCompletionDate`(实际竣工日期);仅对「日期正常」项目按 DATE 筛选 |
|
||||||
|
| dateFrom | string | 否 | 时间范围起(YYYY-MM-DD),与 dateFilterType 配合 |
|
||||||
|
| dateTo | string | 否 | 时间范围止(YYYY-MM-DD),与 dateFilterType 配合 |
|
||||||
|
| dateAbnormal | boolean | 否 | 为 true 时只返回「日期异常」项目(四个日期中任意一个非「日期正常」) |
|
||||||
|
|
||||||
#### 响应参数(成功,HTTP 200)
|
#### 响应参数(成功,HTTP 200)
|
||||||
|
|
||||||
@@ -261,7 +265,7 @@
|
|||||||
| subItemCount | number | 子项个数 |
|
| subItemCount | number | 子项个数 |
|
||||||
| subItemCode | string | 子项编码 |
|
| subItemCode | string | 子项编码 |
|
||||||
| totalInvestment | number | 项目总投资(万元) |
|
| totalInvestment | number | 项目总投资(万元) |
|
||||||
| bidContractAmount | number | 中标合同金额(万元) |
|
| bidContractAmount | string | 中标合同金额(万元,字符串存储) |
|
||||||
| warrantyRatio | string \| number | 质保金比例 |
|
| warrantyRatio | string \| number | 质保金比例 |
|
||||||
| settlementAmount | number | 结算金额(万元) |
|
| settlementAmount | number | 结算金额(万元) |
|
||||||
| totalCostEstimate | number | 总成本测算 |
|
| totalCostEstimate | number | 总成本测算 |
|
||||||
@@ -270,12 +274,12 @@
|
|||||||
| ownerUnit | string | 业主单位 |
|
| ownerUnit | string | 业主单位 |
|
||||||
| ownerContact | string | 业主联系人及电话 |
|
| ownerContact | string | 业主联系人及电话 |
|
||||||
| bidType | string | 中标形式 |
|
| bidType | string | 中标形式 |
|
||||||
| signDate | string | 签订日期(YYYY-MM-DD) |
|
| signDate | string | 签订日期:有有效日期时返回 YYYY-MM-DD,否则返回原文(如「在建中」「未开工」) |
|
||||||
| startDate | string | 开工日期 |
|
| startDate | string | 开工日期:同上 |
|
||||||
| plannedCompletionDate | string | 计划竣工日期 |
|
| plannedCompletionDate | string | 计划竣工日期:同上 |
|
||||||
| actualCompletionDate | string | 实际竣工日期 |
|
| actualCompletionDate | string | 实际竣工日期:同上 |
|
||||||
| warrantyAmount | number | 质保金(万元) |
|
| warrantyAmount | number | 质保金(万元) |
|
||||||
| warrantyEndDate | string | 质保期截止日 |
|
| warrantyEndDate | string | 质保期截止日(字符串存储) |
|
||||||
| actualWarrantyRefundDate | string | 实际退质保金日期 |
|
| actualWarrantyRefundDate | string | 实际退质保金日期 |
|
||||||
| projectDepartment | string | 所属项目部 |
|
| projectDepartment | string | 所属项目部 |
|
||||||
| projectLeaderContact | string | 项目负责人及电话 |
|
| projectLeaderContact | string | 项目负责人及电话 |
|
||||||
|
|||||||
@@ -35,6 +35,25 @@ mysql -u root -p < backend/schema.sql
|
|||||||
|
|
||||||
按提示输入 root 密码即可。
|
按提示输入 root 密码即可。
|
||||||
|
|
||||||
|
## 2.1 创建专用用户(避免 root Access denied)
|
||||||
|
|
||||||
|
Ubuntu 下 MySQL 的 root 常用 socket 认证,脚本用 root 连接会报 `Access denied for user 'root'@'localhost'`。建议为 ocean 创建专用用户:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/xsl/code/ocean
|
||||||
|
sudo mysql < backend/scripts/create_db_user.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
默认会创建用户 `ocean` / 密码 `ocean123`(仅限 ocean 库)。然后在 `backend/.env` 中设置:
|
||||||
|
|
||||||
|
```
|
||||||
|
MYSQL_USER=ocean
|
||||||
|
MYSQL_PASSWORD=ocean123
|
||||||
|
MYSQL_DATABASE=ocean
|
||||||
|
```
|
||||||
|
|
||||||
|
若需自定义密码,可先编辑 `backend/scripts/create_db_user.sql` 中 `IDENTIFIED BY '...'` 再执行。
|
||||||
|
|
||||||
## 3. 验证
|
## 3. 验证
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+31
-13
@@ -1,29 +1,35 @@
|
|||||||
# 项目管理系统后端
|
# 项目管理系统后端
|
||||||
|
|
||||||
依据《产品文档》与《API 文档》,Python + FastAPI 实现,TDD 开发。
|
依据《产品文档》与《API 文档》,Python + FastAPI 实现,数据持久化到 MySQL(ocean 库)。
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── src/ # 后端源码
|
├── src/ # 后端源码
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ └── app.py # FastAPI 应用与路由
|
│ ├── app.py # FastAPI 应用与路由
|
||||||
├── tests/ # 测试用例
|
│ ├── config.py # 配置(环境变量)
|
||||||
├── pytest.ini # pytest 配置(pythonpath = src)
|
│ ├── database.py # MySQL 连接
|
||||||
|
│ ├── auth.py # 登录、Token 校验
|
||||||
|
│ └── projects_repo.py # 项目与操作日志数据层
|
||||||
|
├── tests/ # 测试(由测试工程师维护)
|
||||||
|
├── schema.sql # 数据库表结构
|
||||||
|
├── .env.example # 环境变量示例
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
└── API.md
|
└── API.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## 运行测试
|
## 环境与数据库
|
||||||
|
|
||||||
```bash
|
1. 复制 `.env.example` 为 `.env`,按本地 MySQL 填写 `MYSQL_*`。
|
||||||
cd backend
|
2. 执行 `schema.sql` 创建数据库 `ocean` 及表(users、projects、operation_logs)。**若已有 projects 表**,需补加四个日期原文列:见下方「数据库迁移」。
|
||||||
source .venv/bin/activate
|
3. **首次使用**:在 `users` 表中插入至少一个用户;`password_hash` 需用 bcrypt 哈希。可在 Python 中执行:
|
||||||
pytest tests/ -v
|
```python
|
||||||
```
|
from app.auth import hash_password
|
||||||
|
print(hash_password("你的密码")) # 将输出写入 INSERT 语句
|
||||||
pytest 会通过 `pytest.ini` 的 `pythonpath = src` 自动把 `src` 加入 Python 路径,无需手动设置 PYTHONPATH。
|
```
|
||||||
|
示例 SQL:`INSERT INTO users (id, username, password_hash, role, display_name) VALUES ('user-1', 'market', '<上面输出的哈希>', '市场部', '市场部');`
|
||||||
|
|
||||||
## 启动服务
|
## 启动服务
|
||||||
|
|
||||||
@@ -39,3 +45,15 @@ PYTHONPATH=src uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
|||||||
cd backend/src
|
cd backend/src
|
||||||
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
uvicorn app:app --reload --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 功能说明
|
||||||
|
|
||||||
|
- **认证**:`POST /api/auth/login` 校验 users 表账号密码,签发 token(内存存储);后续请求头 `Authorization: Bearer <token>`。
|
||||||
|
- **项目列表**:分页,支持按项目名称/合同编号搜索(searchType + keyword)、按进度/费用筛选(progress、cost)、时间筛选(dateFilterType + dateFrom/dateTo,仅对「日期正常」项目)、日期异常筛选(dateAbnormal 为 true 时只返回日期异常项目)。
|
||||||
|
- **项目详情/新建/更新**:与 API 文档一致;新建仅市场部;更新时写入 operation_logs。
|
||||||
|
- **操作日志**:按项目 id 分页查询,含操作人、时间、修改摘要。
|
||||||
|
|
||||||
|
## 数据库迁移(已有 projects 表时)
|
||||||
|
|
||||||
|
1. **日期原文列**(若无):执行 `PYTHONPATH=src python scripts/migrate_add_date_str_columns.py`,或执行 README 中「增加四个 _str 列」的 SQL。
|
||||||
|
2. **三列改为字符串**(质保期截止日、中标合同金额、工程款拨付方式):执行 `PYTHONPATH=src python scripts/migrate_string_columns.py`。
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# 项目管理系统 API(依据产品文档与 API 文档)
|
# 项目管理系统 API(依据产品文档与 API 文档)
|
||||||
fastapi>=0.104.0
|
fastapi>=0.104.0
|
||||||
uvicorn[standard]>=0.24.0
|
uvicorn[standard]>=0.24.0
|
||||||
# 测试
|
python-dotenv>=1.0.0
|
||||||
|
mysql-connector-python>=8.0.0
|
||||||
|
passlib[bcrypt]>=1.7.4
|
||||||
|
xlrd>=2.0.0
|
||||||
|
# 测试(由测试工程师维护)
|
||||||
pytest>=7.4.0
|
pytest>=7.4.0
|
||||||
httpx>=0.25.0
|
httpx>=0.25.0
|
||||||
# 后续:数据库
|
|
||||||
# python-dotenv>=1.0.0
|
|
||||||
# mysql-connector-python>=8.0.0
|
|
||||||
|
|||||||
+8
-4
@@ -44,7 +44,7 @@ CREATE TABLE `projects` (
|
|||||||
`sub_item_count` int DEFAULT NULL COMMENT '子项个数',
|
`sub_item_count` int DEFAULT NULL COMMENT '子项个数',
|
||||||
`sub_item_code` varchar(128) DEFAULT NULL COMMENT '子项编码',
|
`sub_item_code` varchar(128) DEFAULT NULL COMMENT '子项编码',
|
||||||
`total_investment` decimal(20,4) DEFAULT NULL COMMENT '项目总投资(万元)',
|
`total_investment` decimal(20,4) DEFAULT NULL COMMENT '项目总投资(万元)',
|
||||||
`bid_contract_amount` decimal(20,4) DEFAULT NULL COMMENT '中标合同金额(万元)',
|
`bid_contract_amount` varchar(512) DEFAULT NULL COMMENT '中标合同金额(万元,字符串存储)',
|
||||||
`warranty_ratio` varchar(32) DEFAULT NULL COMMENT '质保金比例',
|
`warranty_ratio` varchar(32) DEFAULT NULL COMMENT '质保金比例',
|
||||||
`settlement_amount` decimal(20,4) DEFAULT NULL COMMENT '结算金额(万元)',
|
`settlement_amount` decimal(20,4) DEFAULT NULL COMMENT '结算金额(万元)',
|
||||||
`total_cost_estimate` decimal(20,4) DEFAULT NULL COMMENT '总成本测算',
|
`total_cost_estimate` decimal(20,4) DEFAULT NULL COMMENT '总成本测算',
|
||||||
@@ -54,18 +54,22 @@ CREATE TABLE `projects` (
|
|||||||
`owner_contact` varchar(255) DEFAULT NULL COMMENT '业主联系人及电话',
|
`owner_contact` varchar(255) DEFAULT NULL COMMENT '业主联系人及电话',
|
||||||
`bid_type` varchar(64) DEFAULT NULL COMMENT '中标形式',
|
`bid_type` varchar(64) DEFAULT NULL COMMENT '中标形式',
|
||||||
`sign_date` date DEFAULT NULL COMMENT '签订日期',
|
`sign_date` date DEFAULT NULL COMMENT '签订日期',
|
||||||
|
`sign_date_str` varchar(128) DEFAULT NULL COMMENT '签订日期原文:日期正常或导入字符串',
|
||||||
`start_date` date DEFAULT NULL COMMENT '开工日期',
|
`start_date` date DEFAULT NULL COMMENT '开工日期',
|
||||||
|
`start_date_str` varchar(128) DEFAULT NULL COMMENT '开工日期原文:日期正常或导入字符串',
|
||||||
`planned_completion_date` date DEFAULT NULL COMMENT '计划竣工日期',
|
`planned_completion_date` date DEFAULT NULL COMMENT '计划竣工日期',
|
||||||
|
`planned_completion_date_str` varchar(128) DEFAULT NULL COMMENT '计划竣工日期原文:日期正常或导入字符串',
|
||||||
`actual_completion_date` date DEFAULT NULL COMMENT '实际竣工日期',
|
`actual_completion_date` date DEFAULT NULL COMMENT '实际竣工日期',
|
||||||
|
`actual_completion_date_str` varchar(128) DEFAULT NULL COMMENT '实际竣工日期原文:日期正常或导入字符串',
|
||||||
`warranty_amount` decimal(20,4) DEFAULT NULL COMMENT '质保金(万元)',
|
`warranty_amount` decimal(20,4) DEFAULT NULL COMMENT '质保金(万元)',
|
||||||
`warranty_end_date` date DEFAULT NULL COMMENT '质保期截止日',
|
`warranty_end_date` varchar(256) DEFAULT NULL COMMENT '质保期截止日(字符串存储)',
|
||||||
`actual_warranty_refund_date` date DEFAULT NULL COMMENT '实际退质保金日期',
|
`actual_warranty_refund_date` date DEFAULT NULL COMMENT '实际退质保金日期',
|
||||||
`project_department` varchar(128) DEFAULT NULL COMMENT '所属项目部',
|
`project_department` varchar(128) DEFAULT NULL COMMENT '所属项目部',
|
||||||
`project_leader_contact` varchar(255) DEFAULT NULL COMMENT '项目负责人及电话',
|
`project_leader_contact` varchar(255) DEFAULT NULL COMMENT '项目负责人及电话',
|
||||||
`payment_method` varchar(128) DEFAULT NULL COMMENT '工程款拨付方式',
|
`payment_method` varchar(2048) DEFAULT NULL COMMENT '工程款拨付方式',
|
||||||
-- 成本控制
|
-- 成本控制
|
||||||
`total_cost` decimal(20,4) DEFAULT NULL COMMENT '总体成本',
|
`total_cost` decimal(20,4) DEFAULT NULL COMMENT '总体成本',
|
||||||
`is_adjusted` varchar(8) DEFAULT NULL COMMENT '是否调整:是/否',
|
`is_adjusted` varchar(512) DEFAULT NULL COMMENT '是否调整:是/否',
|
||||||
`migrant_worker_plan` varchar(64) DEFAULT NULL COMMENT '农民工工资(按进度计划)',
|
`migrant_worker_plan` varchar(64) DEFAULT NULL COMMENT '农民工工资(按进度计划)',
|
||||||
`migrant_worker_actual` decimal(20,4) DEFAULT NULL COMMENT '农民工工资(实付)',
|
`migrant_worker_actual` decimal(20,4) DEFAULT NULL COMMENT '农民工工资(实付)',
|
||||||
`self_supply_material_control` decimal(20,4) DEFAULT NULL COMMENT '乙供材料费(控制)',
|
`self_supply_material_control` decimal(20,4) DEFAULT NULL COMMENT '乙供材料费(控制)',
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# 脚本说明
|
||||||
|
|
||||||
|
## seed_test_users.py:插入测试用户(供测试工程师使用)
|
||||||
|
|
||||||
|
向 `users` 表插入 6 个测试账号(管理员、市场部、工程部、技经部、财务部、物贸部各一),**统一密码:123456**。
|
||||||
|
|
||||||
|
**用法**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
PYTHONPATH=src python scripts/seed_test_users.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**测试账号**
|
||||||
|
|
||||||
|
| 账号 (username) | 角色 | 密码 |
|
||||||
|
|-----------------|----------|--------|
|
||||||
|
| admin | 管理员 | 123456 |
|
||||||
|
| market | 市场部 | 123456 |
|
||||||
|
| engineer | 工程部 | 123456 |
|
||||||
|
| tech | 技经部 | 123456 |
|
||||||
|
| finance | 财务部 | 123456 |
|
||||||
|
| material | 物贸部 | 123456 |
|
||||||
|
|
||||||
|
(新建项目仅市场部可调,其他角色可查看/修改。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## clean_db.py:清空项目与操作日志表
|
||||||
|
|
||||||
|
清空 `projects`、`operation_logs` 表(保留 `users`)。用于重新导入前清理数据。
|
||||||
|
|
||||||
|
**用法**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
PYTHONPATH=src python scripts/clean_db.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**清理并重新导入(一条命令)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend && source .venv/bin/activate && PYTHONPATH=src python scripts/clean_db.py && PYTHONPATH=src python scripts/import_xls.py ../docs/example.xls
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## import_xls.py:导入 example.xls 到 projects 表
|
||||||
|
|
||||||
|
将 `docs/example.xls` 中的项目数据导入 ocean 数据库的 `projects` 表。
|
||||||
|
|
||||||
|
**前置条件**
|
||||||
|
|
||||||
|
1. 已执行 `schema.sql` 创建数据库和表。
|
||||||
|
2. 已配置 `.env`(MySQL 连接信息)。
|
||||||
|
3. 已安装依赖:`pip install -r requirements.txt`(含 mysql-connector-python、xlrd)。
|
||||||
|
|
||||||
|
**用法**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
source .venv/bin/activate
|
||||||
|
PYTHONPATH=src python scripts/import_xls.py
|
||||||
|
```
|
||||||
|
|
||||||
|
默认读取 `../docs/example.xls`(相对 backend)。也可指定路径:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PYTHONPATH=src python scripts/import_xls.py /path/to/example.xls
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**
|
||||||
|
|
||||||
|
- 使用第一个 Sheet;表头为第 3 行(row 2),数据从第 6 行(row 5)开始。
|
||||||
|
- 仅导入「合同编号」非空的行;若「项目名称」为空则用合同编号或「未命名」。
|
||||||
|
- 表头与 `schema.sql`、`初始需求` 对齐(含「其中:」等前缀的列已映射)。
|
||||||
|
- 导入成功后输出:成功条数、跳过条数。
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
清空 projects 与 operation_logs 表(保留 users)。
|
||||||
|
用法:cd backend && PYTHONPATH=src python3 scripts/clean_db.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_backend = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_backend) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_backend))
|
||||||
|
|
||||||
|
from src.database import get_connection
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SET FOREIGN_KEY_CHECKS = 0")
|
||||||
|
cur.execute("TRUNCATE TABLE operation_logs")
|
||||||
|
cur.execute("TRUNCATE TABLE projects")
|
||||||
|
cur.execute("SET FOREIGN_KEY_CHECKS = 1")
|
||||||
|
cur.close()
|
||||||
|
print("已清空 operation_logs、projects 表。")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 创建 ocean 专用数据库用户(解决 root@localhost 的 Access denied)
|
||||||
|
-- 执行:sudo mysql < backend/scripts/create_db_user.sql
|
||||||
|
-- 然后将 backend/.env 中 MYSQL_USER=ocean, MYSQL_PASSWORD=你设置的密码
|
||||||
|
|
||||||
|
CREATE USER IF NOT EXISTS 'ocean'@'localhost' IDENTIFIED BY 'ocean123';
|
||||||
|
GRANT ALL PRIVILEGES ON ocean.* TO 'ocean'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
将 docs/example.xls 中的项目数据导入 ocean 数据库 projects 表。
|
||||||
|
用法(在项目根目录或 backend 目录执行):
|
||||||
|
cd backend && PYTHONPATH=src python scripts/import_xls.py [path_to_example.xls]
|
||||||
|
默认路径:../docs/example.xls(相对 backend)或 docs/example.xls(相对项目根)。
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import xlrd
|
||||||
|
|
||||||
|
# 数据库中的日期列(schema DATE 类型);Excel 里可能填「在建中」「未开工」等文字
|
||||||
|
DATE_COLUMNS = {
|
||||||
|
"sign_date",
|
||||||
|
"start_date",
|
||||||
|
"planned_completion_date",
|
||||||
|
"actual_completion_date",
|
||||||
|
"actual_warranty_refund_date",
|
||||||
|
}
|
||||||
|
# 质保期截止日、中标合同金额、工程款拨付方式:按字符串存储,原样导入
|
||||||
|
STRING_STORAGE_COLUMNS = {"warranty_end_date", "bid_contract_amount", "payment_method"}
|
||||||
|
# 签订/开工/计划竣工/实际竣工:有 _str 字段,日期正常写 DATE+「日期正常」,非日期写 _str
|
||||||
|
DATE_COLUMNS_WITH_STR = {
|
||||||
|
"sign_date",
|
||||||
|
"start_date",
|
||||||
|
"planned_completion_date",
|
||||||
|
"actual_completion_date",
|
||||||
|
}
|
||||||
|
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||||
|
|
||||||
|
# 确保可导入 src
|
||||||
|
_backend = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_backend) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_backend))
|
||||||
|
os.chdir(_backend)
|
||||||
|
|
||||||
|
from src.database import get_connection, new_id
|
||||||
|
|
||||||
|
# Excel 表头(中文,strip 后)-> 数据库列名(snake_case)
|
||||||
|
# 依据 schema.sql、初始需求;表头可能含换行或「其中:」等前缀
|
||||||
|
EXCEL_HEADER_TO_DB = {
|
||||||
|
"序号": "serial_no",
|
||||||
|
"合同编号": "contract_code",
|
||||||
|
"供电局项目合同编号": "power_bureau_contract_code",
|
||||||
|
"项目名称": "project_name",
|
||||||
|
"子项个数": "sub_item_count",
|
||||||
|
"子项编码": "sub_item_code",
|
||||||
|
"项目总投资(万元)": "total_investment",
|
||||||
|
"中标合同金额(万元)": "bid_contract_amount",
|
||||||
|
"质保金比例": "warranty_ratio",
|
||||||
|
"结算金额(万元)": "settlement_amount",
|
||||||
|
"总成本测算": "total_cost_estimate",
|
||||||
|
"工程电压等级": "voltage_level",
|
||||||
|
"工程类别": "project_category",
|
||||||
|
"业主单位": "owner_unit",
|
||||||
|
"业主联系人及电话": "owner_contact",
|
||||||
|
"中标形式": "bid_type",
|
||||||
|
"签订日期": "sign_date",
|
||||||
|
"开工日期": "start_date",
|
||||||
|
"计划竣工日期": "planned_completion_date",
|
||||||
|
"实际竣工日期": "actual_completion_date",
|
||||||
|
"质保金(万元)": "warranty_amount",
|
||||||
|
"质保期截止日": "warranty_end_date",
|
||||||
|
"实际退质保金日期": "actual_warranty_refund_date",
|
||||||
|
"所属项目部": "project_department",
|
||||||
|
"项目负责人及电话": "project_leader_contact",
|
||||||
|
"工程款拨付方式": "payment_method",
|
||||||
|
"总体成本(控制)": "total_cost",
|
||||||
|
"是否调整(是/否)": "is_adjusted",
|
||||||
|
"农民工工资(按进度计划)": "migrant_worker_plan",
|
||||||
|
"农民工工资(实付)": "migrant_worker_actual",
|
||||||
|
"其中:乙供材料费(控制)": "self_supply_material_control",
|
||||||
|
"应付材料费(按收款比例)": "material_payable_by_ratio",
|
||||||
|
"实际发生材料费": "material_actual_occurred",
|
||||||
|
"实际支付材料费": "material_actual_paid",
|
||||||
|
"其中:其他费用(控制)": "other_cost_control",
|
||||||
|
"应付其他费": "other_payable",
|
||||||
|
"实际其他费用": "other_actual",
|
||||||
|
"税金": "tax",
|
||||||
|
"利润(万元)": "profit",
|
||||||
|
"实际利润(万元)": "actual_profit",
|
||||||
|
"成本结算金额(万元)": "cost_settlement_amount",
|
||||||
|
"累计进度": "cumulative_progress",
|
||||||
|
"应收款(完成进度款)": "receivable_progress",
|
||||||
|
"开票金额(万元)": "invoice_amount",
|
||||||
|
"实际收款金额(万元)": "actual_receipt_amount",
|
||||||
|
"实际收款完成率": "actual_receipt_rate",
|
||||||
|
"应付款金额(万元)": "payable_amount",
|
||||||
|
"实际付款金额(万元)": "actual_payment_amount",
|
||||||
|
"未收款(万元)": "unreceived_amount",
|
||||||
|
"实际付款完成率": "actual_payment_rate",
|
||||||
|
"民工工资清欠金额(万元)": "migrant_worker_arrears",
|
||||||
|
"结算后成本测算金额": "settlement_cost_estimate",
|
||||||
|
"结算人工费": "settlement_labor_cost",
|
||||||
|
"结算材料费": "settlement_material_cost",
|
||||||
|
"结算其他费": "settlement_other_cost",
|
||||||
|
"到期应结算项目个数": "due_settlement_count",
|
||||||
|
"到期未完成结算个数": "overdue_settlement_count",
|
||||||
|
"存在的问题": "existing_problems",
|
||||||
|
"建议措施": "suggestions",
|
||||||
|
"备注": "remark",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_header(h: str) -> str:
|
||||||
|
return h.strip().replace("\n", "").replace("\r", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_value(sh: xlrd.sheet.Sheet, row: int, col: int):
|
||||||
|
"""取单元格值,日期转 YYYY-MM-DD,空转 None。"""
|
||||||
|
try:
|
||||||
|
cell = sh.cell(row, col)
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
if cell.ctype == xlrd.XL_CELL_EMPTY:
|
||||||
|
return None
|
||||||
|
if cell.ctype == xlrd.XL_CELL_DATE:
|
||||||
|
try:
|
||||||
|
d = xlrd.xldate.xldate_as_datetime(cell.value, 0)
|
||||||
|
return d.strftime("%Y-%m-%d")
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
v = cell.value
|
||||||
|
if isinstance(v, float):
|
||||||
|
if v != v: # NaN
|
||||||
|
return None
|
||||||
|
if v == int(v):
|
||||||
|
v = int(v)
|
||||||
|
if v == "" or (isinstance(v, str) and not v.strip()):
|
||||||
|
return None
|
||||||
|
if isinstance(v, str):
|
||||||
|
v = v.strip()
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_value_date_and_str(sh: xlrd.sheet.Sheet, row: int, col: int) -> tuple:
|
||||||
|
"""专用于带 _str 的日期列。返回 (date_val, str_val):date_val 为 YYYY-MM-DD 或 None,str_val 为「日期正常」或原文或 None。"""
|
||||||
|
try:
|
||||||
|
cell = sh.cell(row, col)
|
||||||
|
except IndexError:
|
||||||
|
return None, None
|
||||||
|
if cell.ctype == xlrd.XL_CELL_EMPTY:
|
||||||
|
return None, None
|
||||||
|
# 日期型或数字型(Excel 序列日期)
|
||||||
|
if cell.ctype == xlrd.XL_CELL_DATE:
|
||||||
|
try:
|
||||||
|
d = xlrd.xldate.xldate_as_datetime(cell.value, 0)
|
||||||
|
return d.strftime("%Y-%m-%d"), "日期正常"
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
if cell.ctype == xlrd.XL_CELL_NUMBER and isinstance(cell.value, (int, float)):
|
||||||
|
v = cell.value
|
||||||
|
if 20000 <= v <= 50000 or (0 < v < 1): # 常见 Excel 日期范围或时间部分
|
||||||
|
try:
|
||||||
|
d = xlrd.xldate.xldate_as_datetime(v, 0)
|
||||||
|
return d.strftime("%Y-%m-%d"), "日期正常"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None, str(int(v)) if v == int(v) else str(v)
|
||||||
|
# 文本
|
||||||
|
v = cell.value
|
||||||
|
if v is None or (isinstance(v, str) and not v.strip()):
|
||||||
|
return None, None
|
||||||
|
s = (v if isinstance(v, str) else str(v)).strip()
|
||||||
|
if DATE_PATTERN.match(s):
|
||||||
|
return s, "日期正常"
|
||||||
|
return None, s
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_value_raw_str(sh: xlrd.sheet.Sheet, row: int, col: int):
|
||||||
|
"""取单元格原值并转为字符串(用于 warranty_end_date、bid_contract_amount 等字符串存储列)。"""
|
||||||
|
try:
|
||||||
|
cell = sh.cell(row, col)
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
if cell.ctype == xlrd.XL_CELL_EMPTY:
|
||||||
|
return None
|
||||||
|
v = cell.value
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
s = (str(v) if not isinstance(v, str) else v).strip()
|
||||||
|
return s if s else None
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_db_dict(sh: xlrd.sheet.Sheet, header_to_col: dict, row_idx: int) -> dict:
|
||||||
|
"""将一行转为 DB 列名 -> 值的字典。header_to_col: 表头(已 normalize) -> 列索引。"""
|
||||||
|
row_dict = {}
|
||||||
|
for header, col in header_to_col.items():
|
||||||
|
db_col = EXCEL_HEADER_TO_DB.get(header)
|
||||||
|
if not db_col:
|
||||||
|
continue
|
||||||
|
if db_col in DATE_COLUMNS_WITH_STR:
|
||||||
|
date_val, str_val = _cell_value_date_and_str(sh, row_idx, col)
|
||||||
|
if date_val is not None:
|
||||||
|
row_dict[db_col] = date_val
|
||||||
|
row_dict[db_col + "_str"] = "日期正常"
|
||||||
|
elif str_val is not None and str_val.strip():
|
||||||
|
row_dict[db_col + "_str"] = str_val
|
||||||
|
continue
|
||||||
|
if db_col in STRING_STORAGE_COLUMNS:
|
||||||
|
val = _cell_value_raw_str(sh, row_idx, col)
|
||||||
|
if val is not None:
|
||||||
|
row_dict[db_col] = val
|
||||||
|
continue
|
||||||
|
if db_col == "total_cost":
|
||||||
|
val = _cell_value(sh, row_idx, col)
|
||||||
|
if val is not None:
|
||||||
|
if isinstance(val, (int, float)) and (not isinstance(val, float) or not (val != val)):
|
||||||
|
row_dict[db_col] = float(val)
|
||||||
|
elif isinstance(val, str):
|
||||||
|
s = val.strip()
|
||||||
|
try:
|
||||||
|
row_dict[db_col] = float(s)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
row_dict[db_col] = 0
|
||||||
|
row_dict["_total_cost_text"] = s
|
||||||
|
else:
|
||||||
|
row_dict[db_col] = 0
|
||||||
|
continue
|
||||||
|
val = _cell_value(sh, row_idx, col)
|
||||||
|
if val is None:
|
||||||
|
continue
|
||||||
|
if db_col in DATE_COLUMNS and isinstance(val, str) and not DATE_PATTERN.match(val.strip()):
|
||||||
|
continue
|
||||||
|
row_dict[db_col] = val
|
||||||
|
if "_total_cost_text" in row_dict:
|
||||||
|
prefix = "总体成本(文字):" + row_dict["_total_cost_text"] + "\n"
|
||||||
|
row_dict["remark"] = prefix + (row_dict.get("remark") or "")
|
||||||
|
del row_dict["_total_cost_text"]
|
||||||
|
return row_dict
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) >= 2:
|
||||||
|
xls_path = Path(sys.argv[1]).resolve()
|
||||||
|
else:
|
||||||
|
for p in [_backend.parent / "docs" / "example.xls", _backend / "docs" / "example.xls"]:
|
||||||
|
if p.exists():
|
||||||
|
xls_path = p
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
print("未找到 example.xls,请指定路径:python scripts/import_xls.py <path>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not xls_path.exists():
|
||||||
|
print(f"文件不存在:{xls_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
wb = xlrd.open_workbook(str(xls_path))
|
||||||
|
sh = wb.sheet_by_index(0)
|
||||||
|
header_row_idx = 2
|
||||||
|
data_start_idx = 5
|
||||||
|
|
||||||
|
raw_headers = [str(sh.cell_value(header_row_idx, j)) for j in range(sh.ncols)]
|
||||||
|
# header_to_col: 归一化表头 -> 列索引(取首次出现)
|
||||||
|
header_to_col = {}
|
||||||
|
for j, h in enumerate(raw_headers):
|
||||||
|
nh = _normalize_header(h)
|
||||||
|
if nh and nh not in header_to_col:
|
||||||
|
header_to_col[nh] = j
|
||||||
|
|
||||||
|
contract_code_col = header_to_col.get("合同编号")
|
||||||
|
project_name_col = header_to_col.get("项目名称")
|
||||||
|
if contract_code_col is None:
|
||||||
|
print("未找到列「合同编号」,请检查 Excel 表头。")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
skipped = 0
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
for r in range(data_start_idx, sh.nrows):
|
||||||
|
row_dict = _row_to_db_dict(sh, header_to_col, r)
|
||||||
|
contract_code = _cell_value(sh, r, contract_code_col) if contract_code_col is not None else row_dict.get("contract_code")
|
||||||
|
project_name = _cell_value(sh, r, project_name_col) if project_name_col is not None else row_dict.get("project_name")
|
||||||
|
|
||||||
|
if contract_code is None or (isinstance(contract_code, str) and not contract_code.strip()):
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if project_name is None or (isinstance(project_name, str) and not project_name.strip()):
|
||||||
|
project_name = str(contract_code) if contract_code else "未命名"
|
||||||
|
row_dict["contract_code"] = contract_code
|
||||||
|
row_dict["project_name"] = project_name
|
||||||
|
|
||||||
|
pid = new_id()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
row_dict["id"] = pid
|
||||||
|
row_dict["progress"] = (str(row_dict.get("cumulative_progress") or "")[:32]) if row_dict.get("cumulative_progress") is not None else ""
|
||||||
|
row_dict["cost"] = ""
|
||||||
|
row_dict["created_at"] = now
|
||||||
|
row_dict["updated_at"] = now
|
||||||
|
|
||||||
|
cols = list(row_dict.keys())
|
||||||
|
vals = [row_dict[k] for k in cols]
|
||||||
|
placeholders = ", ".join(["%s"] * len(cols))
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO projects (" + ", ".join(cols) + ") VALUES (" + placeholders + ")",
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Row {r + 1} 跳过({e})")
|
||||||
|
skipped += 1
|
||||||
|
cur.close()
|
||||||
|
print(f"导入完成:成功 {inserted} 条,跳过 {skipped} 条。")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
为 projects 表增加四个日期原文列(若不存在)。
|
||||||
|
用法:cd backend && PYTHONPATH=src python3 scripts/migrate_add_date_str_columns.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_backend = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_backend) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_backend))
|
||||||
|
|
||||||
|
from src.database import get_connection
|
||||||
|
|
||||||
|
ALTER_SQL = """
|
||||||
|
ALTER TABLE projects
|
||||||
|
ADD COLUMN sign_date_str varchar(128) DEFAULT NULL COMMENT '签订日期原文:日期正常或导入字符串' AFTER sign_date,
|
||||||
|
ADD COLUMN start_date_str varchar(128) DEFAULT NULL COMMENT '开工日期原文' AFTER start_date,
|
||||||
|
ADD COLUMN planned_completion_date_str varchar(128) DEFAULT NULL COMMENT '计划竣工日期原文' AFTER planned_completion_date,
|
||||||
|
ADD COLUMN actual_completion_date_str varchar(128) DEFAULT NULL COMMENT '实际竣工日期原文' AFTER actual_completion_date;
|
||||||
|
"""
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(ALTER_SQL)
|
||||||
|
conn.commit()
|
||||||
|
print("已为 projects 表添加 sign_date_str、start_date_str、planned_completion_date_str、actual_completion_date_str 列。")
|
||||||
|
except Exception as e:
|
||||||
|
if "Duplicate column name" in str(e):
|
||||||
|
print("列已存在,无需迁移。")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
将 projects 表中「质保期截止日」「中标合同金额」「工程款拨付方式」改为字符串类型。
|
||||||
|
用法:cd backend && PYTHONPATH=src python3 scripts/migrate_string_columns.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_backend = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_backend) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_backend))
|
||||||
|
|
||||||
|
from src.database import get_connection
|
||||||
|
|
||||||
|
def main():
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"ALTER TABLE projects "
|
||||||
|
"MODIFY COLUMN bid_contract_amount varchar(512) DEFAULT NULL COMMENT '中标合同金额(万元,字符串存储)', "
|
||||||
|
"MODIFY COLUMN warranty_end_date varchar(256) DEFAULT NULL COMMENT '质保期截止日(字符串存储)', "
|
||||||
|
"MODIFY COLUMN payment_method varchar(2048) DEFAULT NULL COMMENT '工程款拨付方式', "
|
||||||
|
"MODIFY COLUMN is_adjusted varchar(512) DEFAULT NULL COMMENT '是否调整:是/否'"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print("已将 bid_contract_amount、warranty_end_date、payment_method、is_adjusted 改为目标类型/长度。")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"迁移失败: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
插入测试用户,供测试工程师使用。统一密码:123456
|
||||||
|
用法:cd backend && PYTHONPATH=src python3 scripts/seed_test_users.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_backend = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_backend) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_backend))
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
from src.database import get_connection, new_id
|
||||||
|
|
||||||
|
|
||||||
|
def _hash(pwd: str) -> str:
|
||||||
|
"""bcrypt 哈希,与 auth 中 passlib 校验兼容"""
|
||||||
|
return bcrypt.hashpw(pwd.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||||
|
|
||||||
|
# 角色枚举与测试账号: (username, role, display_name)
|
||||||
|
TEST_USERS = [
|
||||||
|
("admin", "管理员", "管理员"),
|
||||||
|
("market", "市场部", "市场部"),
|
||||||
|
("engineer", "工程部", "工程部"),
|
||||||
|
("tech", "技经部", "技经部"),
|
||||||
|
("finance", "财务部", "财务部"),
|
||||||
|
("material", "物贸部", "物贸部"),
|
||||||
|
]
|
||||||
|
|
||||||
|
TEST_PASSWORD = "123456"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
pwd_hash = _hash(TEST_PASSWORD)
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
for username, role, display_name in TEST_USERS:
|
||||||
|
uid = new_id()
|
||||||
|
try:
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash, role, display_name) VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
(uid, username, pwd_hash, role, display_name),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
print(f"已插入: {username} ({role})")
|
||||||
|
except Exception as e:
|
||||||
|
if "Duplicate entry" in str(e) or "1062" in str(e):
|
||||||
|
print(f"已存在,跳过: {username}")
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
cur.close()
|
||||||
|
print(f"\n测试账号统一密码: {TEST_PASSWORD}")
|
||||||
|
print("账号列表: admin, market, engineer, tech, finance, material")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+83
-97
@@ -1,37 +1,22 @@
|
|||||||
"""
|
"""
|
||||||
项目管理系统 API(依据产品文档与 API 文档)
|
项目管理系统 API(依据产品文档与 API 文档)
|
||||||
TDD:路由按测试用例实现
|
功能:认证、项目 CRUD、操作日志;数据持久化到 MySQL。
|
||||||
"""
|
"""
|
||||||
import uuid
|
from typing import Optional
|
||||||
from datetime import datetime
|
|
||||||
from typing import Any, Optional
|
|
||||||
|
|
||||||
from fastapi import FastAPI, Header, HTTPException
|
from fastapi import FastAPI, Header, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from . import auth
|
||||||
|
from . import projects_repo
|
||||||
|
|
||||||
app = FastAPI(title="项目管理系统 API", version="1.0.0")
|
app = FastAPI(title="项目管理系统 API", version="1.0.0")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
# ---------- 内存存储(TDD 绿阶段最小实现,后续可换 DB)----------
|
|
||||||
# token -> role;测试用:valid-token / market-token -> 市场部, engineer-token -> 工程部
|
|
||||||
TOKEN_ROLE: dict[str, str] = {
|
|
||||||
"valid-token": "市场部",
|
|
||||||
"market-token": "市场部",
|
|
||||||
"engineer-token": "工程部",
|
|
||||||
}
|
|
||||||
# 合法用户:仅 market / valid-password 登录成功
|
|
||||||
VALID_USER = {"username": "market", "password": "valid-password", "role": "市场部", "display_name": "市场部用户"}
|
|
||||||
# 项目 id -> 项目数据(含 contract, costControl, receivable, payable, other)
|
|
||||||
PROJECTS: dict[str, dict[str, Any]] = {}
|
|
||||||
# 操作日志 project_id -> list of log
|
|
||||||
OPERATION_LOGS: dict[str, list[dict]] = {}
|
|
||||||
|
|
||||||
# 不预置项目,测试通过 create 或 fixture 造数;列表无数据时返回 []
|
# ---------- 请求体模型 ----------
|
||||||
|
|
||||||
|
|
||||||
# ---------- 请求/响应模型(最小)----------
|
|
||||||
class LoginBody(BaseModel):
|
class LoginBody(BaseModel):
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
@@ -44,6 +29,10 @@ class ProjectListBody(BaseModel):
|
|||||||
keyword: Optional[str] = None
|
keyword: Optional[str] = None
|
||||||
progress: Optional[str] = None
|
progress: Optional[str] = None
|
||||||
cost: Optional[str] = None
|
cost: Optional[str] = None
|
||||||
|
dateFilterType: Optional[str] = None # signDate | startDate | plannedCompletionDate | actualCompletionDate
|
||||||
|
dateFrom: Optional[str] = None
|
||||||
|
dateTo: Optional[str] = None
|
||||||
|
dateAbnormal: Optional[bool] = None # True:只查日期异常项目
|
||||||
|
|
||||||
|
|
||||||
class ProjectDetailBody(BaseModel):
|
class ProjectDetailBody(BaseModel):
|
||||||
@@ -78,20 +67,15 @@ class ProjectLogsBody(BaseModel):
|
|||||||
pageSize: Optional[int] = 20
|
pageSize: Optional[int] = 20
|
||||||
|
|
||||||
|
|
||||||
def _get_role(authorization: Optional[str] = Header(None)) -> str:
|
def _get_current_user(authorization: Optional[str] = Header(None)) -> dict:
|
||||||
|
"""从 Authorization Bearer token 获取当前用户,否则 401。"""
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
raise HTTPException(status_code=401, detail="未登录")
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
token = authorization[7:].strip()
|
token = authorization[7:].strip()
|
||||||
role = TOKEN_ROLE.get(token)
|
user = auth.get_user_by_token(token)
|
||||||
if role is None:
|
if not user:
|
||||||
raise HTTPException(status_code=401, detail="未登录")
|
raise HTTPException(status_code=401, detail="未登录")
|
||||||
return role
|
return user
|
||||||
|
|
||||||
|
|
||||||
def _get_token(authorization: Optional[str] = Header(None)) -> str:
|
|
||||||
if not authorization or not authorization.startswith("Bearer "):
|
|
||||||
raise HTTPException(status_code=401, detail="未登录")
|
|
||||||
return authorization[7:].strip()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 认证 ----------
|
# ---------- 认证 ----------
|
||||||
@@ -101,16 +85,22 @@ def login(body: LoginBody):
|
|||||||
raise HTTPException(status_code=400, detail="缺少 username")
|
raise HTTPException(status_code=400, detail="缺少 username")
|
||||||
if body.password is None or (isinstance(body.password, str) and body.password.strip() == ""):
|
if body.password is None or (isinstance(body.password, str) and body.password.strip() == ""):
|
||||||
raise HTTPException(status_code=400, detail="缺少 password")
|
raise HTTPException(status_code=400, detail="缺少 password")
|
||||||
if body.username != VALID_USER["username"] or body.password != VALID_USER["password"]:
|
try:
|
||||||
|
user = auth.verify_login(body.username.strip(), body.password)
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
logging.getLogger("app").exception("登录校验异常")
|
||||||
return JSONResponse(status_code=401, content={"code": 401, "message": "账号或密码错误"})
|
return JSONResponse(status_code=401, content={"code": 401, "message": "账号或密码错误"})
|
||||||
token = "valid-token"
|
if not user:
|
||||||
|
return JSONResponse(status_code=401, content={"code": 401, "message": "账号或密码错误"})
|
||||||
|
token = auth.create_token(user)
|
||||||
return {
|
return {
|
||||||
"token": token,
|
"token": token,
|
||||||
"user": {
|
"user": {
|
||||||
"id": "user-market",
|
"id": user["id"],
|
||||||
"username": VALID_USER["username"],
|
"username": user["username"],
|
||||||
"role": VALID_USER["role"],
|
"role": user["role"],
|
||||||
"displayName": VALID_USER["display_name"],
|
"displayName": user["displayName"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,50 +108,39 @@ def login(body: LoginBody):
|
|||||||
# ---------- 项目列表 ----------
|
# ---------- 项目列表 ----------
|
||||||
@app.post("/api/projects/list")
|
@app.post("/api/projects/list")
|
||||||
def project_list(body: ProjectListBody, authorization: Optional[str] = Header(None)):
|
def project_list(body: ProjectListBody, authorization: Optional[str] = Header(None)):
|
||||||
_get_role(authorization)
|
_get_current_user(authorization)
|
||||||
page = body.page or 1
|
page = body.page or 1
|
||||||
page_size = body.pageSize or 20
|
page_size = body.pageSize or 20
|
||||||
items = list(PROJECTS.values())
|
list_, total = projects_repo.list_projects(
|
||||||
# 列表项:id, projectName, contractCode, progress, cost, updatedAt
|
page=page,
|
||||||
list_ = [
|
page_size=page_size,
|
||||||
{
|
search_type=body.searchType,
|
||||||
"id": p["id"],
|
keyword=body.keyword,
|
||||||
"projectName": (p.get("contract") or {}).get("projectName") or "",
|
progress=body.progress,
|
||||||
"contractCode": (p.get("contract") or {}).get("contractCode") or "",
|
cost=body.cost,
|
||||||
"progress": p.get("progress") or "",
|
date_filter_type=body.dateFilterType,
|
||||||
"cost": p.get("cost") or "",
|
date_from=body.dateFrom,
|
||||||
"updatedAt": p.get("updatedAt") or "",
|
date_to=body.dateTo,
|
||||||
}
|
date_abnormal=body.dateAbnormal,
|
||||||
for p in items
|
)
|
||||||
]
|
return {"list": list_, "total": total, "page": page, "pageSize": page_size}
|
||||||
return {"list": list_, "total": len(list_), "page": page, "pageSize": page_size}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 项目详情 ----------
|
# ---------- 项目详情 ----------
|
||||||
@app.post("/api/projects/detail")
|
@app.post("/api/projects/detail")
|
||||||
def project_detail(body: ProjectDetailBody, authorization: Optional[str] = Header(None)):
|
def project_detail(body: ProjectDetailBody, authorization: Optional[str] = Header(None)):
|
||||||
_get_role(authorization)
|
_get_current_user(authorization)
|
||||||
pid = body.id
|
project = projects_repo.get_project(body.id)
|
||||||
if pid not in PROJECTS:
|
if not project:
|
||||||
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
|
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
|
||||||
p = PROJECTS[pid]
|
return project
|
||||||
return {
|
|
||||||
"id": p["id"],
|
|
||||||
"contract": p.get("contract") or {},
|
|
||||||
"costControl": p.get("costControl") or {},
|
|
||||||
"receivable": p.get("receivable") or {},
|
|
||||||
"payable": p.get("payable") or {},
|
|
||||||
"other": p.get("other") or {},
|
|
||||||
"createdAt": p.get("createdAt", ""),
|
|
||||||
"updatedAt": p.get("updatedAt", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------- 新建项目 ----------
|
# ---------- 新建项目 ----------
|
||||||
@app.post("/api/projects/create", status_code=201)
|
@app.post("/api/projects/create", status_code=201)
|
||||||
def project_create(body: ProjectCreateBody, authorization: Optional[str] = Header(None)):
|
def project_create(body: ProjectCreateBody, authorization: Optional[str] = Header(None)):
|
||||||
role = _get_role(authorization)
|
user = _get_current_user(authorization)
|
||||||
if role != "市场部":
|
if user["role"] != "市场部":
|
||||||
return JSONResponse(status_code=403, content={"code": 403, "message": "无权限"})
|
return JSONResponse(status_code=403, content={"code": 403, "message": "无权限"})
|
||||||
contract = body.contract
|
contract = body.contract
|
||||||
if contract is None:
|
if contract is None:
|
||||||
@@ -172,52 +151,59 @@ def project_create(body: ProjectCreateBody, authorization: Optional[str] = Heade
|
|||||||
return JSONResponse(status_code=400, content={"code": 400, "message": "合同编号不能为空"})
|
return JSONResponse(status_code=400, content={"code": 400, "message": "合同编号不能为空"})
|
||||||
if project_name is None or (isinstance(project_name, str) and project_name.strip() == ""):
|
if project_name is None or (isinstance(project_name, str) and project_name.strip() == ""):
|
||||||
return JSONResponse(status_code=400, content={"code": 400, "message": "项目名称不能为空"})
|
return JSONResponse(status_code=400, content={"code": 400, "message": "项目名称不能为空"})
|
||||||
pid = str(uuid.uuid4())
|
contract_dict = body.contract.model_dump() if body.contract else {}
|
||||||
now = datetime.utcnow().isoformat() + "Z"
|
pid = projects_repo.create_project(
|
||||||
PROJECTS[pid] = {
|
contract=contract_dict,
|
||||||
"id": pid,
|
cost_control=body.costControl,
|
||||||
"contract": body.contract.model_dump() if body.contract else {},
|
receivable=body.receivable,
|
||||||
"costControl": body.costControl or {},
|
payable=body.payable,
|
||||||
"receivable": body.receivable or {},
|
other=body.other,
|
||||||
"payable": body.payable or {},
|
)
|
||||||
"other": body.other or {},
|
|
||||||
"createdAt": now,
|
|
||||||
"updatedAt": now,
|
|
||||||
}
|
|
||||||
OPERATION_LOGS[pid] = []
|
|
||||||
return {"id": pid, "message": "创建成功"}
|
return {"id": pid, "message": "创建成功"}
|
||||||
|
|
||||||
|
|
||||||
# ---------- 更新项目 ----------
|
# ---------- 更新项目 ----------
|
||||||
@app.post("/api/projects/update")
|
@app.post("/api/projects/update")
|
||||||
def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Header(None)):
|
def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Header(None)):
|
||||||
_get_role(authorization)
|
user = _get_current_user(authorization)
|
||||||
pid = body.id
|
if not projects_repo.project_exists(body.id):
|
||||||
if pid not in PROJECTS:
|
|
||||||
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
|
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
|
||||||
p = PROJECTS[pid]
|
projects_repo.update_project(
|
||||||
|
body.id,
|
||||||
|
contract=body.contract,
|
||||||
|
cost_control=body.costControl,
|
||||||
|
receivable=body.receivable,
|
||||||
|
payable=body.payable,
|
||||||
|
other=body.other,
|
||||||
|
)
|
||||||
|
summary_parts = []
|
||||||
if body.contract is not None:
|
if body.contract is not None:
|
||||||
p["contract"] = {**(p.get("contract") or {}), **body.contract}
|
summary_parts.append("合同信息")
|
||||||
if body.costControl is not None:
|
if body.costControl is not None:
|
||||||
p["costControl"] = {**(p.get("costControl") or {}), **body.costControl}
|
summary_parts.append("成本控制")
|
||||||
if body.receivable is not None:
|
if body.receivable is not None:
|
||||||
p["receivable"] = {**(p.get("receivable") or {}), **body.receivable}
|
summary_parts.append("应收款")
|
||||||
if body.payable is not None:
|
if body.payable is not None:
|
||||||
p["payable"] = {**(p.get("payable") or {}), **body.payable}
|
summary_parts.append("应付款")
|
||||||
if body.other is not None:
|
if body.other is not None:
|
||||||
p["other"] = {**(p.get("other") or {}), **body.other}
|
summary_parts.append("其他")
|
||||||
p["updatedAt"] = datetime.utcnow().isoformat() + "Z"
|
summary = "、".join(summary_parts) if summary_parts else "项目信息"
|
||||||
return {"id": pid, "message": "保存成功"}
|
projects_repo.insert_operation_log(
|
||||||
|
project_id=body.id,
|
||||||
|
operator_id=user["id"],
|
||||||
|
summary=summary,
|
||||||
|
detail=None,
|
||||||
|
)
|
||||||
|
return {"id": body.id, "message": "保存成功"}
|
||||||
|
|
||||||
|
|
||||||
# ---------- 操作日志 ----------
|
# ---------- 操作日志 ----------
|
||||||
@app.post("/api/projects/logs")
|
@app.post("/api/projects/logs")
|
||||||
def project_logs(body: ProjectLogsBody, authorization: Optional[str] = Header(None)):
|
def project_logs(body: ProjectLogsBody, authorization: Optional[str] = Header(None)):
|
||||||
_get_role(authorization)
|
_get_current_user(authorization)
|
||||||
pid = body.id
|
if not projects_repo.project_exists(body.id):
|
||||||
if pid not in PROJECTS:
|
|
||||||
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
|
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
|
||||||
logs = OPERATION_LOGS.get(pid, [])
|
|
||||||
page = body.page or 1
|
page = body.page or 1
|
||||||
page_size = body.pageSize or 20
|
page_size = body.pageSize or 20
|
||||||
return {"list": logs, "total": len(logs), "page": page, "pageSize": page_size}
|
list_, total = projects_repo.list_operation_logs(body.id, page=page, page_size=page_size)
|
||||||
|
return {"list": list_, "total": total, "page": page, "pageSize": page_size}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""认证:登录校验、Token 签发与校验"""
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
from .database import get_connection, new_id
|
||||||
|
|
||||||
|
pwd_ctx = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
# 内存:token -> 当前用户信息(id, username, role, display_name)
|
||||||
|
TOKEN_STORE: dict[str, dict[str, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_password(plain: str, password_hash: str) -> bool:
|
||||||
|
"""校验密码与哈希是否匹配。优先用 bcrypt 库,避免 passlib 与新版 bcrypt 兼容性问题。"""
|
||||||
|
if not plain or not password_hash:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
if isinstance(password_hash, str) and password_hash.startswith("$2"):
|
||||||
|
return bcrypt.checkpw(plain.encode("utf-8"), password_hash.encode("utf-8"))
|
||||||
|
return pwd_ctx.verify(plain, password_hash)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def verify_login(username: str, password: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""校验账号密码,成功返回用户信息,失败返回 None"""
|
||||||
|
if not username or not password:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT id, username, password_hash, role, display_name FROM users WHERE username = %s",
|
||||||
|
(username.strip(),),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
if not _verify_password(password, row["password_hash"] or ""):
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"username": row["username"],
|
||||||
|
"role": row["role"],
|
||||||
|
"displayName": row["display_name"] or row["username"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_token(user: dict[str, Any]) -> str:
|
||||||
|
"""签发 token,存入 TOKEN_STORE,返回 token 字符串"""
|
||||||
|
token = new_id()
|
||||||
|
TOKEN_STORE[token] = user
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_by_token(token: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""根据 token 获取当前用户,无效返回 None"""
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return TOKEN_STORE.get(token)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plain: str) -> str:
|
||||||
|
"""密码哈希(用于初始化用户等)"""
|
||||||
|
return pwd_ctx.hash(plain)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""配置:从环境变量读取,支持 .env 文件"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# 加载 backend/.env 或当前工作目录 .env
|
||||||
|
env_path = Path(__file__).resolve().parent.parent / ".env"
|
||||||
|
load_dotenv(env_path)
|
||||||
|
|
||||||
|
# MySQL(与 schema.sql 中 ocean 库一致)
|
||||||
|
MYSQL_HOST = os.getenv("MYSQL_HOST", "127.0.0.1")
|
||||||
|
MYSQL_PORT = int(os.getenv("MYSQL_PORT", "3306"))
|
||||||
|
MYSQL_USER = os.getenv("MYSQL_USER", "root")
|
||||||
|
MYSQL_PASSWORD = os.getenv("MYSQL_PASSWORD", "")
|
||||||
|
MYSQL_DATABASE = os.getenv("MYSQL_DATABASE", "ocean")
|
||||||
|
|
||||||
|
# 认证:Token 有效期(秒),0 表示仅内存不持久化
|
||||||
|
TOKEN_EXPIRE_SECONDS = int(os.getenv("TOKEN_EXPIRE_SECONDS", "86400"))
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""数据库连接(MySQL ocean 库)"""
|
||||||
|
import uuid
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from typing import Any, Generator, Optional
|
||||||
|
|
||||||
|
import mysql.connector
|
||||||
|
from mysql.connector import MySQLConnection
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
MYSQL_DATABASE,
|
||||||
|
MYSQL_HOST,
|
||||||
|
MYSQL_PASSWORD,
|
||||||
|
MYSQL_PORT,
|
||||||
|
MYSQL_USER,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_connection() -> MySQLConnection:
|
||||||
|
return mysql.connector.connect(
|
||||||
|
host=MYSQL_HOST,
|
||||||
|
port=MYSQL_PORT,
|
||||||
|
user=MYSQL_USER,
|
||||||
|
password=MYSQL_PASSWORD,
|
||||||
|
database=MYSQL_DATABASE,
|
||||||
|
charset="utf8mb4",
|
||||||
|
autocommit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_connection() -> Generator[MySQLConnection, None, None]:
|
||||||
|
conn = _get_connection()
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def new_id() -> str:
|
||||||
|
return uuid.uuid4().hex
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"""
|
||||||
|
项目与操作日志数据层(依据 schema.sql、API 文档第 8 节)
|
||||||
|
API 使用 camelCase,数据库使用 snake_case,在此做映射。
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from .database import get_connection, new_id
|
||||||
|
|
||||||
|
DATE_STR_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||||
|
|
||||||
|
# API 字段名(camelCase) -> DB 列名(snake_case),按 5 个分类
|
||||||
|
CONTRACT_API_TO_DB = {
|
||||||
|
"serialNo": "serial_no",
|
||||||
|
"contractCode": "contract_code",
|
||||||
|
"powerBureauContractCode": "power_bureau_contract_code",
|
||||||
|
"projectName": "project_name",
|
||||||
|
"subItemCount": "sub_item_count",
|
||||||
|
"subItemCode": "sub_item_code",
|
||||||
|
"totalInvestment": "total_investment",
|
||||||
|
"bidContractAmount": "bid_contract_amount",
|
||||||
|
"warrantyRatio": "warranty_ratio",
|
||||||
|
"settlementAmount": "settlement_amount",
|
||||||
|
"totalCostEstimate": "total_cost_estimate",
|
||||||
|
"voltageLevel": "voltage_level",
|
||||||
|
"projectCategory": "project_category",
|
||||||
|
"ownerUnit": "owner_unit",
|
||||||
|
"ownerContact": "owner_contact",
|
||||||
|
"bidType": "bid_type",
|
||||||
|
"signDate": "sign_date",
|
||||||
|
"startDate": "start_date",
|
||||||
|
"plannedCompletionDate": "planned_completion_date",
|
||||||
|
"actualCompletionDate": "actual_completion_date",
|
||||||
|
"warrantyAmount": "warranty_amount",
|
||||||
|
"warrantyEndDate": "warranty_end_date",
|
||||||
|
"actualWarrantyRefundDate": "actual_warranty_refund_date",
|
||||||
|
"projectDepartment": "project_department",
|
||||||
|
"projectLeaderContact": "project_leader_contact",
|
||||||
|
"paymentMethod": "payment_method",
|
||||||
|
}
|
||||||
|
COST_CONTROL_API_TO_DB = {
|
||||||
|
"totalCost": "total_cost",
|
||||||
|
"isAdjusted": "is_adjusted",
|
||||||
|
"migrantWorkerPlan": "migrant_worker_plan",
|
||||||
|
"migrantWorkerActual": "migrant_worker_actual",
|
||||||
|
"selfSupplyMaterialControl": "self_supply_material_control",
|
||||||
|
"materialPayableByRatio": "material_payable_by_ratio",
|
||||||
|
"materialActualOccurred": "material_actual_occurred",
|
||||||
|
"materialActualPaid": "material_actual_paid",
|
||||||
|
"otherCostControl": "other_cost_control",
|
||||||
|
"otherPayable": "other_payable",
|
||||||
|
"otherActual": "other_actual",
|
||||||
|
"tax": "tax",
|
||||||
|
"profit": "profit",
|
||||||
|
"actualProfit": "actual_profit",
|
||||||
|
"costSettlementAmount": "cost_settlement_amount",
|
||||||
|
}
|
||||||
|
RECEIVABLE_API_TO_DB = {
|
||||||
|
"receivableProgress": "receivable_progress",
|
||||||
|
"invoiceAmount": "invoice_amount",
|
||||||
|
"actualReceiptAmount": "actual_receipt_amount",
|
||||||
|
"actualReceiptRate": "actual_receipt_rate",
|
||||||
|
}
|
||||||
|
PAYABLE_API_TO_DB = {
|
||||||
|
"payableAmount": "payable_amount",
|
||||||
|
"actualPaymentAmount": "actual_payment_amount",
|
||||||
|
"unreceivedAmount": "unreceived_amount",
|
||||||
|
"actualPaymentRate": "actual_payment_rate",
|
||||||
|
"migrantWorkerArrears": "migrant_worker_arrears",
|
||||||
|
}
|
||||||
|
OTHER_API_TO_DB = {
|
||||||
|
"settlementCostEstimate": "settlement_cost_estimate",
|
||||||
|
"settlementLaborCost": "settlement_labor_cost",
|
||||||
|
"settlementMaterialCost": "settlement_material_cost",
|
||||||
|
"settlementOtherCost": "settlement_other_cost",
|
||||||
|
"dueSettlementCount": "due_settlement_count",
|
||||||
|
"overdueSettlementCount": "overdue_settlement_count",
|
||||||
|
"existingProblems": "existing_problems",
|
||||||
|
"suggestions": "suggestions",
|
||||||
|
"cumulativeProgress": "cumulative_progress",
|
||||||
|
"remark": "remark",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 四个日期字段:API 返回时若 _str 为「日期正常」则返回日期,否则返回 _str
|
||||||
|
DATE_FIELDS_WITH_STR = ("sign_date", "start_date", "planned_completion_date", "actual_completion_date")
|
||||||
|
DATE_NORMAL = "日期正常"
|
||||||
|
|
||||||
|
|
||||||
|
def _db_to_camel(d: dict[str, Any], api_to_db: dict[str, str]) -> dict[str, Any]:
|
||||||
|
"""DB 列名 -> API 字段名(反向映射)"""
|
||||||
|
db_to_api = {v: k for k, v in api_to_db.items()}
|
||||||
|
return {db_to_api.get(k, k): v for k, v in d.items() if k in db_to_api}
|
||||||
|
|
||||||
|
|
||||||
|
def _api_to_db(d: Optional[dict], api_to_db: dict[str, str]) -> dict[str, Any]:
|
||||||
|
"""API 请求体(camelCase)-> DB 列(snake_case),仅包含有值的键"""
|
||||||
|
if not d:
|
||||||
|
return {}
|
||||||
|
return {api_to_db[k]: v for k, v in d.items() if k in api_to_db and v is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contract_date_str_fields(c: dict[str, Any]) -> None:
|
||||||
|
"""对合同中的四个日期字段做规范化:日期则写 DATE + _str=日期正常,否则只写 _str。原地修改 c。"""
|
||||||
|
for col in DATE_FIELDS_WITH_STR:
|
||||||
|
if col not in c:
|
||||||
|
continue
|
||||||
|
val = c[col]
|
||||||
|
s = (val if isinstance(val, str) else str(val)).strip() if val else ""
|
||||||
|
if s and DATE_STR_PATTERN.match(s):
|
||||||
|
c[col] = s
|
||||||
|
c[col + "_str"] = DATE_NORMAL
|
||||||
|
else:
|
||||||
|
c[col + "_str"] = s or ""
|
||||||
|
c[col] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_api_project(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""数据库一行 -> API 项目详情(含 contract/costControl/receivable/payable/other)"""
|
||||||
|
contract_cols = set(CONTRACT_API_TO_DB.values())
|
||||||
|
cost_cols = set(COST_CONTROL_API_TO_DB.values())
|
||||||
|
recv_cols = set(RECEIVABLE_API_TO_DB.values())
|
||||||
|
pay_cols = set(PAYABLE_API_TO_DB.values())
|
||||||
|
other_cols = set(OTHER_API_TO_DB.values())
|
||||||
|
contract = {k: v for k, v in row.items() if k in contract_cols}
|
||||||
|
for col in DATE_FIELDS_WITH_STR:
|
||||||
|
if row.get(col + "_str") == DATE_NORMAL and row.get(col):
|
||||||
|
contract[col] = row[col]
|
||||||
|
else:
|
||||||
|
contract[col] = row.get(col + "_str") or ""
|
||||||
|
cost_control = {k: v for k, v in row.items() if k in cost_cols}
|
||||||
|
receivable = {k: v for k, v in row.items() if k in recv_cols}
|
||||||
|
payable = {k: v for k, v in row.items() if k in pay_cols}
|
||||||
|
other = {k: v for k, v in row.items() if k in other_cols}
|
||||||
|
# 日期等转为 ISO 字符串
|
||||||
|
def _serialize(v: Any) -> Any:
|
||||||
|
if hasattr(v, "isoformat"):
|
||||||
|
return v.isoformat()
|
||||||
|
return v
|
||||||
|
return {
|
||||||
|
"id": row.get("id"),
|
||||||
|
"contract": _db_to_camel({k: _serialize(v) for k, v in contract.items()}, CONTRACT_API_TO_DB),
|
||||||
|
"costControl": _db_to_camel({k: _serialize(v) for k, v in cost_control.items()}, COST_CONTROL_API_TO_DB),
|
||||||
|
"receivable": _db_to_camel({k: _serialize(v) for k, v in receivable.items()}, RECEIVABLE_API_TO_DB),
|
||||||
|
"payable": _db_to_camel({k: _serialize(v) for k, v in payable.items()}, PAYABLE_API_TO_DB),
|
||||||
|
"other": _db_to_camel({k: _serialize(v) for k, v in other.items()}, OTHER_API_TO_DB),
|
||||||
|
"createdAt": _serialize(row.get("created_at")),
|
||||||
|
"updatedAt": _serialize(row.get("updated_at")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# 时间筛选项 API -> DB 列名(仅对「日期正常」的项目按 DATE 列筛选)
|
||||||
|
DATE_FILTER_COLUMNS = {
|
||||||
|
"signDate": "sign_date",
|
||||||
|
"startDate": "start_date",
|
||||||
|
"plannedCompletionDate": "planned_completion_date",
|
||||||
|
"actualCompletionDate": "actual_completion_date",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_projects(
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
search_type: Optional[str] = None,
|
||||||
|
keyword: Optional[str] = None,
|
||||||
|
progress: Optional[str] = None,
|
||||||
|
cost: Optional[str] = None,
|
||||||
|
date_filter_type: Optional[str] = None,
|
||||||
|
date_from: Optional[str] = None,
|
||||||
|
date_to: Optional[str] = None,
|
||||||
|
date_abnormal: Optional[bool] = None,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""分页列表,支持按项目名称/合同编号搜索、按进度/费用筛选、时间筛选、日期异常筛选。返回 (list, total)。"""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
where_parts = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if keyword and keyword.strip():
|
||||||
|
if search_type == "name":
|
||||||
|
where_parts.append("project_name LIKE %s")
|
||||||
|
params.append(f"%{keyword.strip()}%")
|
||||||
|
elif search_type == "code":
|
||||||
|
where_parts.append("contract_code LIKE %s")
|
||||||
|
params.append(f"%{keyword.strip()}%")
|
||||||
|
else:
|
||||||
|
where_parts.append("(project_name LIKE %s OR contract_code LIKE %s)")
|
||||||
|
params.extend([f"%{keyword.strip()}%", f"%{keyword.strip()}%"])
|
||||||
|
if progress and progress.strip():
|
||||||
|
where_parts.append("progress = %s")
|
||||||
|
params.append(progress.strip())
|
||||||
|
if cost and cost.strip():
|
||||||
|
where_parts.append("cost = %s")
|
||||||
|
params.append(cost.strip())
|
||||||
|
if date_abnormal:
|
||||||
|
where_parts.append(
|
||||||
|
"((sign_date_str IS NULL OR sign_date_str != %s) OR (start_date_str IS NULL OR start_date_str != %s) "
|
||||||
|
"OR (planned_completion_date_str IS NULL OR planned_completion_date_str != %s) "
|
||||||
|
"OR (actual_completion_date_str IS NULL OR actual_completion_date_str != %s))"
|
||||||
|
)
|
||||||
|
params.extend([DATE_NORMAL, DATE_NORMAL, DATE_NORMAL, DATE_NORMAL])
|
||||||
|
date_col = DATE_FILTER_COLUMNS.get(date_filter_type) if date_filter_type else None
|
||||||
|
if date_col and (date_from or date_to) and not date_abnormal:
|
||||||
|
where_parts.append(f"({date_col}_str = %s)")
|
||||||
|
params.append(DATE_NORMAL)
|
||||||
|
if date_from and date_from.strip():
|
||||||
|
where_parts.append(f"{date_col} >= %s")
|
||||||
|
params.append(date_from.strip())
|
||||||
|
if date_to and date_to.strip():
|
||||||
|
where_parts.append(f"{date_col} <= %s")
|
||||||
|
params.append(date_to.strip())
|
||||||
|
where_sql = " AND ".join(where_parts) if where_parts else "1=1"
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT COUNT(*) AS cnt FROM projects WHERE {where_sql}",
|
||||||
|
tuple(params),
|
||||||
|
)
|
||||||
|
total = cur.fetchone()["cnt"]
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
cur.execute(
|
||||||
|
f"SELECT id, project_name, contract_code, progress, cost, updated_at FROM projects WHERE {where_sql} ORDER BY updated_at DESC LIMIT %s OFFSET %s",
|
||||||
|
tuple(params) + (page_size, offset),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
list_ = [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"projectName": r["project_name"] or "",
|
||||||
|
"contractCode": r["contract_code"] or "",
|
||||||
|
"progress": r["progress"] or "",
|
||||||
|
"cost": r["cost"] or "",
|
||||||
|
"updatedAt": r["updated_at"].isoformat() if r.get("updated_at") else "",
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return list_, total
|
||||||
|
|
||||||
|
|
||||||
|
def get_project(project_id: str) -> Optional[dict[str, Any]]:
|
||||||
|
"""按 id 查项目,返回 API 形态详情,不存在返回 None。"""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute("SELECT * FROM projects WHERE id = %s", (project_id,))
|
||||||
|
row = cur.fetchone()
|
||||||
|
cur.close()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return _row_to_api_project(row)
|
||||||
|
|
||||||
|
|
||||||
|
def create_project(
|
||||||
|
contract: dict,
|
||||||
|
cost_control: Optional[dict] = None,
|
||||||
|
receivable: Optional[dict] = None,
|
||||||
|
payable: Optional[dict] = None,
|
||||||
|
other: Optional[dict] = None,
|
||||||
|
) -> str:
|
||||||
|
"""新建项目,合同信息必含 contract_code、project_name。返回新项目 id。"""
|
||||||
|
pid = new_id()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
c = _api_to_db(contract, CONTRACT_API_TO_DB)
|
||||||
|
_normalize_contract_date_str_fields(c)
|
||||||
|
cc = _api_to_db(cost_control, COST_CONTROL_API_TO_DB)
|
||||||
|
recv = _api_to_db(receivable, RECEIVABLE_API_TO_DB)
|
||||||
|
pay = _api_to_db(payable, PAYABLE_API_TO_DB)
|
||||||
|
oth = _api_to_db(other, OTHER_API_TO_DB)
|
||||||
|
row: dict[str, Any] = {**c, **cc, **recv, **pay, **oth}
|
||||||
|
row["id"] = pid
|
||||||
|
row["progress"] = (row.get("cumulative_progress") or row.get("project_name") or "")[:32] if row.get("cumulative_progress") or row.get("project_name") else ""
|
||||||
|
row["cost"] = ""
|
||||||
|
row["created_at"] = now
|
||||||
|
row["updated_at"] = now
|
||||||
|
cols = [k for k in row.keys()]
|
||||||
|
vals = [row[k] for k in cols]
|
||||||
|
placeholders = ", ".join(["%s"] * len(cols))
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
f"INSERT INTO projects ({', '.join(cols)}) VALUES ({placeholders})",
|
||||||
|
vals,
|
||||||
|
)
|
||||||
|
cur.close()
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def update_project(
|
||||||
|
project_id: str,
|
||||||
|
contract: Optional[dict] = None,
|
||||||
|
cost_control: Optional[dict] = None,
|
||||||
|
receivable: Optional[dict] = None,
|
||||||
|
payable: Optional[dict] = None,
|
||||||
|
other: Optional[dict] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""更新项目(合并传入的字段),不存在返回 False。"""
|
||||||
|
c = _api_to_db(contract, CONTRACT_API_TO_DB)
|
||||||
|
_normalize_contract_date_str_fields(c)
|
||||||
|
cc = _api_to_db(cost_control, COST_CONTROL_API_TO_DB)
|
||||||
|
recv = _api_to_db(receivable, RECEIVABLE_API_TO_DB)
|
||||||
|
pay = _api_to_db(payable, PAYABLE_API_TO_DB)
|
||||||
|
oth = _api_to_db(other, OTHER_API_TO_DB)
|
||||||
|
merged = {**c, **cc, **recv, **pay, **oth}
|
||||||
|
if not merged:
|
||||||
|
return project_exists(project_id)
|
||||||
|
merged["updated_at"] = datetime.utcnow()
|
||||||
|
updates = [f"`{k}` = %s" for k in merged.keys()]
|
||||||
|
params: list[Any] = list(merged.values()) + [project_id]
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE projects SET " + ", ".join(updates) + " WHERE id = %s",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
cur.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def project_exists(project_id: str) -> bool:
|
||||||
|
"""项目是否存在"""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("SELECT 1 FROM projects WHERE id = %s", (project_id,))
|
||||||
|
ok = cur.fetchone() is not None
|
||||||
|
cur.close()
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def insert_operation_log(
|
||||||
|
project_id: str,
|
||||||
|
operator_id: str,
|
||||||
|
summary: str = "",
|
||||||
|
detail: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
"""写入一条操作日志。"""
|
||||||
|
log_id = new_id()
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"INSERT INTO operation_logs (id, project_id, operator_id, summary, detail) VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
(log_id, project_id, operator_id, summary or "项目信息", detail),
|
||||||
|
)
|
||||||
|
cur.close()
|
||||||
|
|
||||||
|
|
||||||
|
def list_operation_logs(
|
||||||
|
project_id: str,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""分页查询某项目的操作日志,返回 (list, total)。含 operator_name(联表 users)。"""
|
||||||
|
with get_connection() as conn:
|
||||||
|
cur = conn.cursor(dictionary=True)
|
||||||
|
cur.execute(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM operation_logs WHERE project_id = %s",
|
||||||
|
(project_id,),
|
||||||
|
)
|
||||||
|
total = cur.fetchone()["cnt"]
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT l.id, l.operator_id, l.operated_at, l.summary, l.detail, u.display_name AS operator_name
|
||||||
|
FROM operation_logs l
|
||||||
|
LEFT JOIN users u ON l.operator_id = u.id
|
||||||
|
WHERE l.project_id = %s
|
||||||
|
ORDER BY l.operated_at DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
""",
|
||||||
|
(project_id, page_size, offset),
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
cur.close()
|
||||||
|
list_ = [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"operatorId": r["operator_id"],
|
||||||
|
"operatorName": r["operator_name"] or r["operator_id"],
|
||||||
|
"operatedAt": r["operated_at"].isoformat() if r.get("operated_at") else "",
|
||||||
|
"summary": r["summary"] or "",
|
||||||
|
"detail": r.get("detail"),
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return list_, total
|
||||||
+27
-7
@@ -2,23 +2,43 @@
|
|||||||
|
|
||||||
依据《产品文档》与《API 文档》,用 Python + pytest 编写。**先写失败用例(RED),再实现路由(GREEN)。**
|
依据《产品文档》与《API 文档》,用 Python + pytest 编写。**先写失败用例(RED),再实现路由(GREEN)。**
|
||||||
|
|
||||||
后端源码在 `backend/src/` 下,pytest 通过 `pytest.ini` 的 `pythonpath = src` 自动加入 `src` 目录,无需手动设置 PYTHONPATH。
|
## 测试与开发环境严格隔离
|
||||||
|
|
||||||
|
- **不加载应用代码、不 mock 认证**:测试通过 HTTP 请求**已启动的后端服务**,与开发环境完全隔离。
|
||||||
|
- **运行方式**:先启动后端服务(如 `uvicorn` 监听 `http://0.0.0.0:8000`),再在另一终端执行 `pytest tests/ -v`。
|
||||||
|
- **服务地址**:默认 `http://127.0.0.1:8000`,可通过环境变量 `TEST_BASE_URL` 覆盖。
|
||||||
|
|
||||||
|
## 登录与带 Token 调用
|
||||||
|
|
||||||
|
- **登录**:`POST /api/auth/login`,请求体 `{"username": "admin", "password": "123456"}`(或 `market` / `engineer` / `tech` / `finance` / `material`,密码均为 `123456`)。
|
||||||
|
- **带 Token 调接口**:在请求头中加 `Authorization: Bearer <登录返回的 token>`,再调用项目列表、详情、新建、更新、操作日志等接口。
|
||||||
|
- **Fixture**:`conftest.py` 提供 `auth_token`(admin)、`market_token`(市场部)、`engineer_token`(工程部),用例中直接注入使用。
|
||||||
|
|
||||||
## 运行测试
|
## 运行测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# 1. 先启动后端(在 backend 目录或项目根目录)
|
||||||
|
uvicorn src.app:app --host 0.0.0.0 --port 8000
|
||||||
|
|
||||||
|
# 2. 在另一终端运行测试
|
||||||
cd backend
|
cd backend
|
||||||
source .venv/bin/activate # 或 pip install -r requirements.txt
|
source .venv/bin/activate # 或 pip install -r requirements.txt
|
||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如需指定服务地址:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TEST_BASE_URL=http://localhost:8000 pytest tests/ -v
|
||||||
|
```
|
||||||
|
|
||||||
## 用例覆盖
|
## 用例覆盖
|
||||||
|
|
||||||
| 接口 | 用例 |
|
| 接口 | 用例 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| POST /api/auth/login | 成功返回 token/user、错误返回 401、缺 username/password 返回 400 |
|
| POST /api/auth/login | 成功返回 token/user、错误凭证 401、缺 username/password 返回 400 |
|
||||||
| POST /api/projects/list | 无 token 401、有 token 返回 list/total/page/pageSize、无数据时 list 空且 total=0 |
|
| POST /api/projects/list | 无 token 401;有 token 返回 list/total/page/pageSize;无数据 list 空且 total=0;支持 searchType+keyword(name/code)、progress、cost、dateFilterType+dateFrom/dateTo、dateAbnormal;列表项含 id/projectName/contractCode/progress/cost/updatedAt;page/pageSize 回显 |
|
||||||
| POST /api/projects/detail | 无 token 401、存在项目返回完整字段、不存在 404 |
|
| POST /api/projects/detail | 无 token 401、缺 id 400/422、存在项目返回完整五类字段、不存在 404 |
|
||||||
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400 |
|
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400、校验失败可含 errors 数组 |
|
||||||
| POST /api/projects/update | 无 token 401、存在项目 200 保存成功、不存在 404 |
|
| POST /api/projects/update | 无 token 401、缺 id 400/422、存在项目 200 保存成功、不存在 404 |
|
||||||
| POST /api/projects/logs | 无 token 401、有 token 返回 list/total/page/pageSize、不存在项目 404 |
|
| POST /api/projects/logs | 无 token 401、缺 id 400/422、有 token 返回 list/total/page/pageSize、不存在项目 404、更新后有一条含 summary 的日志、列表项含 id/operatorId/operatorName/operatedAt/summary |
|
||||||
|
|||||||
+53
-12
@@ -1,18 +1,59 @@
|
|||||||
"""pytest 配置与公共 fixture(依据 API 文档)"""
|
"""pytest 配置与公共 fixture(依据 API 文档)
|
||||||
|
|
||||||
|
测试与开发环境严格隔离:测试通过 HTTP 请求已启动的后端服务,不加载应用代码、不 mock 认证。
|
||||||
|
运行测试前请先启动后端:http://127.0.0.1:8000
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from app import app, PROJECTS, OPERATION_LOGS
|
# 已启动的后端服务地址(可通过环境变量 TEST_BASE_URL 覆盖)
|
||||||
|
BASE_URL = os.environ.get("TEST_BASE_URL", "http://127.0.0.1:8000")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(scope="session")
|
||||||
def reset_store():
|
def http_client():
|
||||||
"""每个测试前清空内存存储,保证隔离"""
|
"""请求已启动的后端服务的 HTTP 客户端(session 级,复用连接)"""
|
||||||
PROJECTS.clear()
|
with httpx.Client(base_url=BASE_URL, timeout=30.0) as c:
|
||||||
OPERATION_LOGS.clear()
|
yield c
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client():
|
def client(http_client):
|
||||||
"""同步测试用:FastAPI TestClient"""
|
"""请求已启动的后端服务的 HTTP 客户端(测试用别名)"""
|
||||||
return TestClient(app)
|
return http_client
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: httpx.Client, username: str, password: str) -> str:
|
||||||
|
"""登录并返回 token,失败则抛出异常。"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": username, "password": password},
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"登录失败 {r.status_code}: {r.text}. 请确认后端已启动({BASE_URL})且存在用户 {username} / 密码 123456。"
|
||||||
|
)
|
||||||
|
body = r.json()
|
||||||
|
if "token" not in body:
|
||||||
|
raise RuntimeError(f"登录响应缺少 token: {body}")
|
||||||
|
return body["token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def auth_token(http_client):
|
||||||
|
"""已登录的任意角色 token(admin,用于列表/详情/更新/日志等)"""
|
||||||
|
return _login(http_client, "admin", "123456")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def market_token(http_client):
|
||||||
|
"""市场部账号 token(仅市场部可新建项目)"""
|
||||||
|
return _login(http_client, "market", "123456")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def engineer_token(http_client):
|
||||||
|
"""工程部账号 token(非市场部,用于 403 等用例)"""
|
||||||
|
return _login(http_client, "engineer", "123456")
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
认证 API 测试(依据 API 文档 2.1 登录)
|
认证 API 测试(依据 API 文档 2.1 登录)
|
||||||
TDD:先写失败用例,再实现路由
|
|
||||||
|
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,真实调用登录接口。
|
||||||
|
运行前请先启动后端:http://127.0.0.1:8000
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -8,7 +10,7 @@ def test_login_success_returns_200_with_token_and_user(client):
|
|||||||
"""正确账号密码返回 200 且包含 token 和 user"""
|
"""正确账号密码返回 200 且包含 token 和 user"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/auth/login",
|
"/api/auth/login",
|
||||||
json={"username": "market", "password": "valid-password"},
|
json={"username": "admin", "password": "123456"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
body = r.json()
|
body = r.json()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
项目 API 测试(依据 API 文档 3–7 节)
|
项目 API 测试(依据 API 文档 3–7 节)
|
||||||
TDD:先写失败用例,再实现路由
|
|
||||||
|
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,带 Token 调用项目列表/详情/新建/更新/操作日志。
|
||||||
|
运行前请先启动后端:http://127.0.0.1:8000;登录账号见 conftest(admin / market / engineer 等,密码 123456)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -17,11 +19,11 @@ def test_list_without_token_returns_401(client):
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_list_with_token_returns_200_with_list_total_page_page_size(client):
|
def test_list_with_token_returns_200_with_list_total_page_page_size(client, auth_token):
|
||||||
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
|
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/list",
|
"/api/projects/list",
|
||||||
headers=_auth_header("valid-token"),
|
headers=_auth_header(auth_token),
|
||||||
json={"page": 1, "pageSize": 20},
|
json={"page": 1, "pageSize": 20},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -33,16 +35,139 @@ def test_list_with_token_returns_200_with_list_total_page_page_size(client):
|
|||||||
assert "pageSize" in body
|
assert "pageSize" in body
|
||||||
|
|
||||||
|
|
||||||
def test_list_empty_result_has_empty_list_and_total_zero(client):
|
def test_list_empty_result_has_empty_list_and_total_zero(client, auth_token):
|
||||||
"""无数据时 list 为空数组且 total 为 0"""
|
"""列表返回 200;无数据时 list 为空且 total 为 0(有数据时仅校验结构)"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/list",
|
"/api/projects/list",
|
||||||
headers=_auth_header("valid-token"),
|
headers=_auth_header(auth_token),
|
||||||
json={},
|
json={},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["list"] == []
|
body = r.json()
|
||||||
assert r.json()["total"] == 0
|
assert "list" in body and isinstance(body["list"], list)
|
||||||
|
assert "total" in body and isinstance(body["total"], int)
|
||||||
|
if len(body["list"]) == 0:
|
||||||
|
assert body["total"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accepts_search_type_name_and_keyword(client, auth_token):
|
||||||
|
"""支持 searchType=name 与 keyword 搜索项目名称"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/list",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"searchType": "name", "keyword": "测试"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "list" in body
|
||||||
|
assert "total" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accepts_search_type_code_and_keyword(client, auth_token):
|
||||||
|
"""支持 searchType=code 与 keyword 搜索项目编号"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/list",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"searchType": "code", "keyword": "HT001"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "list" in body
|
||||||
|
assert "total" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accepts_progress_filter(client, auth_token):
|
||||||
|
"""支持按 progress 筛选项目进度"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/list",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"progress": "进行中"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "list" in body
|
||||||
|
assert "total" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accepts_cost_filter(client, auth_token):
|
||||||
|
"""支持按 cost 筛选项目费用"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/list",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"cost": "100万以下"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "list" in body
|
||||||
|
assert "total" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accepts_date_filter_and_date_range(client, auth_token):
|
||||||
|
"""支持 dateFilterType 与 dateFrom、dateTo 时间筛选"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/list",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={
|
||||||
|
"dateFilterType": "signDate",
|
||||||
|
"dateFrom": "2024-01-01",
|
||||||
|
"dateTo": "2024-12-31",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "list" in body
|
||||||
|
assert "total" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accepts_date_abnormal_filter(client, auth_token):
|
||||||
|
"""支持 dateAbnormal=true 仅返回日期异常项目"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/list",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"dateAbnormal": True},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "list" in body
|
||||||
|
assert isinstance(body["list"], list)
|
||||||
|
assert "total" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_items_contain_required_fields(client, auth_token, market_token):
|
||||||
|
"""列表项包含 id、projectName、contractCode、progress、cost、updatedAt"""
|
||||||
|
client.post(
|
||||||
|
"/api/projects/create",
|
||||||
|
headers=_auth_header(market_token),
|
||||||
|
json={"contract": {"contractCode": "HT-LIST", "projectName": "列表项字段测试"}},
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/list",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"page": 1, "pageSize": 20},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert len(body["list"]) >= 1
|
||||||
|
item = body["list"][0]
|
||||||
|
assert "id" in item
|
||||||
|
assert "projectName" in item
|
||||||
|
assert "contractCode" in item
|
||||||
|
assert "progress" in item
|
||||||
|
assert "cost" in item
|
||||||
|
assert "updatedAt" in item
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_respects_page_and_page_size(client, auth_token):
|
||||||
|
"""请求 page、pageSize 时响应中 page、pageSize 与请求一致"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/list",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"page": 2, "pageSize": 5},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body.get("page") == 2
|
||||||
|
assert body.get("pageSize") == 5
|
||||||
|
|
||||||
|
|
||||||
# ---------- POST /api/projects/detail ----------
|
# ---------- POST /api/projects/detail ----------
|
||||||
@@ -54,18 +179,18 @@ def test_detail_without_token_returns_401(client):
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_detail_existing_project_returns_200_with_full_fields(client):
|
def test_detail_existing_project_returns_200_with_full_fields(client, auth_token, market_token):
|
||||||
"""存在项目时返回 200 且包含 id、contract、costControl、receivable、payable、other、createdAt、updatedAt"""
|
"""存在项目时返回 200 且包含 id、contract、costControl、receivable、payable、other、createdAt、updatedAt"""
|
||||||
create_r = client.post(
|
create_r = client.post(
|
||||||
"/api/projects/create",
|
"/api/projects/create",
|
||||||
headers=_auth_header("market-token"),
|
headers=_auth_header(market_token),
|
||||||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||||||
)
|
)
|
||||||
assert create_r.status_code == 201
|
assert create_r.status_code == 201
|
||||||
pid = create_r.json()["id"]
|
pid = create_r.json()["id"]
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/detail",
|
"/api/projects/detail",
|
||||||
headers=_auth_header("valid-token"),
|
headers=_auth_header(auth_token),
|
||||||
json={"id": pid},
|
json={"id": pid},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -80,11 +205,11 @@ def test_detail_existing_project_returns_200_with_full_fields(client):
|
|||||||
assert "updatedAt" in body
|
assert "updatedAt" in body
|
||||||
|
|
||||||
|
|
||||||
def test_detail_nonexistent_project_returns_404_with_code_and_message(client):
|
def test_detail_nonexistent_project_returns_404_with_code_and_message(client, auth_token):
|
||||||
"""不存在项目时返回 404 且包含 code 和 message"""
|
"""不存在项目时返回 404 且包含 code 和 message"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/detail",
|
"/api/projects/detail",
|
||||||
headers=_auth_header("valid-token"),
|
headers=_auth_header(auth_token),
|
||||||
json={"id": "non-existent-id"},
|
json={"id": "non-existent-id"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
@@ -93,6 +218,18 @@ def test_detail_nonexistent_project_returns_404_with_code_and_message(client):
|
|||||||
assert "message" in body
|
assert "message" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_detail_missing_id_returns_400(client, auth_token):
|
||||||
|
"""缺少 id 时返回 400 或 422(参数错误)"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/detail",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert r.status_code in (400, 422)
|
||||||
|
body = r.json()
|
||||||
|
assert body.get("code") in (400, 422) or "detail" in body
|
||||||
|
|
||||||
|
|
||||||
# ---------- POST /api/projects/create ----------
|
# ---------- POST /api/projects/create ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -105,22 +242,22 @@ def test_create_without_token_returns_401(client):
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_create_non_market_role_returns_403(client):
|
def test_create_non_market_role_returns_403(client, engineer_token):
|
||||||
"""非市场部角色返回 403"""
|
"""非市场部角色返回 403"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/create",
|
"/api/projects/create",
|
||||||
headers=_auth_header("engineer-token"),
|
headers=_auth_header(engineer_token),
|
||||||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||||||
)
|
)
|
||||||
assert r.status_code == 403
|
assert r.status_code == 403
|
||||||
assert r.json().get("code") == 403
|
assert r.json().get("code") == 403
|
||||||
|
|
||||||
|
|
||||||
def test_create_market_with_contract_code_and_name_returns_201_with_id_and_message(client):
|
def test_create_market_with_contract_code_and_name_returns_201_with_id_and_message(client, market_token):
|
||||||
"""市场部且合同编号与项目名称齐全时返回 201 且包含 id 和 message"""
|
"""市场部且合同编号与项目名称齐全时返回 201 且包含 id 和 message"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/create",
|
"/api/projects/create",
|
||||||
headers=_auth_header("market-token"),
|
headers=_auth_header(market_token),
|
||||||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||||||
)
|
)
|
||||||
assert r.status_code == 201
|
assert r.status_code == 201
|
||||||
@@ -129,28 +266,43 @@ def test_create_market_with_contract_code_and_name_returns_201_with_id_and_messa
|
|||||||
assert body.get("message") == "创建成功"
|
assert body.get("message") == "创建成功"
|
||||||
|
|
||||||
|
|
||||||
def test_create_missing_contract_code_returns_400(client):
|
def test_create_missing_contract_code_returns_400(client, market_token):
|
||||||
"""缺少合同编号时返回 400"""
|
"""缺少合同编号时返回 400"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/create",
|
"/api/projects/create",
|
||||||
headers=_auth_header("market-token"),
|
headers=_auth_header(market_token),
|
||||||
json={"contract": {"projectName": "测试项目"}},
|
json={"contract": {"projectName": "测试项目"}},
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json().get("code") == 400
|
assert r.json().get("code") == 400
|
||||||
|
|
||||||
|
|
||||||
def test_create_missing_project_name_returns_400(client):
|
def test_create_missing_project_name_returns_400(client, market_token):
|
||||||
"""缺少项目名称时返回 400"""
|
"""缺少项目名称时返回 400"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/create",
|
"/api/projects/create",
|
||||||
headers=_auth_header("market-token"),
|
headers=_auth_header(market_token),
|
||||||
json={"contract": {"contractCode": "HT001"}},
|
json={"contract": {"contractCode": "HT001"}},
|
||||||
)
|
)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json().get("code") == 400
|
assert r.json().get("code") == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_validation_error_may_include_errors_array(client, market_token):
|
||||||
|
"""参数校验失败时响应可包含 errors 数组(field、message)"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/create",
|
||||||
|
headers=_auth_header(market_token),
|
||||||
|
json={"contract": {}},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
body = r.json()
|
||||||
|
assert body.get("code") == 400
|
||||||
|
if "errors" in body and body["errors"]:
|
||||||
|
for e in body["errors"]:
|
||||||
|
assert "field" in e or "message" in e
|
||||||
|
|
||||||
|
|
||||||
# ---------- POST /api/projects/update ----------
|
# ---------- POST /api/projects/update ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -160,18 +312,30 @@ def test_update_without_token_returns_401(client):
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_update_existing_project_returns_200_with_id_and_save_success_message(client):
|
def test_update_missing_id_returns_400(client, auth_token):
|
||||||
|
"""更新项目缺少 id 时返回 400 或 422(参数错误)"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/update",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"contract": {"projectName": "任意"}},
|
||||||
|
)
|
||||||
|
assert r.status_code in (400, 422)
|
||||||
|
body = r.json()
|
||||||
|
assert body.get("code") in (400, 422) or "detail" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_existing_project_returns_200_with_id_and_save_success_message(client, auth_token, market_token):
|
||||||
"""存在项目时返回 200 且包含 id 和 message「保存成功」"""
|
"""存在项目时返回 200 且包含 id 和 message「保存成功」"""
|
||||||
create_r = client.post(
|
create_r = client.post(
|
||||||
"/api/projects/create",
|
"/api/projects/create",
|
||||||
headers=_auth_header("market-token"),
|
headers=_auth_header(market_token),
|
||||||
json={"contract": {"contractCode": "HT002", "projectName": "测试项目2"}},
|
json={"contract": {"contractCode": "HT002", "projectName": "测试项目2"}},
|
||||||
)
|
)
|
||||||
assert create_r.status_code == 201
|
assert create_r.status_code == 201
|
||||||
pid = create_r.json()["id"]
|
pid = create_r.json()["id"]
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/update",
|
"/api/projects/update",
|
||||||
headers=_auth_header("valid-token"),
|
headers=_auth_header(auth_token),
|
||||||
json={"id": pid, "contract": {"projectName": "新名称"}},
|
json={"id": pid, "contract": {"projectName": "新名称"}},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -180,11 +344,11 @@ def test_update_existing_project_returns_200_with_id_and_save_success_message(cl
|
|||||||
assert body.get("message") == "保存成功"
|
assert body.get("message") == "保存成功"
|
||||||
|
|
||||||
|
|
||||||
def test_update_nonexistent_project_returns_404(client):
|
def test_update_nonexistent_project_returns_404(client, auth_token):
|
||||||
"""不存在项目时返回 404"""
|
"""不存在项目时返回 404"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/update",
|
"/api/projects/update",
|
||||||
headers=_auth_header("valid-token"),
|
headers=_auth_header(auth_token),
|
||||||
json={"id": "non-existent-id"},
|
json={"id": "non-existent-id"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
@@ -200,18 +364,30 @@ def test_logs_without_token_returns_401(client):
|
|||||||
assert r.status_code == 401
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_logs_with_token_returns_200_with_list_total_page_page_size(client):
|
def test_logs_missing_id_returns_400(client, auth_token):
|
||||||
|
"""操作日志缺少 id 时返回 400 或 422(参数错误)"""
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/logs",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert r.status_code in (400, 422)
|
||||||
|
body = r.json()
|
||||||
|
assert body.get("code") in (400, 422) or "detail" in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_logs_with_token_returns_200_with_list_total_page_page_size(client, auth_token, market_token):
|
||||||
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
|
"""有 token 时返回 200 且包含 list、total、page、pageSize"""
|
||||||
create_r = client.post(
|
create_r = client.post(
|
||||||
"/api/projects/create",
|
"/api/projects/create",
|
||||||
headers=_auth_header("market-token"),
|
headers=_auth_header(market_token),
|
||||||
json={"contract": {"contractCode": "HT003", "projectName": "测试项目3"}},
|
json={"contract": {"contractCode": "HT003", "projectName": "测试项目3"}},
|
||||||
)
|
)
|
||||||
assert create_r.status_code == 201
|
assert create_r.status_code == 201
|
||||||
pid = create_r.json()["id"]
|
pid = create_r.json()["id"]
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/logs",
|
"/api/projects/logs",
|
||||||
headers=_auth_header("valid-token"),
|
headers=_auth_header(auth_token),
|
||||||
json={"id": pid},
|
json={"id": pid},
|
||||||
)
|
)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
@@ -223,12 +399,68 @@ def test_logs_with_token_returns_200_with_list_total_page_page_size(client):
|
|||||||
assert "pageSize" in body
|
assert "pageSize" in body
|
||||||
|
|
||||||
|
|
||||||
def test_logs_nonexistent_project_returns_404(client):
|
def test_logs_nonexistent_project_returns_404(client, auth_token):
|
||||||
"""不存在项目时返回 404"""
|
"""不存在项目时返回 404"""
|
||||||
r = client.post(
|
r = client.post(
|
||||||
"/api/projects/logs",
|
"/api/projects/logs",
|
||||||
headers=_auth_header("valid-token"),
|
headers=_auth_header(auth_token),
|
||||||
json={"id": "non-existent-id"},
|
json={"id": "non-existent-id"},
|
||||||
)
|
)
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
assert r.json().get("code") == 404
|
assert r.json().get("code") == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_logs_after_update_returns_record_with_summary(client, auth_token, market_token):
|
||||||
|
"""更新项目后调用操作日志接口,至少返回一条记录且含 summary"""
|
||||||
|
create_r = client.post(
|
||||||
|
"/api/projects/create",
|
||||||
|
headers=_auth_header(market_token),
|
||||||
|
json={"contract": {"contractCode": "HT-LOG", "projectName": "日志测试"}},
|
||||||
|
)
|
||||||
|
assert create_r.status_code == 201
|
||||||
|
pid = create_r.json()["id"]
|
||||||
|
client.post(
|
||||||
|
"/api/projects/update",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"id": pid, "contract": {"projectName": "日志测试已更新"}},
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/logs",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"id": pid},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert "list" in body
|
||||||
|
assert len(body["list"]) >= 1
|
||||||
|
first = body["list"][0]
|
||||||
|
assert "summary" in first
|
||||||
|
|
||||||
|
|
||||||
|
def test_logs_list_items_contain_required_fields(client, auth_token, market_token):
|
||||||
|
"""操作日志列表项包含 id、operatorId、operatorName、operatedAt、summary"""
|
||||||
|
create_r = client.post(
|
||||||
|
"/api/projects/create",
|
||||||
|
headers=_auth_header(market_token),
|
||||||
|
json={"contract": {"contractCode": "HT-LOG2", "projectName": "日志字段测试"}},
|
||||||
|
)
|
||||||
|
pid = create_r.json()["id"]
|
||||||
|
client.post(
|
||||||
|
"/api/projects/update",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"id": pid, "contract": {"projectName": "更新"}},
|
||||||
|
)
|
||||||
|
r = client.post(
|
||||||
|
"/api/projects/logs",
|
||||||
|
headers=_auth_header(auth_token),
|
||||||
|
json={"id": pid},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert len(body["list"]) >= 1
|
||||||
|
item = body["list"][0]
|
||||||
|
assert "id" in item
|
||||||
|
assert "operatorId" in item
|
||||||
|
assert "operatorName" in item
|
||||||
|
assert "operatedAt" in item
|
||||||
|
assert "summary" in item
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 后端 API 测试结果与反馈
|
||||||
|
|
||||||
|
**最近一次执行**:回归测试(后端工程师修复登录 500 后)
|
||||||
|
**测试环境**:对已启动服务 `http://127.0.0.1:8000` 发起 HTTP 请求(测试与开发环境隔离)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 回归测试结果汇总
|
||||||
|
|
||||||
|
| 结果 | 数量 |
|
||||||
|
|------|------|
|
||||||
|
| **通过** | **35** |
|
||||||
|
| **失败** | 0 |
|
||||||
|
| **错误** | 0 |
|
||||||
|
| **合计** | 35 |
|
||||||
|
|
||||||
|
**结论:全部用例通过,回归测试通过。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用例明细(35 个)
|
||||||
|
|
||||||
|
### 认证(4)
|
||||||
|
|
||||||
|
- test_login_success_returns_200_with_token_and_user ✓
|
||||||
|
- test_login_wrong_credentials_returns_401_with_code_and_message ✓
|
||||||
|
- test_login_missing_username_returns_400 ✓
|
||||||
|
- test_login_missing_password_returns_400 ✓
|
||||||
|
|
||||||
|
### 项目列表(10)
|
||||||
|
|
||||||
|
- test_list_without_token_returns_401 ✓
|
||||||
|
- test_list_with_token_returns_200_with_list_total_page_page_size ✓
|
||||||
|
- test_list_empty_result_has_empty_list_and_total_zero ✓
|
||||||
|
- test_list_accepts_search_type_name_and_keyword ✓
|
||||||
|
- test_list_accepts_search_type_code_and_keyword ✓
|
||||||
|
- test_list_accepts_progress_filter ✓
|
||||||
|
- test_list_accepts_cost_filter ✓
|
||||||
|
- test_list_accepts_date_filter_and_date_range ✓
|
||||||
|
- test_list_accepts_date_abnormal_filter ✓
|
||||||
|
- test_list_items_contain_required_fields ✓
|
||||||
|
- test_list_respects_page_and_page_size ✓
|
||||||
|
|
||||||
|
### 项目详情(4)
|
||||||
|
|
||||||
|
- test_detail_without_token_returns_401 ✓
|
||||||
|
- test_detail_existing_project_returns_200_with_full_fields ✓
|
||||||
|
- test_detail_nonexistent_project_returns_404_with_code_and_message ✓
|
||||||
|
- test_detail_missing_id_returns_400 ✓
|
||||||
|
|
||||||
|
### 新建项目(5)
|
||||||
|
|
||||||
|
- test_create_without_token_returns_401 ✓
|
||||||
|
- test_create_non_market_role_returns_403 ✓
|
||||||
|
- test_create_market_with_contract_code_and_name_returns_201_with_id_and_message ✓
|
||||||
|
- test_create_missing_contract_code_returns_400 ✓
|
||||||
|
- test_create_missing_project_name_returns_400 ✓
|
||||||
|
- test_create_validation_error_may_include_errors_array ✓
|
||||||
|
|
||||||
|
### 更新项目(4)
|
||||||
|
|
||||||
|
- test_update_without_token_returns_401 ✓
|
||||||
|
- test_update_missing_id_returns_400 ✓
|
||||||
|
- test_update_existing_project_returns_200_with_id_and_save_success_message ✓
|
||||||
|
- test_update_nonexistent_project_returns_404 ✓
|
||||||
|
|
||||||
|
### 操作日志(6)
|
||||||
|
|
||||||
|
- test_logs_without_token_returns_401 ✓
|
||||||
|
- test_logs_missing_id_returns_400 ✓
|
||||||
|
- test_logs_with_token_returns_200_with_list_total_page_page_size ✓
|
||||||
|
- test_logs_nonexistent_project_returns_404 ✓
|
||||||
|
- test_logs_after_update_returns_record_with_summary ✓
|
||||||
|
- test_logs_list_items_contain_required_fields ✓
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 复现命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pytest tests/ -v
|
||||||
|
```
|
||||||
Binary file not shown.
+15
-6
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
### 页面 2:项目列表页
|
### 页面 2:项目列表页
|
||||||
|
|
||||||
- **功能**:展示项目列表,支持按名称/编号搜索、按进度/费用筛选;进入项目详情或新建项目。
|
- **功能**:展示项目列表,支持按名称/编号搜索、按进度/费用筛选、**按各日期筛选**、**按「日期异常」筛选**;进入项目详情或新建项目。
|
||||||
- **用户交互**:
|
- **用户交互**:
|
||||||
|
|
||||||
| 用户操作 | 系统反馈 |
|
| 用户操作 | 系统反馈 |
|
||||||
@@ -55,11 +55,14 @@
|
|||||||
| 选择搜索维度(项目名称 / 项目编号),输入关键词,点击「搜索」或回车 | 按当前维度和关键词请求列表;请求中可保留原列表或局部 Loading;结果替换列表;无结果时展示空状态。 |
|
| 选择搜索维度(项目名称 / 项目编号),输入关键词,点击「搜索」或回车 | 按当前维度和关键词请求列表;请求中可保留原列表或局部 Loading;结果替换列表;无结果时展示空状态。 |
|
||||||
| 选择「项目进度」筛选 | 按所选进度请求列表并刷新表格;无结果时展示空状态。 |
|
| 选择「项目进度」筛选 | 按所选进度请求列表并刷新表格;无结果时展示空状态。 |
|
||||||
| 选择「项目费用」筛选 | 按所选费用请求列表并刷新表格;无结果时展示空状态。 |
|
| 选择「项目费用」筛选 | 按所选费用请求列表并刷新表格;无结果时展示空状态。 |
|
||||||
|
| **选择按日期筛选**(签订日期 / 开工日期 / 计划竣工日期 / 实际竣工日期) | 按所选日期类型及条件(如区间、单日等,与后端约定)请求列表并刷新表格;无结果时展示空状态。 |
|
||||||
|
| **选择「日期异常」筛选** | 仅展示存在“日期异常”的项目(任一日期的值非「日期正常」);与后端接口参数一致;无结果时展示空状态。 |
|
||||||
| 点击某一行的「查看」或「编辑」 | 跳转至**项目详情/编辑页**,并传入该项目 ID;详情加载中显示 Loading。 |
|
| 点击某一行的「查看」或「编辑」 | 跳转至**项目详情/编辑页**,并传入该项目 ID;详情加载中显示 Loading。 |
|
||||||
| 点击「新建项目」(仅市场部可见) | 跳转至**项目详情/编辑页**,不传项目 ID(新建模式);页面展示 5 个 Tab 的空表单。 |
|
| 点击「新建项目」(仅市场部可见) | 跳转至**项目详情/编辑页**,不传项目 ID(新建模式);页面展示 5 个 Tab 的空表单。 |
|
||||||
| 分页操作(若有) | 请求对应页数据并刷新列表。 |
|
| 分页操作(若有) | 请求对应页数据并刷新列表。 |
|
||||||
|
|
||||||
- **列表展示**:至少包含项目名称、合同编号、进度、费用等关键信息列,以及操作列(查看/编辑)。具体列与后端接口字段一致。
|
- **列表展示**:至少包含项目名称、合同编号、进度、费用等关键信息列,以及操作列(查看/编辑)。具体列与后端接口字段一致。
|
||||||
|
- **筛选说明**:除项目进度、项目费用外,列表需支持**按签订日期、开工日期、计划竣工日期、实际竣工日期**的筛选(日期类型与条件与后端约定),以及**「日期异常」**筛选项(筛选至少有一个日期字段非「日期正常」的项目)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -80,6 +83,12 @@
|
|||||||
|
|
||||||
- **5 个 Tab**:合同信息、成本控制、应收款、应付款、其他。每个 Tab 内为该分类下的表单字段,字段与《产品文档》一致,单位与校验与后端对齐。字段清单见本文第 5 节。
|
- **5 个 Tab**:合同信息、成本控制、应收款、应付款、其他。每个 Tab 内为该分类下的表单字段,字段与《产品文档》一致,单位与校验与后端对齐。字段清单见本文第 5 节。
|
||||||
|
|
||||||
|
- **日期字段逻辑(签订日期、开工日期、计划竣工日期、实际竣工日期)**
|
||||||
|
以上四个字段在后端为**字符串**,既可表示日期,也可表示说明文字;前端需同时支持**显示日期**或**显示字符串**。
|
||||||
|
- **后端存储(导入)**:导入数据时,若该字段有有效日期数据,则存为固定字符串「日期正常」;若无日期数据,则存入导入的原始字符串(如「未签订」「待定」等)。
|
||||||
|
- **API 返回**:接口返回时,若该字段值为「日期正常」,则返回实际日期(格式与后端约定);否则返回该字符串。
|
||||||
|
- **前端展示**:根据接口返回值判断——若为日期格式则按日期展示(如日期选择器或格式化日期文本);若为字符串则直接展示该字符串。编辑时,用户可选择/输入日期或输入文字,提交格式与后端接口约定一致。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 页面 4:操作日志
|
### 页面 4:操作日志
|
||||||
@@ -115,7 +124,7 @@
|
|||||||
|
|
||||||
详情/编辑页按 5 个 Tab 组织,字段与《产品文档》一致;单位、校验、是否必填与后端对齐。
|
详情/编辑页按 5 个 Tab 组织,字段与《产品文档》一致;单位、校验、是否必填与后端对齐。
|
||||||
|
|
||||||
- **合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订日期、开工日期、计划竣工日期、实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
|
- **合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、**签订日期、开工日期、计划竣工日期、实际竣工日期**(以上四字段可显示日期或字符串,逻辑见页面 3「日期字段逻辑」)、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
|
||||||
- **成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。
|
- **成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。
|
||||||
- **应收款**:应收款(完成进度款)、开票金额、实际收款金额、实际收款完成率。
|
- **应收款**:应收款(完成进度款)、开票金额、实际收款金额、实际收款完成率。
|
||||||
- **应付款**:应付款金额、实际付款金额、未收款、实际付款完成率、民工工资清欠金额。
|
- **应付款**:应付款金额、实际付款金额、未收款、实际付款完成率、民工工资清欠金额。
|
||||||
@@ -128,10 +137,10 @@
|
|||||||
- **页面数**:4 个(登录、项目列表、项目详情/编辑、操作日志;操作日志可为抽屉)。
|
- **页面数**:4 个(登录、项目列表、项目详情/编辑、操作日志;操作日志可为抽屉)。
|
||||||
- **权限**:仅市场部展示「新建项目」;所有角色可查看、编辑、保存;保存由后端记日志,前端仅提供「查看操作日志」入口与展示。
|
- **权限**:仅市场部展示「新建项目」;所有角色可查看、编辑、保存;保存由后端记日志,前端仅提供「查看操作日志」入口与展示。
|
||||||
- **搜索**:项目名称、项目编号;触发方式与后端约定(如回车/点击搜索)。
|
- **搜索**:项目名称、项目编号;触发方式与后端约定(如回车/点击搜索)。
|
||||||
- **筛选**:项目进度、项目费用;选项与列表接口参数一致。
|
- **筛选**:项目进度、项目费用;**按签订日期、开工日期、计划竣工日期、实际竣工日期**(日期条件与后端约定);**「日期异常」**(筛选存在非「日期正常」日期字段的项目);选项与列表接口参数一致。
|
||||||
- **详情页**:5 个 Tab 对应 5 类字段,保存时防重复提交、成功/失败有明确反馈。
|
- **详情页**:5 个 Tab 对应 5 类字段;**签订日期、开工日期、计划竣工日期、实际竣工日期** 四个字段:API 若返回日期则按日期展示,若返回字符串则原样展示;保存时防重复提交、成功/失败有明确反馈。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**文档版本**:v2
|
**文档版本**:v2.1
|
||||||
**依据**:产品文档
|
**依据**:产品文档、后端日期字段与列表筛选约定
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## 3. 项目信息(5 个分类)
|
## 3. 项目信息(5 个分类)
|
||||||
|
|
||||||
**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订/开工/计划竣工/实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
|
**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订日期、开工日期、计划竣工日期、实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
|
||||||
|
|
||||||
**成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。
|
**成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user