所有需求实现完成
This commit is contained in:
+181
-10
@@ -53,12 +53,177 @@
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目列表
|
||||
## 2.2 用户管理(仅管理员)
|
||||
|
||||
以下接口仅**管理员**角色可调用,用于查看、创建、修改、删除其他用户。非管理员返回 HTTP 403。
|
||||
|
||||
### 2.2.0 用户列表
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **接口** | `POST /api/users/list` |
|
||||
| **说明** | 分页获取用户列表(不含密码) |
|
||||
| **权限** | 已登录,且角色为管理员 |
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| page | number | 否 | 页码,从 1 开始,默认 1 |
|
||||
| pageSize | number | 否 | 每页条数,默认 100 |
|
||||
|
||||
#### 响应参数(成功,HTTP 200)
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| list | array | 用户列表 |
|
||||
| list[].id | string | 用户 ID |
|
||||
| list[].username | string | 账号 |
|
||||
| list[].role | string | 角色 |
|
||||
| list[].displayName | string | 显示名称 |
|
||||
| list[].createdAt | string | 创建时间,ISO8601 |
|
||||
| list[].updatedAt | string | 更新时间,ISO8601 |
|
||||
| total | number | 总条数 |
|
||||
| page | number | 当前页码 |
|
||||
| pageSize | number | 每页条数 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2.1 创建用户
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **接口** | `POST /api/users/create` |
|
||||
| **说明** | 创建新用户 |
|
||||
| **权限** | 已登录,且角色为管理员 |
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| username | string | 是 | 账号(唯一) |
|
||||
| password | string | 是 | 密码 |
|
||||
| role | string | 是 | 角色:管理员、市场部、工程部、技经部、财务部、物贸部 |
|
||||
| displayName | string | 否 | 显示名称,默认同 username |
|
||||
|
||||
#### 响应参数(成功,HTTP 201)
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | string | 新用户 ID |
|
||||
| message | string | 固定为「创建成功」 |
|
||||
|
||||
#### 响应参数(失败)
|
||||
|
||||
| HTTP 状态 | 说明 |
|
||||
|-----------|------|
|
||||
| 400 | 账号/密码为空、角色无效、账号已存在 |
|
||||
| 403 | 非管理员 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2.2 更新用户
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **接口** | `POST /api/users/update` |
|
||||
| **说明** | 修改指定用户(仅更新传入的字段) |
|
||||
| **权限** | 已登录,且角色为管理员 |
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| id | string | 是 | 用户 ID |
|
||||
| password | string | 否 | 新密码,不传则不修改 |
|
||||
| role | string | 否 | 新角色 |
|
||||
| displayName | string | 否 | 新显示名称 |
|
||||
|
||||
#### 响应参数(成功,HTTP 200)
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | string | 用户 ID |
|
||||
| message | string | 固定为「保存成功」 |
|
||||
|
||||
#### 响应参数(失败)
|
||||
|
||||
| HTTP 状态 | 说明 |
|
||||
|-----------|------|
|
||||
| 400 | 角色无效等 |
|
||||
| 403 | 非管理员 |
|
||||
| 404 | 用户不存在 |
|
||||
|
||||
---
|
||||
|
||||
### 2.2.3 删除用户
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **接口** | `POST /api/users/delete` |
|
||||
| **说明** | 删除指定用户。不能删除当前登录用户。 |
|
||||
| **权限** | 已登录,且角色为管理员 |
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| id | string | 是 | 用户 ID |
|
||||
|
||||
#### 响应参数(成功,HTTP 200)
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| message | string | 固定为「删除成功」 |
|
||||
|
||||
#### 响应参数(失败)
|
||||
|
||||
| HTTP 状态 | 说明 |
|
||||
|-----------|------|
|
||||
| 400 | 不能删除当前登录用户 |
|
||||
| 403 | 非管理员 |
|
||||
| 404 | 用户不存在 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 项目统计
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **接口** | `POST /api/projects/statistics` |
|
||||
| **说明** | 获取项目统计信息。传入 statisticsType=全部 返回各类型数量(byType);传入具体类型返回该类型项目列表 |
|
||||
| **权限** | 已登录,所有角色可访问 |
|
||||
|
||||
#### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| statisticsType | string | 否 | 全部(默认)返回各类型数量;基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他 返回该类型项目列表 |
|
||||
|
||||
#### 响应参数(statisticsType=全部)
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| total | number | 项目总数 |
|
||||
| statisticsType | string | 固定为「全部」 |
|
||||
| byType | object | 各统计类型数量,如 { "基建工程": 10, "业扩项目": 5, "其他": 3 } |
|
||||
|
||||
#### 响应参数(statisticsType 为具体类型)
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| total | number | 该类型项目数 |
|
||||
| statisticsType | string | 请求的统计类型 |
|
||||
| list | array | 该类型项目列表,结构同项目列表 list[].* |
|
||||
|
||||
---
|
||||
|
||||
## 4. 项目列表
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **接口** | `POST /api/projects/list` |
|
||||
| **说明** | 分页获取项目列表,支持按名称/合同编号搜索、按进度/费用/合同金额筛选、时间筛选、日期异常筛选 |
|
||||
| **说明** | 分页获取项目列表,支持按名称/合同编号搜索、按进度/费用/合同金额/统计类型筛选、时间筛选、日期异常筛选 |
|
||||
| **权限** | 已登录,所有角色可访问 |
|
||||
|
||||
#### 请求参数
|
||||
@@ -77,6 +242,7 @@
|
||||
| dateAbnormal | boolean | 否 | 为 true 时只返回「日期异常」项目(四个日期中任意一个非「日期正常」) |
|
||||
| amountMin | number | 否 | 合同金额(万元)下限,按中标合同金额筛选 |
|
||||
| amountMax | number | 否 | 合同金额(万元)上限,按中标合同金额筛选 |
|
||||
| statisticsType | string | 否 | 统计类型筛选:全部、基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他 |
|
||||
|
||||
#### 响应参数(成功,HTTP 200)
|
||||
|
||||
@@ -84,6 +250,7 @@
|
||||
|--------|------|------|
|
||||
| list | array | 项目列表,无数据时为空数组 |
|
||||
| list[].id | string | 项目 ID |
|
||||
| list[].statisticsType | string | 统计类型 |
|
||||
| list[].projectName | string | 项目名称 |
|
||||
| list[].contractCode | string | 合同编号 |
|
||||
| list[].progress | string | 项目进度 |
|
||||
@@ -101,7 +268,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 4. 项目详情
|
||||
## 5. 项目详情
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
@@ -137,7 +304,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 5. 新建项目
|
||||
## 6. 新建项目
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
@@ -183,7 +350,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 6. 更新项目
|
||||
## 7. 更新项目
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
@@ -218,7 +385,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 7. 操作日志
|
||||
## 8. 操作日志
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
@@ -258,7 +425,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 8. 项目信息字段(5 个分类)
|
||||
## 9. 项目信息字段(5 个分类)
|
||||
|
||||
以下为各分类在 **请求/响应** 中使用的字段定义;单位、是否必填、校验规则由后端统一规定,前端与后端对齐。
|
||||
|
||||
@@ -349,7 +516,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 9. 项目列表/筛选枚举
|
||||
## 10. 项目列表/筛选枚举
|
||||
|
||||
以下由后端定义并提供给前端(如通过配置接口或文档约定):
|
||||
|
||||
@@ -360,7 +527,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 10. 统一错误响应
|
||||
## 11. 统一错误响应
|
||||
|
||||
| HTTP 状态 | 说明 |
|
||||
|-----------|------|
|
||||
@@ -394,11 +561,15 @@
|
||||
|
||||
---
|
||||
|
||||
## 11. 接口一览
|
||||
## 12. 接口一览
|
||||
|
||||
| 接口 | 方法 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| /api/auth/login | POST | 登录 | 公开 |
|
||||
| /api/users/list | POST | 用户列表(分页) | 已登录且管理员 |
|
||||
| /api/users/create | POST | 创建用户 | 已登录且管理员 |
|
||||
| /api/users/update | POST | 更新用户 | 已登录且管理员 |
|
||||
| /api/users/delete | POST | 删除用户 | 已登录且管理员 |
|
||||
| /api/projects/list | POST | 项目列表(搜索、筛选、分页) | 已登录 |
|
||||
| /api/projects/detail | POST | 项目详情 | 已登录 |
|
||||
| /api/projects/create | POST | 新建项目 | 已登录且市场部 |
|
||||
|
||||
@@ -36,6 +36,7 @@ CREATE TABLE `projects` (
|
||||
`id` varchar(64) NOT NULL COMMENT '项目ID',
|
||||
`progress` varchar(32) DEFAULT NULL COMMENT '项目进度(列表筛选)',
|
||||
`cost` varchar(32) DEFAULT NULL COMMENT '项目费用(列表筛选)',
|
||||
`statistics_type` varchar(32) DEFAULT NULL COMMENT '统计类型:基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他',
|
||||
-- 合同信息
|
||||
`serial_no` varchar(32) DEFAULT NULL COMMENT '序号',
|
||||
`contract_code` varchar(128) NOT NULL COMMENT '合同编号',
|
||||
|
||||
@@ -47,6 +47,34 @@ cd backend && source .venv/bin/activate && PYTHONPATH=src python scripts/clean_d
|
||||
|
||||
---
|
||||
|
||||
## migrate_add_statistics_type.py:为 projects 表增加统计类型列
|
||||
|
||||
为已有 `projects` 表添加 `statistics_type` 列(若不存在)。
|
||||
|
||||
```bash
|
||||
cd backend && PYTHONPATH=src python scripts/migrate_add_statistics_type.py
|
||||
```
|
||||
|
||||
**migrate_statistics_type_repair.py**:将已有数据中的「检修、技改、抢修」统一改为「检修-技改-抢修」(一次性执行)。
|
||||
|
||||
```bash
|
||||
cd backend && PYTHONPATH=src python scripts/migrate_statistics_type_repair.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## fill_statistics_type_from_xls.py:按「统计」表填充统计类型
|
||||
|
||||
根据 `docs/example.xls` 的「统计」表,为已有项目填充 `statistics_type`(基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他)。导入数据后建议执行一次。
|
||||
|
||||
```bash
|
||||
cd backend && PYTHONPATH=src python scripts/fill_statistics_type_from_xls.py
|
||||
# 或指定 xls 路径
|
||||
PYTHONPATH=src python scripts/fill_statistics_type_from_xls.py /path/to/example.xls
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## import_xls.py:导入 example.xls 到 projects 表
|
||||
|
||||
将 `docs/example.xls` 中的项目数据导入 ocean 数据库的 `projects` 表。
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
根据 docs/example.xls 的「统计」表,为 projects 表填充 statistics_type。
|
||||
统计表结构:一、基建工程(3-29) 二、业扩项目(33-50) 三、户表(54-71) 四、客户工程 五、营销项目 六、检修、技改、抢修 -> 统计类型存为「检修-技改-抢修」
|
||||
通过 项目名称 或 所属项目部 包含统计表中的名称/关键字进行匹配。
|
||||
用法:cd backend && PYTHONPATH=src python3 scripts/fill_statistics_type_from_xls.py [path_to_example.xls]
|
||||
"""
|
||||
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 xlrd
|
||||
from src.database import get_connection
|
||||
|
||||
|
||||
def _normalize(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).strip().replace("\n", "").replace("\r", "")
|
||||
|
||||
|
||||
def collect_names_from_stat_sheet(wb: xlrd.Book) -> dict:
|
||||
"""从「统计」表收集各类型的名称/关键字列表。返回 { 统计类型: [名称1, 名称2, ...] }"""
|
||||
if "统计" not in wb.sheet_names():
|
||||
return {}
|
||||
sh = wb.sheet_by_name("统计")
|
||||
out = {
|
||||
"基建工程": [],
|
||||
"业扩项目": [],
|
||||
"户表": [],
|
||||
"客户工程": [],
|
||||
"营销项目": [],
|
||||
"检修-技改-抢修": [],
|
||||
}
|
||||
# 一、基建工程: row 0 title, row 1 header, rows 3-29 data, col 1 = 项目名称
|
||||
for r in range(3, 30):
|
||||
v = _normalize(sh.cell_value(r, 1))
|
||||
if v and v != "合计" and not v.replace(".", "").isdigit():
|
||||
out["基建工程"].append(v)
|
||||
# 二、业扩项目: row 30 title, row 31 header?, row 32 合计, rows 33-50 col 0
|
||||
for r in range(33, 51):
|
||||
v = _normalize(sh.cell_value(r, 0))
|
||||
if v and v != "合计" and "业扩" in v:
|
||||
out["业扩项目"].append(v)
|
||||
# 三、户表: row 51 title, row 52 header, row 53 合计, rows 54-71 col 0
|
||||
for r in range(54, 72):
|
||||
v = _normalize(sh.cell_value(r, 0))
|
||||
if v and v != "合计" and "户表" in v:
|
||||
out["户表"].append(v)
|
||||
# 四、客户工程: row 72 title, row 73 header(序号,所属项目部,项目个数,总投资), 74+ 所属项目部 col 1
|
||||
for r in range(75, 96):
|
||||
v = _normalize(sh.cell_value(r, 1))
|
||||
if v and v != "合计" and not v.replace(".", "").isdigit():
|
||||
out["客户工程"].append(v)
|
||||
# 五、营销项目: row 96 title, rows 97-107
|
||||
for r in range(97, 108):
|
||||
v = _normalize(sh.cell_value(r, 0)) or _normalize(sh.cell_value(r, 1))
|
||||
if v and v != "合计":
|
||||
out["营销项目"].append(v)
|
||||
# 六、检修、技改、抢修: row 108 title, rows 109+ col 1 主项目名称 -> 统计类型「检修-技改-抢修」
|
||||
for r in range(109, min(134, sh.nrows)):
|
||||
v = _normalize(sh.cell_value(r, 1))
|
||||
if v and v not in ("合计", "小计", ""):
|
||||
out["检修-技改-抢修"].append(v)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) >= 2:
|
||||
xls_path = Path(sys.argv[1]).resolve()
|
||||
else:
|
||||
xls_path = _backend.parent / "docs" / "example.xls"
|
||||
if not xls_path.exists():
|
||||
xls_path = _backend / "docs" / "example.xls"
|
||||
if not xls_path.exists():
|
||||
print(f"未找到文件:{xls_path}")
|
||||
sys.exit(1)
|
||||
wb = xlrd.open_workbook(str(xls_path))
|
||||
by_type = collect_names_from_stat_sheet(wb)
|
||||
# 基建工程:从「凯里XX供电局」「都匀XX供电局」提取 XX,匹配 所属项目部 含 XX
|
||||
build_keywords = []
|
||||
for n in by_type.get("基建工程", []):
|
||||
n = _normalize(n)
|
||||
if "供电局" in n:
|
||||
# 凯里城区供电分局 -> 城区,凯里三穗供电局 -> 三穗,都匀瓮安供电局 -> 瓮安
|
||||
for prefix in ("凯里", "都匀"):
|
||||
if n.startswith(prefix):
|
||||
mid = n[len(prefix) :].split("供电局")[0].strip()
|
||||
if mid and len(mid) <= 6:
|
||||
build_keywords.append((mid, "基建工程"))
|
||||
break
|
||||
# 匹配顺序:检修-技改-抢修、业扩、户表、营销、基建(先关键字再名称)、客户工程
|
||||
order = ["检修-技改-抢修", "业扩项目", "户表", "营销项目", "基建工程", "客户工程"]
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SELECT id, project_name, project_department FROM projects")
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
updated = 0
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
for r in rows:
|
||||
pid = r["id"]
|
||||
pname = _normalize(r.get("project_name") or "")
|
||||
pdept = _normalize(r.get("project_department") or "")
|
||||
st = None
|
||||
# 基建:项目部关键字匹配(如 剑河项目部 -> 基建工程)
|
||||
for kw, typ in build_keywords:
|
||||
if kw in pdept or kw in pname:
|
||||
st = typ
|
||||
break
|
||||
if not st:
|
||||
for t in order:
|
||||
names = by_type.get(t, [])
|
||||
for n in names:
|
||||
if n and (n in pname or n in pdept or (len(n) >= 4 and pname.startswith(n[:4]))):
|
||||
st = t
|
||||
break
|
||||
if st:
|
||||
break
|
||||
if not st:
|
||||
st = "其他"
|
||||
cur.execute("UPDATE projects SET statistics_type = %s WHERE id = %s", (st, pid))
|
||||
updated += 1
|
||||
conn.commit()
|
||||
cur.close()
|
||||
print(f"已根据统计表填充 statistics_type,共更新 {updated} 条项目。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
为 projects 表增加 statistics_type 列(若不存在)。
|
||||
用法:cd backend && PYTHONPATH=src python3 scripts/migrate_add_statistics_type.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 "
|
||||
"ADD COLUMN statistics_type varchar(32) DEFAULT NULL "
|
||||
"COMMENT '统计类型:基建工程、业扩项目、户表、客户工程、营销项目、检修-技改-抢修、其他' "
|
||||
"AFTER cost"
|
||||
)
|
||||
conn.commit()
|
||||
print("已为 projects 表添加 statistics_type 列。")
|
||||
except Exception as e:
|
||||
if "Duplicate column name" in str(e):
|
||||
print("列已存在,无需迁移。")
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
将 projects 表中 statistics_type 为「检修、技改、抢修」的项统一改为「检修-技改-抢修」。
|
||||
用法:cd backend && PYTHONPATH=src python3 scripts/migrate_statistics_type_repair.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(
|
||||
"UPDATE projects SET statistics_type = %s WHERE statistics_type = %s",
|
||||
("检修-技改-抢修", "检修、技改、抢修"),
|
||||
)
|
||||
n = cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
print(f"已将 statistics_type「检修、技改、抢修」更新为「检修-技改-抢修」,共 {n} 条。")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -35,6 +35,11 @@ class ProjectListBody(BaseModel):
|
||||
dateAbnormal: Optional[bool] = None # True:只查日期异常项目
|
||||
amountMin: Optional[float] = None # 合同金额(万元)下限
|
||||
amountMax: Optional[float] = None # 合同金额(万元)上限
|
||||
statisticsType: Optional[str] = None # 全部|基建工程|业扩项目|户表|客户工程|营销项目|检修-技改-抢修|其他
|
||||
|
||||
|
||||
class ProjectStatisticsBody(BaseModel):
|
||||
statisticsType: Optional[str] = None # 全部 返回各类型数量;具体类型返回该类型项目列表
|
||||
|
||||
|
||||
class ProjectDetailBody(BaseModel):
|
||||
@@ -52,6 +57,7 @@ class ProjectCreateBody(BaseModel):
|
||||
receivable: Optional[dict] = None
|
||||
payable: Optional[dict] = None
|
||||
other: Optional[dict] = None
|
||||
statisticsType: Optional[str] = None # 基建工程|业扩项目|户表|客户工程|营销项目|检修-技改-抢修|其他
|
||||
|
||||
|
||||
class ProjectUpdateBody(BaseModel):
|
||||
@@ -61,6 +67,7 @@ class ProjectUpdateBody(BaseModel):
|
||||
receivable: Optional[dict] = None
|
||||
payable: Optional[dict] = None
|
||||
other: Optional[dict] = None
|
||||
statisticsType: Optional[str] = None
|
||||
|
||||
|
||||
class ProjectLogsBody(BaseModel):
|
||||
@@ -69,6 +76,29 @@ class ProjectLogsBody(BaseModel):
|
||||
pageSize: Optional[int] = 20
|
||||
|
||||
|
||||
class UserCreateBody(BaseModel):
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
displayName: Optional[str] = None
|
||||
|
||||
|
||||
class UserUpdateBody(BaseModel):
|
||||
id: str
|
||||
password: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
displayName: Optional[str] = None
|
||||
|
||||
|
||||
class UserDeleteBody(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class UserListBody(BaseModel):
|
||||
page: Optional[int] = 1
|
||||
pageSize: Optional[int] = 100
|
||||
|
||||
|
||||
def _get_current_user(authorization: Optional[str] = Header(None)) -> dict:
|
||||
"""从 Authorization Bearer token 获取当前用户,否则 401。"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
@@ -80,6 +110,13 @@ def _get_current_user(authorization: Optional[str] = Header(None)) -> dict:
|
||||
return user
|
||||
|
||||
|
||||
def _require_admin(user: dict):
|
||||
"""要求当前用户为管理员,否则返回 403 JSONResponse(路由需 return 其返回值)。"""
|
||||
if user.get("role") != "管理员":
|
||||
return JSONResponse(status_code=403, content={"code": 403, "message": "仅管理员可操作"})
|
||||
return None
|
||||
|
||||
|
||||
# ---------- 认证 ----------
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: LoginBody):
|
||||
@@ -126,10 +163,21 @@ def project_list(body: ProjectListBody, authorization: Optional[str] = Header(No
|
||||
date_abnormal=body.dateAbnormal,
|
||||
amount_min=body.amountMin,
|
||||
amount_max=body.amountMax,
|
||||
statistics_type=body.statisticsType,
|
||||
)
|
||||
return {"list": list_, "total": total, "page": page, "pageSize": page_size}
|
||||
|
||||
|
||||
# ---------- 项目统计 ----------
|
||||
@app.post("/api/projects/statistics")
|
||||
def project_statistics(body: ProjectStatisticsBody, authorization: Optional[str] = Header(None)):
|
||||
"""获取项目统计信息。statisticsType=全部 返回各类型数量;具体类型返回该类型项目列表。"""
|
||||
_get_current_user(authorization)
|
||||
st = (body.statisticsType or "全部").strip()
|
||||
data = projects_repo.get_project_statistics(st)
|
||||
return data
|
||||
|
||||
|
||||
# ---------- 项目详情 ----------
|
||||
@app.post("/api/projects/detail")
|
||||
def project_detail(body: ProjectDetailBody, authorization: Optional[str] = Header(None)):
|
||||
@@ -162,6 +210,7 @@ def project_create(body: ProjectCreateBody, authorization: Optional[str] = Heade
|
||||
receivable=body.receivable,
|
||||
payable=body.payable,
|
||||
other=body.other,
|
||||
statistics_type=body.statisticsType,
|
||||
)
|
||||
return {"id": pid, "message": "创建成功"}
|
||||
|
||||
@@ -179,6 +228,7 @@ def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Heade
|
||||
receivable=body.receivable,
|
||||
payable=body.payable,
|
||||
other=body.other,
|
||||
statistics_type=body.statisticsType,
|
||||
)
|
||||
summary_parts = []
|
||||
if body.contract is not None:
|
||||
@@ -201,6 +251,78 @@ def project_update(body: ProjectUpdateBody, authorization: Optional[str] = Heade
|
||||
return {"id": body.id, "message": "保存成功"}
|
||||
|
||||
|
||||
# ---------- 用户管理(仅管理员) ----------
|
||||
@app.post("/api/users/list")
|
||||
def user_list(body: UserListBody, authorization: Optional[str] = Header(None)):
|
||||
user = _get_current_user(authorization)
|
||||
r = _require_admin(user)
|
||||
if r is not None:
|
||||
return r
|
||||
page = body.page or 1
|
||||
page_size = body.pageSize or 100
|
||||
list_, total = auth.list_users(page=page, page_size=page_size)
|
||||
return {"list": list_, "total": total, "page": page, "pageSize": page_size}
|
||||
|
||||
|
||||
@app.post("/api/users/create", status_code=201)
|
||||
def user_create(body: UserCreateBody, authorization: Optional[str] = Header(None)):
|
||||
user = _get_current_user(authorization)
|
||||
r = _require_admin(user)
|
||||
if r is not None:
|
||||
return r
|
||||
if not body.username or (isinstance(body.username, str) and not body.username.strip()):
|
||||
return JSONResponse(status_code=400, content={"code": 400, "message": "账号不能为空"})
|
||||
if not body.password or (isinstance(body.password, str) and not body.password.strip()):
|
||||
return JSONResponse(status_code=400, content={"code": 400, "message": "密码不能为空"})
|
||||
if not body.role or body.role.strip() not in auth.ROLES:
|
||||
return JSONResponse(status_code=400, content={"code": 400, "message": "角色无效"})
|
||||
try:
|
||||
uid = auth.create_user(
|
||||
username=body.username.strip(),
|
||||
password=body.password,
|
||||
role=body.role.strip(),
|
||||
display_name=body.displayName.strip() if body.displayName else None,
|
||||
)
|
||||
return {"id": uid, "message": "创建成功"}
|
||||
except ValueError as e:
|
||||
return JSONResponse(status_code=400, content={"code": 400, "message": str(e)})
|
||||
|
||||
|
||||
@app.post("/api/users/update")
|
||||
def user_update(body: UserUpdateBody, authorization: Optional[str] = Header(None)):
|
||||
user = _get_current_user(authorization)
|
||||
r = _require_admin(user)
|
||||
if r is not None:
|
||||
return r
|
||||
if not auth.get_user_by_id(body.id):
|
||||
return JSONResponse(status_code=404, content={"code": 404, "message": "用户不存在"})
|
||||
if body.role is not None and body.role.strip() not in auth.ROLES:
|
||||
return JSONResponse(status_code=400, content={"code": 400, "message": "角色无效"})
|
||||
try:
|
||||
auth.update_user(
|
||||
body.id,
|
||||
password=body.password,
|
||||
role=body.role.strip() if body.role else None,
|
||||
display_name=body.displayName.strip() if body.displayName else None,
|
||||
)
|
||||
return {"id": body.id, "message": "保存成功"}
|
||||
except ValueError as e:
|
||||
return JSONResponse(status_code=400, content={"code": 400, "message": str(e)})
|
||||
|
||||
|
||||
@app.post("/api/users/delete")
|
||||
def user_delete(body: UserDeleteBody, authorization: Optional[str] = Header(None)):
|
||||
user = _get_current_user(authorization)
|
||||
r = _require_admin(user)
|
||||
if r is not None:
|
||||
return r
|
||||
if body.id == user["id"]:
|
||||
return JSONResponse(status_code=400, content={"code": 400, "message": "不能删除当前登录用户"})
|
||||
if not auth.delete_user(body.id):
|
||||
return JSONResponse(status_code=404, content={"code": 404, "message": "用户不存在"})
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
# ---------- 操作日志 ----------
|
||||
@app.post("/api/projects/logs")
|
||||
def project_logs(body: ProjectLogsBody, authorization: Optional[str] = Header(None)):
|
||||
|
||||
@@ -68,3 +68,133 @@ def get_user_by_token(token: str) -> Optional[dict[str, Any]]:
|
||||
def hash_password(plain: str) -> str:
|
||||
"""密码哈希(用于初始化用户等)"""
|
||||
return pwd_ctx.hash(plain)
|
||||
|
||||
|
||||
def _hash_bcrypt(plain: str) -> str:
|
||||
"""使用 bcrypt 生成哈希,与 _verify_password 兼容"""
|
||||
return bcrypt.hashpw(plain.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
||||
|
||||
|
||||
ROLES = ("管理员", "市场部", "工程部", "技经部", "财务部", "物贸部")
|
||||
|
||||
|
||||
def create_user(username: str, password: str, role: str, display_name: Optional[str] = None) -> str:
|
||||
"""创建用户,返回用户 id。username 重复则抛 ValueError。"""
|
||||
username = username.strip()
|
||||
if not username:
|
||||
raise ValueError("账号不能为空")
|
||||
if not password or len(password.strip()) < 1:
|
||||
raise ValueError("密码不能为空")
|
||||
if role not in ROLES:
|
||||
raise ValueError("角色无效")
|
||||
uid = new_id()
|
||||
pwd_hash = _hash_bcrypt(password)
|
||||
display = (display_name or username).strip() or username
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
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),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
if "Duplicate entry" in str(e) or "1062" in str(e):
|
||||
raise ValueError("账号已存在")
|
||||
raise
|
||||
finally:
|
||||
cur.close()
|
||||
return uid
|
||||
|
||||
|
||||
def list_users(page: int = 1, page_size: int = 100) -> tuple[list[dict], int]:
|
||||
"""分页获取用户列表(不含密码),返回 (list, total)。"""
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute("SELECT COUNT(*) AS cnt FROM users")
|
||||
total = cur.fetchone()["cnt"]
|
||||
offset = (page - 1) * page_size
|
||||
cur.execute(
|
||||
"SELECT id, username, role, display_name, created_at, updated_at FROM users ORDER BY created_at DESC LIMIT %s OFFSET %s",
|
||||
(page_size, offset),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
list_ = [
|
||||
{
|
||||
"id": r["id"],
|
||||
"username": r["username"],
|
||||
"role": r["role"],
|
||||
"displayName": r["display_name"] or r["username"],
|
||||
"createdAt": r["created_at"].isoformat() if r.get("created_at") else "",
|
||||
"updatedAt": r["updated_at"].isoformat() if r.get("updated_at") else "",
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return list_, total
|
||||
|
||||
|
||||
def get_user_by_id(user_id: str) -> Optional[dict[str, Any]]:
|
||||
"""按 id 查询用户(不含密码),不存在返回 None。"""
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute(
|
||||
"SELECT id, username, role, display_name, created_at, updated_at FROM users WHERE id = %s",
|
||||
(user_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"username": row["username"],
|
||||
"role": row["role"],
|
||||
"displayName": row["display_name"] or row["username"],
|
||||
"createdAt": row["created_at"].isoformat() if row.get("created_at") else None,
|
||||
"updatedAt": row["updated_at"].isoformat() if row.get("updated_at") else None,
|
||||
}
|
||||
|
||||
|
||||
def update_user(
|
||||
user_id: str,
|
||||
password: Optional[str] = None,
|
||||
role: Optional[str] = None,
|
||||
display_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""更新用户(仅更新传入的字段)。不存在返回 False。"""
|
||||
if not get_user_by_id(user_id):
|
||||
return False
|
||||
if role is not None and role not in ROLES:
|
||||
raise ValueError("角色无效")
|
||||
updates = []
|
||||
params = []
|
||||
if password is not None and len(password.strip()) > 0:
|
||||
updates.append("password_hash = %s")
|
||||
params.append(_hash_bcrypt(password))
|
||||
if role is not None:
|
||||
updates.append("role = %s")
|
||||
params.append(role)
|
||||
if display_name is not None:
|
||||
updates.append("display_name = %s")
|
||||
params.append(display_name.strip() or None)
|
||||
if not updates:
|
||||
return True
|
||||
params.append(user_id)
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE users SET " + ", ".join(updates) + " WHERE id = %s", params)
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return True
|
||||
|
||||
|
||||
def delete_user(user_id: str) -> bool:
|
||||
"""删除用户。不存在返回 False。"""
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM users WHERE id = %s", (user_id,))
|
||||
n = cur.rowcount
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return n > 0
|
||||
|
||||
@@ -171,12 +171,16 @@ def list_projects(
|
||||
date_abnormal: Optional[bool] = None,
|
||||
amount_min: Optional[float] = None,
|
||||
amount_max: Optional[float] = None,
|
||||
statistics_type: Optional[str] = None,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""分页列表,支持按项目名称/合同编号搜索、按进度/费用/合同金额筛选、时间筛选、日期异常筛选。返回 (list, total)。"""
|
||||
"""分页列表,支持按项目名称/合同编号搜索、按进度/费用/合同金额/统计类型筛选、时间筛选、日期异常筛选。返回 (list, total)。"""
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
where_parts = []
|
||||
params: list[Any] = []
|
||||
if statistics_type and statistics_type.strip() and statistics_type.strip() != "全部":
|
||||
where_parts.append("statistics_type = %s")
|
||||
params.append(statistics_type.strip())
|
||||
if keyword and keyword.strip():
|
||||
if search_type == "name":
|
||||
where_parts.append("project_name LIKE %s")
|
||||
@@ -226,7 +230,7 @@ def list_projects(
|
||||
cur.execute(
|
||||
f"""SELECT id, project_name, contract_code, progress, cost, updated_at,
|
||||
bid_contract_amount, owner_unit, sign_date, sign_date_str,
|
||||
project_department, total_cost, project_leader_contact
|
||||
project_department, total_cost, project_leader_contact, statistics_type
|
||||
FROM projects WHERE {where_sql} ORDER BY updated_at DESC LIMIT %s OFFSET %s""",
|
||||
tuple(params) + (page_size, offset),
|
||||
)
|
||||
@@ -235,7 +239,12 @@ def list_projects(
|
||||
list_ = []
|
||||
for r in rows:
|
||||
_sd = r.get("sign_date")
|
||||
sign_val = r.get("sign_date_str") or (_sd.strftime("%Y-%m-%d") if _sd else None)
|
||||
sign_date_str_val = r.get("sign_date_str")
|
||||
# 只有不是「日期正常」时才显示原文;否则显示实际日期
|
||||
if sign_date_str_val and str(sign_date_str_val).strip() != DATE_NORMAL:
|
||||
sign_val = sign_date_str_val
|
||||
else:
|
||||
sign_val = (_sd.strftime("%Y-%m-%d") if _sd else None) or (sign_date_str_val or "")
|
||||
list_.append({
|
||||
"id": r["id"],
|
||||
"projectName": r["project_name"] or "",
|
||||
@@ -249,10 +258,76 @@ def list_projects(
|
||||
"projectDepartment": r.get("project_department") or "",
|
||||
"totalCost": str(r["total_cost"]) if r.get("total_cost") is not None else "",
|
||||
"projectLeaderContact": r.get("project_leader_contact") or "",
|
||||
"statisticsType": r.get("statistics_type") or "",
|
||||
})
|
||||
return list_, total
|
||||
|
||||
|
||||
# 统计类型枚举(与前端/API 一致)
|
||||
STATISTICS_TYPES = (
|
||||
"全部",
|
||||
"基建工程",
|
||||
"业扩项目",
|
||||
"户表",
|
||||
"客户工程",
|
||||
"营销项目",
|
||||
"检修-技改-抢修",
|
||||
"其他",
|
||||
)
|
||||
|
||||
|
||||
def get_project_statistics(statistics_type: Optional[str] = None) -> dict:
|
||||
"""获取项目统计信息。statistics_type 为「全部」或空时返回各类型数量;否则返回该类型下的 list 与 total。"""
|
||||
with get_connection() as conn:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
if not statistics_type or statistics_type.strip() == "全部":
|
||||
cur.execute(
|
||||
"""SELECT statistics_type AS type, COUNT(*) AS cnt FROM projects
|
||||
GROUP BY statistics_type"""
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
by_type = {r["type"] or "其他": r["cnt"] for r in rows}
|
||||
total = sum(by_type.values())
|
||||
return {"total": total, "statisticsType": "全部", "byType": by_type}
|
||||
st = statistics_type.strip()
|
||||
cur.execute("SELECT COUNT(*) AS cnt FROM projects WHERE statistics_type = %s", (st,))
|
||||
total = cur.fetchone()["cnt"]
|
||||
cur.execute(
|
||||
"""SELECT id, project_name, contract_code, progress, cost, updated_at,
|
||||
bid_contract_amount, owner_unit, sign_date, sign_date_str,
|
||||
project_department, total_cost, project_leader_contact, statistics_type
|
||||
FROM projects WHERE statistics_type = %s ORDER BY updated_at DESC""",
|
||||
(st,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
list_ = []
|
||||
for r in rows:
|
||||
_sd = r.get("sign_date")
|
||||
sign_date_str_val = r.get("sign_date_str")
|
||||
if sign_date_str_val and str(sign_date_str_val).strip() != DATE_NORMAL:
|
||||
sign_val = sign_date_str_val
|
||||
else:
|
||||
sign_val = (_sd.strftime("%Y-%m-%d") if _sd else None) or (sign_date_str_val or "")
|
||||
list_.append({
|
||||
"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 "",
|
||||
"bidContractAmount": r.get("bid_contract_amount") or "",
|
||||
"ownerUnit": r.get("owner_unit") or "",
|
||||
"signDate": sign_val or "",
|
||||
"projectDepartment": r.get("project_department") or "",
|
||||
"totalCost": str(r["total_cost"]) if r.get("total_cost") is not None else "",
|
||||
"projectLeaderContact": r.get("project_leader_contact") or "",
|
||||
"statisticsType": r.get("statistics_type") or "",
|
||||
})
|
||||
return {"total": total, "statisticsType": st, "list": list_}
|
||||
|
||||
|
||||
def get_project(project_id: str) -> Optional[dict[str, Any]]:
|
||||
"""按 id 查项目,返回 API 形态详情,不存在返回 None。"""
|
||||
with get_connection() as conn:
|
||||
@@ -271,6 +346,7 @@ def create_project(
|
||||
receivable: Optional[dict] = None,
|
||||
payable: Optional[dict] = None,
|
||||
other: Optional[dict] = None,
|
||||
statistics_type: Optional[str] = None,
|
||||
) -> str:
|
||||
"""新建项目,合同信息必含 contract_code、project_name。返回新项目 id。"""
|
||||
pid = new_id()
|
||||
@@ -285,6 +361,8 @@ def create_project(
|
||||
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"] = ""
|
||||
if statistics_type is not None and str(statistics_type).strip():
|
||||
row["statistics_type"] = str(statistics_type).strip()
|
||||
row["created_at"] = now
|
||||
row["updated_at"] = now
|
||||
cols = [k for k in row.keys()]
|
||||
@@ -307,6 +385,7 @@ def update_project(
|
||||
receivable: Optional[dict] = None,
|
||||
payable: Optional[dict] = None,
|
||||
other: Optional[dict] = None,
|
||||
statistics_type: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""更新项目(合并传入的字段),不存在返回 False。"""
|
||||
c = _api_to_db(contract, CONTRACT_API_TO_DB)
|
||||
@@ -316,6 +395,8 @@ def update_project(
|
||||
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 statistics_type is not None:
|
||||
merged["statistics_type"] = str(statistics_type).strip() if statistics_type else None
|
||||
if not merged:
|
||||
return project_exists(project_id)
|
||||
merged["updated_at"] = datetime.utcnow()
|
||||
|
||||
@@ -37,6 +37,10 @@ TEST_BASE_URL=http://localhost:8000 pytest tests/ -v
|
||||
| 接口 | 用例 |
|
||||
|------|------|
|
||||
| POST /api/auth/login | 成功返回 token/user、错误凭证 401、缺 username/password 返回 400 |
|
||||
| POST /api/users/create | 无 token 401、非管理员 403、管理员 201+id+message、缺 username/password/role 400 |
|
||||
| POST /api/users/update | 无 token 401、非管理员 403、管理员 200+id+message、不存在 404 |
|
||||
| POST /api/users/delete | 无 token 401、非管理员 403、管理员 200+message、不存在 404、不能删自己 400 |
|
||||
| POST /api/projects/statistics | 无 token 401;有 token 默认/全部 返回 total、statisticsType、byType;具体类型返回 total、statisticsType、list |
|
||||
| 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 数组 |
|
||||
|
||||
@@ -10,6 +10,64 @@ def _auth_header(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------- POST /api/projects/statistics(API 文档第 3 节)----------
|
||||
|
||||
|
||||
def test_statistics_without_token_returns_401(client):
|
||||
"""项目统计无 token 时返回 401"""
|
||||
r = client.post("/api/projects/statistics", json={})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_statistics_with_token_default_returns_200_with_total_and_by_type(client, auth_token):
|
||||
"""有 token 且未传 statisticsType(或传全部)时返回 200,含 total、statisticsType、byType"""
|
||||
r = client.post(
|
||||
"/api/projects/statistics",
|
||||
headers=_auth_header(auth_token),
|
||||
json={},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "total" in body
|
||||
assert isinstance(body["total"], int)
|
||||
assert "statisticsType" in body
|
||||
assert body["statisticsType"] == "全部"
|
||||
assert "byType" in body
|
||||
assert isinstance(body["byType"], dict)
|
||||
|
||||
|
||||
def test_statistics_with_statistics_type_all_returns_by_type(client, auth_token):
|
||||
"""statisticsType=全部 时返回 byType 各类型数量"""
|
||||
r = client.post(
|
||||
"/api/projects/statistics",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"statisticsType": "全部"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body.get("statisticsType") == "全部"
|
||||
assert "byType" in body
|
||||
for _, count in body["byType"].items():
|
||||
assert isinstance(count, (int, float))
|
||||
|
||||
|
||||
def test_statistics_with_specific_type_returns_200_with_total_and_list(client, auth_token):
|
||||
"""statisticsType 为具体类型时返回 200,含 total、statisticsType、list"""
|
||||
r = client.post(
|
||||
"/api/projects/statistics",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"statisticsType": "基建工程"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert "total" in body
|
||||
assert isinstance(body["total"], int)
|
||||
assert "statisticsType" in body
|
||||
assert body["statisticsType"] == "基建工程"
|
||||
assert "list" in body
|
||||
assert isinstance(body["list"], list)
|
||||
|
||||
|
||||
# ---------- POST /api/projects/list ----------
|
||||
|
||||
|
||||
@@ -495,7 +553,7 @@ def test_e2e_login_then_get_project_list(client):
|
||||
|
||||
|
||||
def test_e2e_login_then_create_project_then_list_includes_it(client):
|
||||
"""E2E:登录(市场部)→ 创建项目 → 获取列表,列表中包含新建项目"""
|
||||
"""E2E:登录(市场部)→ 创建项目 → 按名称搜索列表,列表中包含新建项目"""
|
||||
token = _login_and_get_token(client, "market", "123456")
|
||||
project_name = "E2E流程新建项目"
|
||||
contract_code = "HT-E2E-001"
|
||||
@@ -509,14 +567,14 @@ def test_e2e_login_then_create_project_then_list_includes_it(client):
|
||||
list_r = client.post(
|
||||
"/api/projects/list",
|
||||
headers=_auth_header(token),
|
||||
json={"page": 1, "pageSize": 100},
|
||||
json={"page": 1, "pageSize": 20, "searchType": "name", "keyword": project_name},
|
||||
)
|
||||
assert list_r.status_code == 200
|
||||
items = list_r.json().get("list", [])
|
||||
ids = [p["id"] for p in items]
|
||||
assert pid in ids
|
||||
names = [p.get("projectName") for p in items if p.get("id") == pid]
|
||||
assert project_name in names or len(names) >= 1
|
||||
assert pid in ids, f"新建项目 id {pid} 应在搜索结果中,list ids: {ids[:5]}..."
|
||||
found = next((p for p in items if p.get("id") == pid), None)
|
||||
assert found is not None and found.get("projectName") == project_name
|
||||
|
||||
|
||||
def test_e2e_login_then_create_then_detail_then_update_then_detail_reflects(client):
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
用户管理 API 测试(依据 API 文档 2.2 节)
|
||||
|
||||
仅管理员可创建、修改、删除用户。测试对已启动服务发起 HTTP 请求,使用 admin token。
|
||||
"""
|
||||
import uuid
|
||||
|
||||
|
||||
def _auth_header(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------- POST /api/users/create ----------
|
||||
|
||||
|
||||
def test_users_create_without_token_returns_401(client):
|
||||
"""创建用户无 token 时返回 401"""
|
||||
r = client.post(
|
||||
"/api/users/create",
|
||||
json={"username": "u1", "password": "123456", "role": "工程部"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_users_create_non_admin_returns_403(client, engineer_token):
|
||||
"""非管理员创建用户返回 403"""
|
||||
r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(engineer_token),
|
||||
json={"username": "u_engineer_cannot", "password": "123456", "role": "工程部"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json().get("code") == 403
|
||||
|
||||
|
||||
def test_users_create_admin_returns_201_with_id_and_message(client, auth_token):
|
||||
"""管理员创建用户返回 201 且包含 id 和 message「创建成功」"""
|
||||
username = f"testuser_create_{uuid.uuid4().hex[:8]}"
|
||||
r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={
|
||||
"username": username,
|
||||
"password": "123456",
|
||||
"role": "技经部",
|
||||
"displayName": "测试创建用户",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert "id" in body
|
||||
assert body.get("message") == "创建成功"
|
||||
|
||||
|
||||
def test_users_create_missing_username_returns_400(client, auth_token):
|
||||
"""创建用户缺少 username 时返回 400"""
|
||||
r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"password": "123456", "role": "工程部"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json().get("code") == 400
|
||||
|
||||
|
||||
def test_users_create_missing_password_returns_400(client, auth_token):
|
||||
"""创建用户缺少 password 时返回 400"""
|
||||
r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"username": "testuser_nopwd", "role": "工程部"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json().get("code") == 400
|
||||
|
||||
|
||||
def test_users_create_missing_role_returns_400(client, auth_token):
|
||||
"""创建用户缺少 role 时返回 400"""
|
||||
r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"username": "testuser_norole", "password": "123456"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert r.json().get("code") == 400
|
||||
|
||||
|
||||
# ---------- POST /api/users/update ----------
|
||||
|
||||
|
||||
def test_users_update_without_token_returns_401(client):
|
||||
"""更新用户无 token 时返回 401"""
|
||||
r = client.post("/api/users/update", json={"id": "any-id"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_users_update_non_admin_returns_403(client, engineer_token, auth_token):
|
||||
"""非管理员更新用户返回 403"""
|
||||
username = f"testuser_for_update_{uuid.uuid4().hex[:8]}"
|
||||
create_r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"username": username, "password": "123456", "role": "财务部"},
|
||||
)
|
||||
assert create_r.status_code == 201
|
||||
uid = create_r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/users/update",
|
||||
headers=_auth_header(engineer_token),
|
||||
json={"id": uid, "displayName": "不应成功"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json().get("code") == 403
|
||||
|
||||
|
||||
def test_users_update_admin_returns_200_with_id_and_message(client, auth_token):
|
||||
"""管理员更新用户返回 200 且包含 id 和 message「保存成功」"""
|
||||
username = f"testuser_update_{uuid.uuid4().hex[:8]}"
|
||||
create_r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"username": username, "password": "123456", "role": "物贸部"},
|
||||
)
|
||||
assert create_r.status_code == 201
|
||||
uid = create_r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/users/update",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": uid, "displayName": "更新后的显示名", "role": "财务部"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body.get("id") == uid
|
||||
assert body.get("message") == "保存成功"
|
||||
|
||||
|
||||
def test_users_update_nonexistent_returns_404(client, auth_token):
|
||||
"""更新不存在用户返回 404"""
|
||||
r = client.post(
|
||||
"/api/users/update",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": "non-existent-user-id", "displayName": "任意"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert r.json().get("code") == 404
|
||||
|
||||
|
||||
# ---------- POST /api/users/delete ----------
|
||||
|
||||
|
||||
def test_users_delete_without_token_returns_401(client):
|
||||
"""删除用户无 token 时返回 401"""
|
||||
r = client.post("/api/users/delete", json={"id": "any-id"})
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_users_delete_non_admin_returns_403(client, engineer_token, auth_token):
|
||||
"""非管理员删除用户返回 403"""
|
||||
username = f"testuser_for_del_{uuid.uuid4().hex[:8]}"
|
||||
create_r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"username": username, "password": "123456", "role": "工程部"},
|
||||
)
|
||||
assert create_r.status_code == 201
|
||||
uid = create_r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/users/delete",
|
||||
headers=_auth_header(engineer_token),
|
||||
json={"id": uid},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
assert r.json().get("code") == 403
|
||||
|
||||
|
||||
def test_users_delete_admin_returns_200_with_message(client, auth_token):
|
||||
"""管理员删除用户返回 200 且 message「删除成功」"""
|
||||
username = f"testuser_delete_{uuid.uuid4().hex[:8]}"
|
||||
create_r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"username": username, "password": "123456", "role": "工程部"},
|
||||
)
|
||||
assert create_r.status_code == 201
|
||||
uid = create_r.json()["id"]
|
||||
r = client.post(
|
||||
"/api/users/delete",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": uid},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json().get("message") == "删除成功"
|
||||
|
||||
|
||||
def test_users_delete_nonexistent_returns_404(client, auth_token):
|
||||
"""删除不存在用户返回 404"""
|
||||
r = client.post(
|
||||
"/api/users/delete",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": "non-existent-user-id"},
|
||||
)
|
||||
assert r.status_code == 404
|
||||
assert r.json().get("code") == 404
|
||||
|
||||
|
||||
def test_users_delete_self_returns_400(client, auth_token):
|
||||
"""管理员不能删除当前登录用户自己,返回 400"""
|
||||
# 先登录拿到当前用户 id(从 login 响应的 user.id)
|
||||
login_r = client.post("/api/auth/login", json={"username": "admin", "password": "123456"})
|
||||
assert login_r.status_code == 200
|
||||
current_user_id = login_r.json()["user"]["id"]
|
||||
r = client.post(
|
||||
"/api/users/delete",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": current_user_id},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
body = r.json()
|
||||
assert body.get("code") == 400
|
||||
assert "message" in body
|
||||
|
||||
|
||||
# ---------- E2E:管理员 创建 → 修改 → 删除 用户 ----------
|
||||
|
||||
|
||||
def test_e2e_admin_create_then_update_then_delete_user(client, auth_token):
|
||||
"""E2E:管理员创建用户 → 修改用户 → 删除用户"""
|
||||
username = f"testuser_e2e_{uuid.uuid4().hex[:8]}"
|
||||
create_r = client.post(
|
||||
"/api/users/create",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"username": username, "password": "123456", "role": "市场部", "displayName": "E2E初始"},
|
||||
)
|
||||
assert create_r.status_code == 201
|
||||
uid = create_r.json()["id"]
|
||||
assert uid
|
||||
|
||||
update_r = client.post(
|
||||
"/api/users/update",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": uid, "displayName": "E2E已修改", "role": "技经部"},
|
||||
)
|
||||
assert update_r.status_code == 200
|
||||
assert update_r.json().get("message") == "保存成功"
|
||||
|
||||
delete_r = client.post(
|
||||
"/api/users/delete",
|
||||
headers=_auth_header(auth_token),
|
||||
json={"id": uid},
|
||||
)
|
||||
assert delete_r.status_code == 200
|
||||
assert delete_r.json().get("message") == "删除成功"
|
||||
Reference in New Issue
Block a user