后端第一步开发完成,第一版回归测试完成

This commit is contained in:
Your Name
2026-01-31 19:09:48 +08:00
parent 11cef531bd
commit c9e75030af
26 changed files with 1686 additions and 184 deletions
+10
View File
@@ -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
View File
@@ -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 | 项目负责人及电话 |
+19
View File
@@ -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
View File
@@ -1,29 +1,35 @@
# 项目管理系统后端
依据《产品文档》与《API 文档》,Python + FastAPI 实现,TDD 开发
依据《产品文档》与《API 文档》,Python + FastAPI 实现,数据持久化到 MySQLocean 库)
## 目录结构
```
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`。
+5 -4
View File
@@ -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
View File
@@ -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 '乙供材料费(控制)',
+79
View File
@@ -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``初始需求` 对齐(含「其中:」等前缀的列已映射)。
- 导入成功后输出:成功条数、跳过条数。
+26
View File
@@ -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()
+7
View File
@@ -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;
+314
View File
@@ -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 或 Nonestr_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()
+35
View File
@@ -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()
+59
View File
@@ -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
View File
@@ -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}
+70
View File
@@ -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)
+19
View File
@@ -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"))
+44
View File
@@ -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
+381
View File
@@ -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
View File
@@ -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/updatedAtpage/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
View File
@@ -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")
+4 -2
View File
@@ -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()
+263 -31
View File
@@ -1,6 +1,8 @@
"""
项目 API 测试(依据 API 文档 3–7 节)
TDD:先写失败用例,再实现路由
测试与开发环境严格隔离:通过 HTTP 请求已启动的后端服务,带 Token 调用项目列表/详情/新建/更新/操作日志。
运行前请先启动后端:http://127.0.0.1:8000;登录账号见 conftestadmin / 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
+83
View File
@@ -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
```
BIN
View File
Binary file not shown.
+15 -6
View File
@@ -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
View File
@@ -14,7 +14,7 @@
## 3. 项目信息(5 个分类)
**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订/开工/计划竣工/实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订日期、开工日期、计划竣工日期、实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。
**成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。