后端第一步开发完成,第一版回归测试完成
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` |
|
||||
| **说明** | 分页获取项目列表,支持按名称/编号搜索、按进度/费用筛选 |
|
||||
| **说明** | 分页获取项目列表,支持按名称/编号搜索、按进度/费用筛选、时间筛选、日期异常筛选 |
|
||||
| **权限** | 已登录,所有角色可访问 |
|
||||
|
||||
#### 请求参数
|
||||
@@ -71,6 +71,10 @@
|
||||
| keyword | string | 否 | 搜索关键词,与 searchType 配合使用 |
|
||||
| progress | 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)
|
||||
|
||||
@@ -261,7 +265,7 @@
|
||||
| subItemCount | number | 子项个数 |
|
||||
| subItemCode | string | 子项编码 |
|
||||
| totalInvestment | number | 项目总投资(万元) |
|
||||
| bidContractAmount | number | 中标合同金额(万元) |
|
||||
| bidContractAmount | string | 中标合同金额(万元,字符串存储) |
|
||||
| warrantyRatio | string \| number | 质保金比例 |
|
||||
| settlementAmount | number | 结算金额(万元) |
|
||||
| totalCostEstimate | number | 总成本测算 |
|
||||
@@ -270,12 +274,12 @@
|
||||
| ownerUnit | string | 业主单位 |
|
||||
| ownerContact | string | 业主联系人及电话 |
|
||||
| bidType | string | 中标形式 |
|
||||
| signDate | string | 签订日期(YYYY-MM-DD) |
|
||||
| startDate | string | 开工日期 |
|
||||
| plannedCompletionDate | string | 计划竣工日期 |
|
||||
| actualCompletionDate | string | 实际竣工日期 |
|
||||
| signDate | string | 签订日期:有有效日期时返回 YYYY-MM-DD,否则返回原文(如「在建中」「未开工」) |
|
||||
| startDate | string | 开工日期:同上 |
|
||||
| plannedCompletionDate | string | 计划竣工日期:同上 |
|
||||
| actualCompletionDate | string | 实际竣工日期:同上 |
|
||||
| warrantyAmount | number | 质保金(万元) |
|
||||
| warrantyEndDate | string | 质保期截止日 |
|
||||
| warrantyEndDate | string | 质保期截止日(字符串存储) |
|
||||
| actualWarrantyRefundDate | string | 实际退质保金日期 |
|
||||
| projectDepartment | string | 所属项目部 |
|
||||
| projectLeaderContact | string | 项目负责人及电话 |
|
||||
|
||||
@@ -35,6 +35,25 @@ mysql -u root -p < backend/schema.sql
|
||||
|
||||
按提示输入 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. 验证
|
||||
|
||||
```bash
|
||||
|
||||
+31
-13
@@ -1,29 +1,35 @@
|
||||
# 项目管理系统后端
|
||||
|
||||
依据《产品文档》与《API 文档》,Python + FastAPI 实现,TDD 开发。
|
||||
依据《产品文档》与《API 文档》,Python + FastAPI 实现,数据持久化到 MySQL(ocean 库)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── src/ # 后端源码
|
||||
├── src/ # 后端源码
|
||||
│ ├── __init__.py
|
||||
│ └── app.py # FastAPI 应用与路由
|
||||
├── tests/ # 测试用例
|
||||
├── pytest.ini # pytest 配置(pythonpath = src)
|
||||
│ ├── app.py # FastAPI 应用与路由
|
||||
│ ├── config.py # 配置(环境变量)
|
||||
│ ├── database.py # MySQL 连接
|
||||
│ ├── auth.py # 登录、Token 校验
|
||||
│ └── projects_repo.py # 项目与操作日志数据层
|
||||
├── tests/ # 测试(由测试工程师维护)
|
||||
├── schema.sql # 数据库表结构
|
||||
├── .env.example # 环境变量示例
|
||||
├── requirements.txt
|
||||
└── API.md
|
||||
```
|
||||
|
||||
## 运行测试
|
||||
## 环境与数据库
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
source .venv/bin/activate
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
pytest 会通过 `pytest.ini` 的 `pythonpath = src` 自动把 `src` 加入 Python 路径,无需手动设置 PYTHONPATH。
|
||||
1. 复制 `.env.example` 为 `.env`,按本地 MySQL 填写 `MYSQL_*`。
|
||||
2. 执行 `schema.sql` 创建数据库 `ocean` 及表(users、projects、operation_logs)。**若已有 projects 表**,需补加四个日期原文列:见下方「数据库迁移」。
|
||||
3. **首次使用**:在 `users` 表中插入至少一个用户;`password_hash` 需用 bcrypt 哈希。可在 Python 中执行:
|
||||
```python
|
||||
from app.auth import hash_password
|
||||
print(hash_password("你的密码")) # 将输出写入 INSERT 语句
|
||||
```
|
||||
示例 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
|
||||
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 文档)
|
||||
fastapi>=0.104.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
|
||||
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_code` varchar(128) 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 '质保金比例',
|
||||
`settlement_amount` 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 '业主联系人及电话',
|
||||
`bid_type` varchar(64) 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_str` varchar(128) 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_str` varchar(128) 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 '实际退质保金日期',
|
||||
`project_department` varchar(128) 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 '总体成本',
|
||||
`is_adjusted` varchar(8) DEFAULT NULL COMMENT '是否调整:是/否',
|
||||
`is_adjusted` varchar(512) DEFAULT NULL COMMENT '是否调整:是/否',
|
||||
`migrant_worker_plan` varchar(64) DEFAULT NULL COMMENT '农民工工资(按进度计划)',
|
||||
`migrant_worker_actual` 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 文档)
|
||||
TDD:路由按测试用例实现
|
||||
功能:认证、项目 CRUD、操作日志;数据持久化到 MySQL。
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI, Header, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from . import auth
|
||||
from . import projects_repo
|
||||
|
||||
app = FastAPI(title="项目管理系统 API", version="1.0.0")
|
||||
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):
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
@@ -44,6 +29,10 @@ class ProjectListBody(BaseModel):
|
||||
keyword: Optional[str] = None
|
||||
progress: 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):
|
||||
@@ -78,20 +67,15 @@ class ProjectLogsBody(BaseModel):
|
||||
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 "):
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
token = authorization[7:].strip()
|
||||
role = TOKEN_ROLE.get(token)
|
||||
if role is None:
|
||||
user = auth.get_user_by_token(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="未登录")
|
||||
return role
|
||||
|
||||
|
||||
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()
|
||||
return user
|
||||
|
||||
|
||||
# ---------- 认证 ----------
|
||||
@@ -101,16 +85,22 @@ def login(body: LoginBody):
|
||||
raise HTTPException(status_code=400, detail="缺少 username")
|
||||
if body.password is None or (isinstance(body.password, str) and body.password.strip() == ""):
|
||||
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": "账号或密码错误"})
|
||||
token = "valid-token"
|
||||
if not user:
|
||||
return JSONResponse(status_code=401, content={"code": 401, "message": "账号或密码错误"})
|
||||
token = auth.create_token(user)
|
||||
return {
|
||||
"token": token,
|
||||
"user": {
|
||||
"id": "user-market",
|
||||
"username": VALID_USER["username"],
|
||||
"role": VALID_USER["role"],
|
||||
"displayName": VALID_USER["display_name"],
|
||||
"id": user["id"],
|
||||
"username": user["username"],
|
||||
"role": user["role"],
|
||||
"displayName": user["displayName"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -118,50 +108,39 @@ def login(body: LoginBody):
|
||||
# ---------- 项目列表 ----------
|
||||
@app.post("/api/projects/list")
|
||||
def project_list(body: ProjectListBody, authorization: Optional[str] = Header(None)):
|
||||
_get_role(authorization)
|
||||
_get_current_user(authorization)
|
||||
page = body.page or 1
|
||||
page_size = body.pageSize or 20
|
||||
items = list(PROJECTS.values())
|
||||
# 列表项:id, projectName, contractCode, progress, cost, updatedAt
|
||||
list_ = [
|
||||
{
|
||||
"id": p["id"],
|
||||
"projectName": (p.get("contract") or {}).get("projectName") or "",
|
||||
"contractCode": (p.get("contract") or {}).get("contractCode") or "",
|
||||
"progress": p.get("progress") or "",
|
||||
"cost": p.get("cost") or "",
|
||||
"updatedAt": p.get("updatedAt") or "",
|
||||
}
|
||||
for p in items
|
||||
]
|
||||
return {"list": list_, "total": len(list_), "page": page, "pageSize": page_size}
|
||||
list_, total = projects_repo.list_projects(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
search_type=body.searchType,
|
||||
keyword=body.keyword,
|
||||
progress=body.progress,
|
||||
cost=body.cost,
|
||||
date_filter_type=body.dateFilterType,
|
||||
date_from=body.dateFrom,
|
||||
date_to=body.dateTo,
|
||||
date_abnormal=body.dateAbnormal,
|
||||
)
|
||||
return {"list": list_, "total": total, "page": page, "pageSize": page_size}
|
||||
|
||||
|
||||
# ---------- 项目详情 ----------
|
||||
@app.post("/api/projects/detail")
|
||||
def project_detail(body: ProjectDetailBody, authorization: Optional[str] = Header(None)):
|
||||
_get_role(authorization)
|
||||
pid = body.id
|
||||
if pid not in PROJECTS:
|
||||
_get_current_user(authorization)
|
||||
project = projects_repo.get_project(body.id)
|
||||
if not project:
|
||||
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
|
||||
p = PROJECTS[pid]
|
||||
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", ""),
|
||||
}
|
||||
return project
|
||||
|
||||
|
||||
# ---------- 新建项目 ----------
|
||||
@app.post("/api/projects/create", status_code=201)
|
||||
def project_create(body: ProjectCreateBody, authorization: Optional[str] = Header(None)):
|
||||
role = _get_role(authorization)
|
||||
if role != "市场部":
|
||||
user = _get_current_user(authorization)
|
||||
if user["role"] != "市场部":
|
||||
return JSONResponse(status_code=403, content={"code": 403, "message": "无权限"})
|
||||
contract = body.contract
|
||||
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": "合同编号不能为空"})
|
||||
if project_name is None or (isinstance(project_name, str) and project_name.strip() == ""):
|
||||
return JSONResponse(status_code=400, content={"code": 400, "message": "项目名称不能为空"})
|
||||
pid = str(uuid.uuid4())
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
PROJECTS[pid] = {
|
||||
"id": pid,
|
||||
"contract": body.contract.model_dump() if body.contract else {},
|
||||
"costControl": body.costControl or {},
|
||||
"receivable": body.receivable or {},
|
||||
"payable": body.payable or {},
|
||||
"other": body.other or {},
|
||||
"createdAt": now,
|
||||
"updatedAt": now,
|
||||
}
|
||||
OPERATION_LOGS[pid] = []
|
||||
contract_dict = body.contract.model_dump() if body.contract else {}
|
||||
pid = projects_repo.create_project(
|
||||
contract=contract_dict,
|
||||
cost_control=body.costControl,
|
||||
receivable=body.receivable,
|
||||
payable=body.payable,
|
||||
other=body.other,
|
||||
)
|
||||
return {"id": pid, "message": "创建成功"}
|
||||
|
||||
|
||||
# ---------- 更新项目 ----------
|
||||
@app.post("/api/projects/update")
|
||||
def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Header(None)):
|
||||
_get_role(authorization)
|
||||
pid = body.id
|
||||
if pid not in PROJECTS:
|
||||
user = _get_current_user(authorization)
|
||||
if not projects_repo.project_exists(body.id):
|
||||
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:
|
||||
p["contract"] = {**(p.get("contract") or {}), **body.contract}
|
||||
summary_parts.append("合同信息")
|
||||
if body.costControl is not None:
|
||||
p["costControl"] = {**(p.get("costControl") or {}), **body.costControl}
|
||||
summary_parts.append("成本控制")
|
||||
if body.receivable is not None:
|
||||
p["receivable"] = {**(p.get("receivable") or {}), **body.receivable}
|
||||
summary_parts.append("应收款")
|
||||
if body.payable is not None:
|
||||
p["payable"] = {**(p.get("payable") or {}), **body.payable}
|
||||
summary_parts.append("应付款")
|
||||
if body.other is not None:
|
||||
p["other"] = {**(p.get("other") or {}), **body.other}
|
||||
p["updatedAt"] = datetime.utcnow().isoformat() + "Z"
|
||||
return {"id": pid, "message": "保存成功"}
|
||||
summary_parts.append("其他")
|
||||
summary = "、".join(summary_parts) if summary_parts else "项目信息"
|
||||
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")
|
||||
def project_logs(body: ProjectLogsBody, authorization: Optional[str] = Header(None)):
|
||||
_get_role(authorization)
|
||||
pid = body.id
|
||||
if pid not in PROJECTS:
|
||||
_get_current_user(authorization)
|
||||
if not projects_repo.project_exists(body.id):
|
||||
return JSONResponse(status_code=404, content={"code": 404, "message": "项目不存在"})
|
||||
logs = OPERATION_LOGS.get(pid, [])
|
||||
page = body.page or 1
|
||||
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)。**
|
||||
|
||||
后端源码在 `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
|
||||
# 1. 先启动后端(在 backend 目录或项目根目录)
|
||||
uvicorn src.app:app --host 0.0.0.0 --port 8000
|
||||
|
||||
# 2. 在另一终端运行测试
|
||||
cd backend
|
||||
source .venv/bin/activate # 或 pip install -r requirements.txt
|
||||
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/projects/list | 无 token 401、有 token 返回 list/total/page/pageSize、无数据时 list 空且 total=0 |
|
||||
| POST /api/projects/detail | 无 token 401、存在项目返回完整字段、不存在 404 |
|
||||
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400 |
|
||||
| POST /api/projects/update | 无 token 401、存在项目 200 保存成功、不存在 404 |
|
||||
| POST /api/projects/logs | 无 token 401、有 token 返回 list/total/page/pageSize、不存在项目 404 |
|
||||
| POST /api/auth/login | 成功返回 token/user、错误凭证 401、缺 username/password 返回 400 |
|
||||
| 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、缺 id 400/422、存在项目返回完整五类字段、不存在 404 |
|
||||
| POST /api/projects/create | 无 token 401、非市场部 403、市场部且必填齐全 201、缺合同编号/项目名称 400、校验失败可含 errors 数组 |
|
||||
| POST /api/projects/update | 无 token 401、缺 id 400/422、存在项目 200 保存成功、不存在 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
|
||||
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)
|
||||
def reset_store():
|
||||
"""每个测试前清空内存存储,保证隔离"""
|
||||
PROJECTS.clear()
|
||||
OPERATION_LOGS.clear()
|
||||
yield
|
||||
@pytest.fixture(scope="session")
|
||||
def http_client():
|
||||
"""请求已启动的后端服务的 HTTP 客户端(session 级,复用连接)"""
|
||||
with httpx.Client(base_url=BASE_URL, timeout=30.0) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""同步测试用:FastAPI TestClient"""
|
||||
return TestClient(app)
|
||||
def client(http_client):
|
||||
"""请求已启动的后端服务的 HTTP 客户端(测试用别名)"""
|
||||
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 登录)
|
||||
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"""
|
||||
r = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "market", "password": "valid-password"},
|
||||
json={"username": "admin", "password": "123456"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
项目 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
|
||||
|
||||
|
||||
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"""
|
||||
r = client.post(
|
||||
"/api/projects/list",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"page": 1, "pageSize": 20},
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def test_list_empty_result_has_empty_list_and_total_zero(client):
|
||||
"""无数据时 list 为空数组且 total 为 0"""
|
||||
def test_list_empty_result_has_empty_list_and_total_zero(client, auth_token):
|
||||
"""列表返回 200;无数据时 list 为空且 total 为 0(有数据时仅校验结构)"""
|
||||
r = client.post(
|
||||
"/api/projects/list",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["list"] == []
|
||||
assert r.json()["total"] == 0
|
||||
body = r.json()
|
||||
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 ----------
|
||||
@@ -54,18 +179,18 @@ def test_detail_without_token_returns_401(client):
|
||||
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"""
|
||||
create_r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("market-token"),
|
||||
headers=_auth_header(market_token),
|
||||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||||
)
|
||||
assert create_r.status_code == 201
|
||||
pid = create_r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/projects/detail",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": pid},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@@ -80,11 +205,11 @@ def test_detail_existing_project_returns_200_with_full_fields(client):
|
||||
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"""
|
||||
r = client.post(
|
||||
"/api/projects/detail",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": "non-existent-id"},
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
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 ----------
|
||||
|
||||
|
||||
@@ -105,22 +242,22 @@ def test_create_without_token_returns_401(client):
|
||||
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"""
|
||||
r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("engineer-token"),
|
||||
headers=_auth_header(engineer_token),
|
||||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||||
)
|
||||
assert r.status_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"""
|
||||
r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("market-token"),
|
||||
headers=_auth_header(market_token),
|
||||
json={"contract": {"contractCode": "HT001", "projectName": "测试项目"}},
|
||||
)
|
||||
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") == "创建成功"
|
||||
|
||||
|
||||
def test_create_missing_contract_code_returns_400(client):
|
||||
def test_create_missing_contract_code_returns_400(client, market_token):
|
||||
"""缺少合同编号时返回 400"""
|
||||
r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("market-token"),
|
||||
headers=_auth_header(market_token),
|
||||
json={"contract": {"projectName": "测试项目"}},
|
||||
)
|
||||
assert r.status_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"""
|
||||
r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("market-token"),
|
||||
headers=_auth_header(market_token),
|
||||
json={"contract": {"contractCode": "HT001"}},
|
||||
)
|
||||
assert r.status_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 ----------
|
||||
|
||||
|
||||
@@ -160,18 +312,30 @@ def test_update_without_token_returns_401(client):
|
||||
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「保存成功」"""
|
||||
create_r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("market-token"),
|
||||
headers=_auth_header(market_token),
|
||||
json={"contract": {"contractCode": "HT002", "projectName": "测试项目2"}},
|
||||
)
|
||||
assert create_r.status_code == 201
|
||||
pid = create_r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/projects/update",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": pid, "contract": {"projectName": "新名称"}},
|
||||
)
|
||||
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") == "保存成功"
|
||||
|
||||
|
||||
def test_update_nonexistent_project_returns_404(client):
|
||||
def test_update_nonexistent_project_returns_404(client, auth_token):
|
||||
"""不存在项目时返回 404"""
|
||||
r = client.post(
|
||||
"/api/projects/update",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": "non-existent-id"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
@@ -200,18 +364,30 @@ def test_logs_without_token_returns_401(client):
|
||||
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"""
|
||||
create_r = client.post(
|
||||
"/api/projects/create",
|
||||
headers=_auth_header("market-token"),
|
||||
headers=_auth_header(market_token),
|
||||
json={"contract": {"contractCode": "HT003", "projectName": "测试项目3"}},
|
||||
)
|
||||
assert create_r.status_code == 201
|
||||
pid = create_r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/projects/logs",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": pid},
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
def test_logs_nonexistent_project_returns_404(client):
|
||||
def test_logs_nonexistent_project_returns_404(client, auth_token):
|
||||
"""不存在项目时返回 404"""
|
||||
r = client.post(
|
||||
"/api/projects/logs",
|
||||
headers=_auth_header("valid-token"),
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": "non-existent-id"},
|
||||
)
|
||||
assert r.status_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:项目列表页
|
||||
|
||||
- **功能**:展示项目列表,支持按名称/编号搜索、按进度/费用筛选;进入项目详情或新建项目。
|
||||
- **功能**:展示项目列表,支持按名称/编号搜索、按进度/费用筛选、**按各日期筛选**、**按「日期异常」筛选**;进入项目详情或新建项目。
|
||||
- **用户交互**:
|
||||
|
||||
| 用户操作 | 系统反馈 |
|
||||
@@ -55,11 +55,14 @@
|
||||
| 选择搜索维度(项目名称 / 项目编号),输入关键词,点击「搜索」或回车 | 按当前维度和关键词请求列表;请求中可保留原列表或局部 Loading;结果替换列表;无结果时展示空状态。 |
|
||||
| 选择「项目进度」筛选 | 按所选进度请求列表并刷新表格;无结果时展示空状态。 |
|
||||
| 选择「项目费用」筛选 | 按所选费用请求列表并刷新表格;无结果时展示空状态。 |
|
||||
| **选择按日期筛选**(签订日期 / 开工日期 / 计划竣工日期 / 实际竣工日期) | 按所选日期类型及条件(如区间、单日等,与后端约定)请求列表并刷新表格;无结果时展示空状态。 |
|
||||
| **选择「日期异常」筛选** | 仅展示存在“日期异常”的项目(任一日期的值非「日期正常」);与后端接口参数一致;无结果时展示空状态。 |
|
||||
| 点击某一行的「查看」或「编辑」 | 跳转至**项目详情/编辑页**,并传入该项目 ID;详情加载中显示 Loading。 |
|
||||
| 点击「新建项目」(仅市场部可见) | 跳转至**项目详情/编辑页**,不传项目 ID(新建模式);页面展示 5 个 Tab 的空表单。 |
|
||||
| 分页操作(若有) | 请求对应页数据并刷新列表。 |
|
||||
|
||||
- **列表展示**:至少包含项目名称、合同编号、进度、费用等关键信息列,以及操作列(查看/编辑)。具体列与后端接口字段一致。
|
||||
- **筛选说明**:除项目进度、项目费用外,列表需支持**按签订日期、开工日期、计划竣工日期、实际竣工日期**的筛选(日期类型与条件与后端约定),以及**「日期异常」**筛选项(筛选至少有一个日期字段非「日期正常」的项目)。
|
||||
|
||||
---
|
||||
|
||||
@@ -80,6 +83,12 @@
|
||||
|
||||
- **5 个 Tab**:合同信息、成本控制、应收款、应付款、其他。每个 Tab 内为该分类下的表单字段,字段与《产品文档》一致,单位与校验与后端对齐。字段清单见本文第 5 节。
|
||||
|
||||
- **日期字段逻辑(签订日期、开工日期、计划竣工日期、实际竣工日期)**
|
||||
以上四个字段在后端为**字符串**,既可表示日期,也可表示说明文字;前端需同时支持**显示日期**或**显示字符串**。
|
||||
- **后端存储(导入)**:导入数据时,若该字段有有效日期数据,则存为固定字符串「日期正常」;若无日期数据,则存入导入的原始字符串(如「未签订」「待定」等)。
|
||||
- **API 返回**:接口返回时,若该字段值为「日期正常」,则返回实际日期(格式与后端约定);否则返回该字符串。
|
||||
- **前端展示**:根据接口返回值判断——若为日期格式则按日期展示(如日期选择器或格式化日期文本);若为字符串则直接展示该字符串。编辑时,用户可选择/输入日期或输入文字,提交格式与后端接口约定一致。
|
||||
|
||||
---
|
||||
|
||||
### 页面 4:操作日志
|
||||
@@ -115,7 +124,7 @@
|
||||
|
||||
详情/编辑页按 5 个 Tab 组织,字段与《产品文档》一致;单位、校验、是否必填与后端对齐。
|
||||
|
||||
- **合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订日期、开工日期、计划竣工日期、实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
|
||||
- **合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、**签订日期、开工日期、计划竣工日期、实际竣工日期**(以上四字段可显示日期或字符串,逻辑见页面 3「日期字段逻辑」)、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
|
||||
- **成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。
|
||||
- **应收款**:应收款(完成进度款)、开票金额、实际收款金额、实际收款完成率。
|
||||
- **应付款**:应付款金额、实际付款金额、未收款、实际付款完成率、民工工资清欠金额。
|
||||
@@ -128,10 +137,10 @@
|
||||
- **页面数**:4 个(登录、项目列表、项目详情/编辑、操作日志;操作日志可为抽屉)。
|
||||
- **权限**:仅市场部展示「新建项目」;所有角色可查看、编辑、保存;保存由后端记日志,前端仅提供「查看操作日志」入口与展示。
|
||||
- **搜索**:项目名称、项目编号;触发方式与后端约定(如回车/点击搜索)。
|
||||
- **筛选**:项目进度、项目费用;选项与列表接口参数一致。
|
||||
- **详情页**:5 个 Tab 对应 5 类字段,保存时防重复提交、成功/失败有明确反馈。
|
||||
- **筛选**:项目进度、项目费用;**按签订日期、开工日期、计划竣工日期、实际竣工日期**(日期条件与后端约定);**「日期异常」**(筛选存在非「日期正常」日期字段的项目);选项与列表接口参数一致。
|
||||
- **详情页**:5 个 Tab 对应 5 类字段;**签订日期、开工日期、计划竣工日期、实际竣工日期** 四个字段:API 若返回日期则按日期展示,若返回字符串则原样展示;保存时防重复提交、成功/失败有明确反馈。
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v2
|
||||
**依据**:产品文档
|
||||
**文档版本**:v2.1
|
||||
**依据**:产品文档、后端日期字段与列表筛选约定
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
## 3. 项目信息(5 个分类)
|
||||
|
||||
**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订/开工/计划竣工/实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
|
||||
**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订日期、开工日期、计划竣工日期、实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
|
||||
|
||||
**成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user