diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..21ca9f3 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/API.md b/backend/API.md index 063f615..7398346 100644 --- a/backend/API.md +++ b/backend/API.md @@ -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 | 项目负责人及电话 | diff --git a/backend/README-DB.md b/backend/README-DB.md index f2d4f93..57d40fa 100644 --- a/backend/README-DB.md +++ b/backend/README-DB.md @@ -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 diff --git a/backend/README.md b/backend/README.md index 2dbf240..d9b5749 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 `。 +- **项目列表**:分页,支持按项目名称/合同编号搜索(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`。 diff --git a/backend/requirements.txt b/backend/requirements.txt index a183bbb..8e28b9a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/schema.sql b/backend/schema.sql index 8e5df22..8d685e6 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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 '乙供材料费(控制)', diff --git a/backend/scripts/README.md b/backend/scripts/README.md new file mode 100644 index 0000000..3e0c414 --- /dev/null +++ b/backend/scripts/README.md @@ -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`、`初始需求` 对齐(含「其中:」等前缀的列已映射)。 +- 导入成功后输出:成功条数、跳过条数。 diff --git a/backend/scripts/clean_db.py b/backend/scripts/clean_db.py new file mode 100644 index 0000000..3e235b6 --- /dev/null +++ b/backend/scripts/clean_db.py @@ -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() diff --git a/backend/scripts/create_db_user.sql b/backend/scripts/create_db_user.sql new file mode 100644 index 0000000..b847fa5 --- /dev/null +++ b/backend/scripts/create_db_user.sql @@ -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; diff --git a/backend/scripts/import_xls.py b/backend/scripts/import_xls.py new file mode 100644 index 0000000..405a4d6 --- /dev/null +++ b/backend/scripts/import_xls.py @@ -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 ") + 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() diff --git a/backend/scripts/migrate_add_date_str_columns.py b/backend/scripts/migrate_add_date_str_columns.py new file mode 100644 index 0000000..0dbc4e0 --- /dev/null +++ b/backend/scripts/migrate_add_date_str_columns.py @@ -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() diff --git a/backend/scripts/migrate_string_columns.py b/backend/scripts/migrate_string_columns.py new file mode 100644 index 0000000..2d37d0d --- /dev/null +++ b/backend/scripts/migrate_string_columns.py @@ -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() diff --git a/backend/scripts/seed_test_users.py b/backend/scripts/seed_test_users.py new file mode 100644 index 0000000..aa15018 --- /dev/null +++ b/backend/scripts/seed_test_users.py @@ -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() diff --git a/backend/src/app.py b/backend/src/app.py index 3c161c2..bfa2b6b 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -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} diff --git a/backend/src/auth.py b/backend/src/auth.py new file mode 100644 index 0000000..9f0ff42 --- /dev/null +++ b/backend/src/auth.py @@ -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) diff --git a/backend/src/config.py b/backend/src/config.py new file mode 100644 index 0000000..9e698ca --- /dev/null +++ b/backend/src/config.py @@ -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")) diff --git a/backend/src/database.py b/backend/src/database.py new file mode 100644 index 0000000..5cbe43d --- /dev/null +++ b/backend/src/database.py @@ -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 diff --git a/backend/src/projects_repo.py b/backend/src/projects_repo.py new file mode 100644 index 0000000..582984f --- /dev/null +++ b/backend/src/projects_repo.py @@ -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 diff --git a/backend/tests/README.md b/backend/tests/README.md index b13df96..82f0b86 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -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 | diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 26f1bac..816dd1c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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") diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 1c64d61..4a0328b 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -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() diff --git a/backend/tests/test_projects_api.py b/backend/tests/test_projects_api.py index f40b28e..64e43ad 100644 --- a/backend/tests/test_projects_api.py +++ b/backend/tests/test_projects_api.py @@ -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 diff --git a/backend/tests/测试结果反馈.md b/backend/tests/测试结果反馈.md new file mode 100644 index 0000000..c2325b4 --- /dev/null +++ b/backend/tests/测试结果反馈.md @@ -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 +``` diff --git a/docs/example.xls b/docs/example.xls new file mode 100644 index 0000000..eae71b0 Binary files /dev/null and b/docs/example.xls differ diff --git a/docs/交互文档.md b/docs/交互文档.md index d4fb90a..c7a5c8a 100644 --- a/docs/交互文档.md +++ b/docs/交互文档.md @@ -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 +**依据**:产品文档、后端日期字段与列表筛选约定 diff --git a/docs/产品文档.md b/docs/产品文档.md index 595d30d..9d6b82b 100644 --- a/docs/产品文档.md +++ b/docs/产品文档.md @@ -14,7 +14,7 @@ ## 3. 项目信息(5 个分类) -**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订/开工/计划竣工/实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。 +**合同信息**:序号、合同编号、供电局项目合同编号、项目名称、子项个数、子项编码、项目总投资、中标合同金额、质保金比例、结算金额、总成本测算、工程电压等级、工程类别、业主单位、业主联系人及电话、中标形式、签订日期、开工日期、计划竣工日期、实际竣工日期、质保金、质保期截止日、实际退质保金日期、所属项目部、项目负责人及电话、工程款拨付方式。 **成本控制**:总体成本、是否调整(是/否)、农民工工资(按进度计划/实付)、乙供材料费(控制)、应付材料费(按收款比例)、实际发生/支付材料费、其他费用(控制)、应付其他费、实际其他费用、税金、利润/实际利润、成本结算金额。