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

This commit is contained in:
Your Name
2026-01-31 19:09:48 +08:00
parent 11cef531bd
commit c9e75030af
26 changed files with 1686 additions and 184 deletions
+79
View File
@@ -0,0 +1,79 @@
# 脚本说明
## seed_test_users.py:插入测试用户(供测试工程师使用)
`users` 表插入 6 个测试账号(管理员、市场部、工程部、技经部、财务部、物贸部各一),**统一密码:123456**。
**用法**
```bash
cd backend
source .venv/bin/activate
PYTHONPATH=src python scripts/seed_test_users.py
```
**测试账号**
| 账号 (username) | 角色 | 密码 |
|-----------------|----------|--------|
| admin | 管理员 | 123456 |
| market | 市场部 | 123456 |
| engineer | 工程部 | 123456 |
| tech | 技经部 | 123456 |
| finance | 财务部 | 123456 |
| material | 物贸部 | 123456 |
(新建项目仅市场部可调,其他角色可查看/修改。)
---
## clean_db.py:清空项目与操作日志表
清空 `projects``operation_logs` 表(保留 `users`)。用于重新导入前清理数据。
**用法**
```bash
cd backend
source .venv/bin/activate
PYTHONPATH=src python scripts/clean_db.py
```
**清理并重新导入(一条命令)**
```bash
cd backend && source .venv/bin/activate && PYTHONPATH=src python scripts/clean_db.py && PYTHONPATH=src python scripts/import_xls.py ../docs/example.xls
```
---
## import_xls.py:导入 example.xls 到 projects 表
`docs/example.xls` 中的项目数据导入 ocean 数据库的 `projects` 表。
**前置条件**
1. 已执行 `schema.sql` 创建数据库和表。
2. 已配置 `.env`MySQL 连接信息)。
3. 已安装依赖:`pip install -r requirements.txt`(含 mysql-connector-python、xlrd)。
**用法**
```bash
cd backend
source .venv/bin/activate
PYTHONPATH=src python scripts/import_xls.py
```
默认读取 `../docs/example.xls`(相对 backend)。也可指定路径:
```bash
PYTHONPATH=src python scripts/import_xls.py /path/to/example.xls
```
**说明**
- 使用第一个 Sheet;表头为第 3 行(row 2),数据从第 6 行(row 5)开始。
- 仅导入「合同编号」非空的行;若「项目名称」为空则用合同编号或「未命名」。
- 表头与 `schema.sql``初始需求` 对齐(含「其中:」等前缀的列已映射)。
- 导入成功后输出:成功条数、跳过条数。
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""
清空 projects 与 operation_logs 表(保留 users)。
用法:cd backend && PYTHONPATH=src python3 scripts/clean_db.py
"""
import sys
from pathlib import Path
_backend = Path(__file__).resolve().parent.parent
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
from src.database import get_connection
def main():
with get_connection() as conn:
cur = conn.cursor()
cur.execute("SET FOREIGN_KEY_CHECKS = 0")
cur.execute("TRUNCATE TABLE operation_logs")
cur.execute("TRUNCATE TABLE projects")
cur.execute("SET FOREIGN_KEY_CHECKS = 1")
cur.close()
print("已清空 operation_logs、projects 表。")
if __name__ == "__main__":
main()
+7
View File
@@ -0,0 +1,7 @@
-- 创建 ocean 专用数据库用户(解决 root@localhost 的 Access denied
-- 执行:sudo mysql < backend/scripts/create_db_user.sql
-- 然后将 backend/.env 中 MYSQL_USER=ocean, MYSQL_PASSWORD=你设置的密码
CREATE USER IF NOT EXISTS 'ocean'@'localhost' IDENTIFIED BY 'ocean123';
GRANT ALL PRIVILEGES ON ocean.* TO 'ocean'@'localhost';
FLUSH PRIVILEGES;
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
将 docs/example.xls 中的项目数据导入 ocean 数据库 projects 表。
用法(在项目根目录或 backend 目录执行):
cd backend && PYTHONPATH=src python scripts/import_xls.py [path_to_example.xls]
默认路径:../docs/example.xls(相对 backend)或 docs/example.xls(相对项目根)。
"""
import os
import re
import sys
from datetime import datetime
from pathlib import Path
import xlrd
# 数据库中的日期列(schema DATE 类型);Excel 里可能填「在建中」「未开工」等文字
DATE_COLUMNS = {
"sign_date",
"start_date",
"planned_completion_date",
"actual_completion_date",
"actual_warranty_refund_date",
}
# 质保期截止日、中标合同金额、工程款拨付方式:按字符串存储,原样导入
STRING_STORAGE_COLUMNS = {"warranty_end_date", "bid_contract_amount", "payment_method"}
# 签订/开工/计划竣工/实际竣工:有 _str 字段,日期正常写 DATE+「日期正常」,非日期写 _str
DATE_COLUMNS_WITH_STR = {
"sign_date",
"start_date",
"planned_completion_date",
"actual_completion_date",
}
DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$")
# 确保可导入 src
_backend = Path(__file__).resolve().parent.parent
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
os.chdir(_backend)
from src.database import get_connection, new_id
# Excel 表头(中文,strip 后)-> 数据库列名(snake_case
# 依据 schema.sql、初始需求;表头可能含换行或「其中:」等前缀
EXCEL_HEADER_TO_DB = {
"序号": "serial_no",
"合同编号": "contract_code",
"供电局项目合同编号": "power_bureau_contract_code",
"项目名称": "project_name",
"子项个数": "sub_item_count",
"子项编码": "sub_item_code",
"项目总投资(万元)": "total_investment",
"中标合同金额(万元)": "bid_contract_amount",
"质保金比例": "warranty_ratio",
"结算金额(万元)": "settlement_amount",
"总成本测算": "total_cost_estimate",
"工程电压等级": "voltage_level",
"工程类别": "project_category",
"业主单位": "owner_unit",
"业主联系人及电话": "owner_contact",
"中标形式": "bid_type",
"签订日期": "sign_date",
"开工日期": "start_date",
"计划竣工日期": "planned_completion_date",
"实际竣工日期": "actual_completion_date",
"质保金(万元)": "warranty_amount",
"质保期截止日": "warranty_end_date",
"实际退质保金日期": "actual_warranty_refund_date",
"所属项目部": "project_department",
"项目负责人及电话": "project_leader_contact",
"工程款拨付方式": "payment_method",
"总体成本(控制)": "total_cost",
"是否调整(是/否)": "is_adjusted",
"农民工工资(按进度计划)": "migrant_worker_plan",
"农民工工资(实付)": "migrant_worker_actual",
"其中:乙供材料费(控制)": "self_supply_material_control",
"应付材料费(按收款比例)": "material_payable_by_ratio",
"实际发生材料费": "material_actual_occurred",
"实际支付材料费": "material_actual_paid",
"其中:其他费用(控制)": "other_cost_control",
"应付其他费": "other_payable",
"实际其他费用": "other_actual",
"税金": "tax",
"利润(万元)": "profit",
"实际利润(万元)": "actual_profit",
"成本结算金额(万元)": "cost_settlement_amount",
"累计进度": "cumulative_progress",
"应收款(完成进度款)": "receivable_progress",
"开票金额(万元)": "invoice_amount",
"实际收款金额(万元)": "actual_receipt_amount",
"实际收款完成率": "actual_receipt_rate",
"应付款金额(万元)": "payable_amount",
"实际付款金额(万元)": "actual_payment_amount",
"未收款(万元)": "unreceived_amount",
"实际付款完成率": "actual_payment_rate",
"民工工资清欠金额(万元)": "migrant_worker_arrears",
"结算后成本测算金额": "settlement_cost_estimate",
"结算人工费": "settlement_labor_cost",
"结算材料费": "settlement_material_cost",
"结算其他费": "settlement_other_cost",
"到期应结算项目个数": "due_settlement_count",
"到期未完成结算个数": "overdue_settlement_count",
"存在的问题": "existing_problems",
"建议措施": "suggestions",
"备注": "remark",
}
def _normalize_header(h: str) -> str:
return h.strip().replace("\n", "").replace("\r", "")
def _cell_value(sh: xlrd.sheet.Sheet, row: int, col: int):
"""取单元格值,日期转 YYYY-MM-DD,空转 None。"""
try:
cell = sh.cell(row, col)
except IndexError:
return None
if cell.ctype == xlrd.XL_CELL_EMPTY:
return None
if cell.ctype == xlrd.XL_CELL_DATE:
try:
d = xlrd.xldate.xldate_as_datetime(cell.value, 0)
return d.strftime("%Y-%m-%d")
except Exception:
return None
v = cell.value
if isinstance(v, float):
if v != v: # NaN
return None
if v == int(v):
v = int(v)
if v == "" or (isinstance(v, str) and not v.strip()):
return None
if isinstance(v, str):
v = v.strip()
return v
def _cell_value_date_and_str(sh: xlrd.sheet.Sheet, row: int, col: int) -> tuple:
"""专用于带 _str 的日期列。返回 (date_val, str_val)date_val 为 YYYY-MM-DD 或 Nonestr_val 为「日期正常」或原文或 None。"""
try:
cell = sh.cell(row, col)
except IndexError:
return None, None
if cell.ctype == xlrd.XL_CELL_EMPTY:
return None, None
# 日期型或数字型(Excel 序列日期)
if cell.ctype == xlrd.XL_CELL_DATE:
try:
d = xlrd.xldate.xldate_as_datetime(cell.value, 0)
return d.strftime("%Y-%m-%d"), "日期正常"
except Exception:
return None, None
if cell.ctype == xlrd.XL_CELL_NUMBER and isinstance(cell.value, (int, float)):
v = cell.value
if 20000 <= v <= 50000 or (0 < v < 1): # 常见 Excel 日期范围或时间部分
try:
d = xlrd.xldate.xldate_as_datetime(v, 0)
return d.strftime("%Y-%m-%d"), "日期正常"
except Exception:
pass
return None, str(int(v)) if v == int(v) else str(v)
# 文本
v = cell.value
if v is None or (isinstance(v, str) and not v.strip()):
return None, None
s = (v if isinstance(v, str) else str(v)).strip()
if DATE_PATTERN.match(s):
return s, "日期正常"
return None, s
def _cell_value_raw_str(sh: xlrd.sheet.Sheet, row: int, col: int):
"""取单元格原值并转为字符串(用于 warranty_end_date、bid_contract_amount 等字符串存储列)。"""
try:
cell = sh.cell(row, col)
except IndexError:
return None
if cell.ctype == xlrd.XL_CELL_EMPTY:
return None
v = cell.value
if v is None:
return None
s = (str(v) if not isinstance(v, str) else v).strip()
return s if s else None
def _row_to_db_dict(sh: xlrd.sheet.Sheet, header_to_col: dict, row_idx: int) -> dict:
"""将一行转为 DB 列名 -> 值的字典。header_to_col: 表头(已 normalize) -> 列索引。"""
row_dict = {}
for header, col in header_to_col.items():
db_col = EXCEL_HEADER_TO_DB.get(header)
if not db_col:
continue
if db_col in DATE_COLUMNS_WITH_STR:
date_val, str_val = _cell_value_date_and_str(sh, row_idx, col)
if date_val is not None:
row_dict[db_col] = date_val
row_dict[db_col + "_str"] = "日期正常"
elif str_val is not None and str_val.strip():
row_dict[db_col + "_str"] = str_val
continue
if db_col in STRING_STORAGE_COLUMNS:
val = _cell_value_raw_str(sh, row_idx, col)
if val is not None:
row_dict[db_col] = val
continue
if db_col == "total_cost":
val = _cell_value(sh, row_idx, col)
if val is not None:
if isinstance(val, (int, float)) and (not isinstance(val, float) or not (val != val)):
row_dict[db_col] = float(val)
elif isinstance(val, str):
s = val.strip()
try:
row_dict[db_col] = float(s)
except (ValueError, TypeError):
row_dict[db_col] = 0
row_dict["_total_cost_text"] = s
else:
row_dict[db_col] = 0
continue
val = _cell_value(sh, row_idx, col)
if val is None:
continue
if db_col in DATE_COLUMNS and isinstance(val, str) and not DATE_PATTERN.match(val.strip()):
continue
row_dict[db_col] = val
if "_total_cost_text" in row_dict:
prefix = "总体成本(文字):" + row_dict["_total_cost_text"] + "\n"
row_dict["remark"] = prefix + (row_dict.get("remark") or "")
del row_dict["_total_cost_text"]
return row_dict
def main():
if len(sys.argv) >= 2:
xls_path = Path(sys.argv[1]).resolve()
else:
for p in [_backend.parent / "docs" / "example.xls", _backend / "docs" / "example.xls"]:
if p.exists():
xls_path = p
break
else:
print("未找到 example.xls,请指定路径:python scripts/import_xls.py <path>")
sys.exit(1)
if not xls_path.exists():
print(f"文件不存在:{xls_path}")
sys.exit(1)
wb = xlrd.open_workbook(str(xls_path))
sh = wb.sheet_by_index(0)
header_row_idx = 2
data_start_idx = 5
raw_headers = [str(sh.cell_value(header_row_idx, j)) for j in range(sh.ncols)]
# header_to_col: 归一化表头 -> 列索引(取首次出现)
header_to_col = {}
for j, h in enumerate(raw_headers):
nh = _normalize_header(h)
if nh and nh not in header_to_col:
header_to_col[nh] = j
contract_code_col = header_to_col.get("合同编号")
project_name_col = header_to_col.get("项目名称")
if contract_code_col is None:
print("未找到列「合同编号」,请检查 Excel 表头。")
sys.exit(1)
inserted = 0
skipped = 0
with get_connection() as conn:
cur = conn.cursor()
for r in range(data_start_idx, sh.nrows):
row_dict = _row_to_db_dict(sh, header_to_col, r)
contract_code = _cell_value(sh, r, contract_code_col) if contract_code_col is not None else row_dict.get("contract_code")
project_name = _cell_value(sh, r, project_name_col) if project_name_col is not None else row_dict.get("project_name")
if contract_code is None or (isinstance(contract_code, str) and not contract_code.strip()):
skipped += 1
continue
if project_name is None or (isinstance(project_name, str) and not project_name.strip()):
project_name = str(contract_code) if contract_code else "未命名"
row_dict["contract_code"] = contract_code
row_dict["project_name"] = project_name
pid = new_id()
now = datetime.utcnow()
row_dict["id"] = pid
row_dict["progress"] = (str(row_dict.get("cumulative_progress") or "")[:32]) if row_dict.get("cumulative_progress") is not None else ""
row_dict["cost"] = ""
row_dict["created_at"] = now
row_dict["updated_at"] = now
cols = list(row_dict.keys())
vals = [row_dict[k] for k in cols]
placeholders = ", ".join(["%s"] * len(cols))
try:
cur.execute(
"INSERT INTO projects (" + ", ".join(cols) + ") VALUES (" + placeholders + ")",
vals,
)
inserted += 1
except Exception as e:
print(f"Row {r + 1} 跳过({e}")
skipped += 1
cur.close()
print(f"导入完成:成功 {inserted} 条,跳过 {skipped} 条。")
if __name__ == "__main__":
main()
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""
为 projects 表增加四个日期原文列(若不存在)。
用法:cd backend && PYTHONPATH=src python3 scripts/migrate_add_date_str_columns.py
"""
import sys
from pathlib import Path
_backend = Path(__file__).resolve().parent.parent
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
from src.database import get_connection
ALTER_SQL = """
ALTER TABLE projects
ADD COLUMN sign_date_str varchar(128) DEFAULT NULL COMMENT '签订日期原文:日期正常或导入字符串' AFTER sign_date,
ADD COLUMN start_date_str varchar(128) DEFAULT NULL COMMENT '开工日期原文' AFTER start_date,
ADD COLUMN planned_completion_date_str varchar(128) DEFAULT NULL COMMENT '计划竣工日期原文' AFTER planned_completion_date,
ADD COLUMN actual_completion_date_str varchar(128) DEFAULT NULL COMMENT '实际竣工日期原文' AFTER actual_completion_date;
"""
def main():
with get_connection() as conn:
cur = conn.cursor()
try:
cur.execute(ALTER_SQL)
conn.commit()
print("已为 projects 表添加 sign_date_str、start_date_str、planned_completion_date_str、actual_completion_date_str 列。")
except Exception as e:
if "Duplicate column name" in str(e):
print("列已存在,无需迁移。")
else:
raise
finally:
cur.close()
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""
将 projects 表中「质保期截止日」「中标合同金额」「工程款拨付方式」改为字符串类型。
用法:cd backend && PYTHONPATH=src python3 scripts/migrate_string_columns.py
"""
import sys
from pathlib import Path
_backend = Path(__file__).resolve().parent.parent
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
from src.database import get_connection
def main():
with get_connection() as conn:
cur = conn.cursor()
try:
cur.execute(
"ALTER TABLE projects "
"MODIFY COLUMN bid_contract_amount varchar(512) DEFAULT NULL COMMENT '中标合同金额(万元,字符串存储)', "
"MODIFY COLUMN warranty_end_date varchar(256) DEFAULT NULL COMMENT '质保期截止日(字符串存储)', "
"MODIFY COLUMN payment_method varchar(2048) DEFAULT NULL COMMENT '工程款拨付方式', "
"MODIFY COLUMN is_adjusted varchar(512) DEFAULT NULL COMMENT '是否调整:是/否'"
)
conn.commit()
print("已将 bid_contract_amount、warranty_end_date、payment_method、is_adjusted 改为目标类型/长度。")
except Exception as e:
print(f"迁移失败: {e}")
raise
finally:
cur.close()
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""
插入测试用户,供测试工程师使用。统一密码:123456
用法:cd backend && PYTHONPATH=src python3 scripts/seed_test_users.py
"""
import sys
from pathlib import Path
_backend = Path(__file__).resolve().parent.parent
if str(_backend) not in sys.path:
sys.path.insert(0, str(_backend))
import bcrypt
from src.database import get_connection, new_id
def _hash(pwd: str) -> str:
"""bcrypt 哈希,与 auth 中 passlib 校验兼容"""
return bcrypt.hashpw(pwd.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
# 角色枚举与测试账号: (username, role, display_name)
TEST_USERS = [
("admin", "管理员", "管理员"),
("market", "市场部", "市场部"),
("engineer", "工程部", "工程部"),
("tech", "技经部", "技经部"),
("finance", "财务部", "财务部"),
("material", "物贸部", "物贸部"),
]
TEST_PASSWORD = "123456"
def main():
pwd_hash = _hash(TEST_PASSWORD)
with get_connection() as conn:
cur = conn.cursor()
for username, role, display_name in TEST_USERS:
uid = new_id()
try:
cur.execute(
"INSERT INTO users (id, username, password_hash, role, display_name) VALUES (%s, %s, %s, %s, %s)",
(uid, username, pwd_hash, role, display_name),
)
conn.commit()
print(f"已插入: {username} ({role})")
except Exception as e:
if "Duplicate entry" in str(e) or "1062" in str(e):
print(f"已存在,跳过: {username}")
else:
raise
cur.close()
print(f"\n测试账号统一密码: {TEST_PASSWORD}")
print("账号列表: admin, market, engineer, tech, finance, material")
if __name__ == "__main__":
main()