16 KiB
人员从业资格证拆分 — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 将驾押人员必备证书从泛称「从业资格证」拆分为「驾驶员从业资格证」与「押运员从业资格证」;兼岗须 4 证齐全;徽章显示「业」「押」;押运员证作为押运员从业资格证别名。
Architecture: 规则单一来源为 pl-personnel-cert.ts(前端)与 pl_cert_required_service.py(后端),二者常数与 match*CertName 保持镜像;both 类型返回独立 4 项列表;历史数据先跑迁移 SQL 再部署代码。
Tech Stack: FastAPI + SQLAlchemy;Vue 3 + Element Plus;MySQL 5.7
Status: ✅ 已实现(2026-07-13)
文件结构
| 操作 | 文件 | 职责 |
|---|---|---|
| Create | backend/scripts/migration_20260713_pl_personnel_cert_split.sql |
历史 cert_name 重命名 + both 待人工清单查询 |
| Create | backend/tests/test_pl_personnel_cert_required.py |
必备规则与匹配单元测试 |
| Modify | backend/app/services/pl_cert_required_service.py |
后端必备常数、both 合并、match_required_cert_name |
| Modify | web-admin/src/utils/pl-personnel-cert.ts |
前端规则源(与后端对齐) |
| Modify | web-admin/src/views/pl/personnel/index.vue |
证书下拉选项 |
| Modify | docs/superpowers/specs/2026-07-12-pl-required-cert-slots-design.md |
废止 D5/D6,指向新 spec |
| Modify | docs/sql/shanxia2.0.sql |
字典 pl_cert_name 增量(可选,见 Task 6) |
| 回归 | web-admin/src/components/business/pl-personnel-cert-badges/index.vue |
无需改代码,验徽章 |
| 回归 | web-admin/src/views/pl/self-vehicle/index.vue |
关联人员证书校验 |
| 回归 | web-admin/src/views/pl/vehicle/index.vue |
同上 |
部署顺序(强制)
1. 执行 migration_20260713_pl_personnel_cert_split.sql
2. 处理 both + 单条「从业资格证」人工清单
3. 部署 backend + web-admin
4. 按 §验收步骤 走查
Task 1: 后端单元测试(TDD 先行)
Files:
-
Create:
backend/tests/test_pl_personnel_cert_required.py -
Step 1: 编写失败测试
"""pl_cert_required_service 人员必备证书规则测试。"""
from __future__ import annotations
import pytest
from app.services.pl_cert_required_service import (
collect_required_cert_errors,
get_required_personnel_cert_names,
match_required_cert_name,
)
def test_match_driver_qualification_exact():
assert match_required_cert_name("驾驶员从业资格证", "驾驶员从业资格证") is True
assert match_required_cert_name("从业资格证", "驾驶员从业资格证") is False
assert match_required_cert_name("危化品从业资格证", "驾驶员从业资格证") is False
def test_match_escort_qualification_and_alias():
assert match_required_cert_name("押运员从业资格证", "押运员从业资格证") is True
assert match_required_cert_name("押运员证", "押运员从业资格证") is True
assert match_required_cert_name("从业资格证", "押运员从业资格证") is False
def test_required_names_by_personnel_type():
assert get_required_personnel_cert_names("driver") == (
"身份证",
"驾驶证",
"驾驶员从业资格证",
)
assert get_required_personnel_cert_names("escort") == (
"身份证",
"押运员从业资格证",
)
assert get_required_personnel_cert_names("both") == (
"身份证",
"驾驶证",
"驾驶员从业资格证",
"押运员从业资格证",
)
def test_both_requires_four_certs():
cert_rows = [
{"certName": "身份证", "certNo": "1", "expireDate": "2030-01-01", "attachmentUrls": "http://a"},
{"certName": "驾驶证", "certNo": "2", "expireDate": "2030-01-01", "attachmentUrls": "http://b"},
{"certName": "驾驶员从业资格证", "certNo": "3", "expireDate": "2030-01-01", "attachmentUrls": "http://c"},
]
errors = collect_required_cert_errors(
cert_rows,
get_required_personnel_cert_names("both"),
)
assert any("押运员从业资格证" in e for e in errors)
def test_escort_alias_counts_as_complete():
cert_rows = [
{"certName": "身份证", "certNo": "1", "expireDate": "2030-01-01", "attachmentUrls": "http://a"},
{"certName": "押运员证", "certNo": "2", "expireDate": "2030-01-01", "attachmentUrls": "http://b"},
]
errors = collect_required_cert_errors(
cert_rows,
get_required_personnel_cert_names("escort"),
)
assert errors == []
- Step 2: 运行测试确认失败
cd backend && source venv/bin/activate && pytest tests/test_pl_personnel_cert_required.py -v
Expected: FAIL(常数仍为「从业资格证」、both 仅 3 项)
- Step 3: Commit 测试文件
git add backend/tests/test_pl_personnel_cert_required.py
git commit -m "test: add personnel cert split required rules tests"
Task 2: 后端规则实现
Files:
-
Modify:
backend/app/services/pl_cert_required_service.py -
Step 1: 更新常数与匹配函数
将第 22–39 行替换为:
PERSONNEL_DRIVER_REQUIRED = ("身份证", "驾驶证", "驾驶员从业资格证")
PERSONNEL_ESCORT_REQUIRED = ("身份证", "押运员从业资格证")
PERSONNEL_BOTH_REQUIRED = (
"身份证",
"驾驶证",
"驾驶员从业资格证",
"押运员从业资格证",
)
def match_required_cert_name(actual_name: str | None, required_name: str) -> bool:
name = (actual_name or "").strip()
if not name:
return False
if required_name == "押运员从业资格证":
return name == "押运员从业资格证" or name == "押运员证"
return name == required_name
- Step 2: 更新
get_required_personnel_cert_names
将第 136–142 行替换为:
def get_required_personnel_cert_names(personnel_type: str | None) -> tuple[str, ...]:
ptype = (personnel_type or "").strip()
if ptype == "escort":
return PERSONNEL_ESCORT_REQUIRED
if ptype == "driver":
return PERSONNEL_DRIVER_REQUIRED
if ptype == "both":
return PERSONNEL_BOTH_REQUIRED
return PERSONNEL_BOTH_REQUIRED
- Step 3: 运行测试
cd backend && pytest tests/test_pl_personnel_cert_required.py -v
Expected: PASS(5 passed)
- Step 4: Commit
git add backend/app/services/pl_cert_required_service.py
git commit -m "feat(pl): split driver and escort qualification cert requirements"
Task 3: 前端规则源
Files:
-
Modify:
web-admin/src/utils/pl-personnel-cert.ts -
Step 1: 更新常数(第 5–11 行)
export const PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES = [
'身份证',
'驾驶证',
'驾驶员从业资格证'
] as const
export const PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES = [
'身份证',
'押运员从业资格证'
] as const
export const PL_PERSONNEL_BOTH_REQUIRED_CERT_NAMES = [
'身份证',
'驾驶证',
'驾驶员从业资格证',
'押运员从业资格证'
] as const
- Step 2: 更新
getRequiredPersonnelCertNamesByType(第 66–73 行)
export function getRequiredPersonnelCertNamesByType(
personnelType?: string | null
): readonly string[] {
if (personnelType === 'escort') {
return PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES
}
if (personnelType === 'driver') {
return PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES
}
return PL_PERSONNEL_BOTH_REQUIRED_CERT_NAMES
}
- Step 3: 更新
matchRequiredCertName(第 114–128 行)
export function matchRequiredCertName(
actualName: string | undefined,
requiredName: string
): boolean {
const name = actualName?.trim() || ''
if (!name) return false
if (requiredName === '押运员从业资格证') {
return name === '押运员从业资格证' || name === '押运员证'
}
return name === requiredName
}
- Step 4: 更新徽章 shortLabel(第 190–194 行)
export const PL_PERSONNEL_CERT_SHORT_LABELS: Record<string, string> = {
身份证: '身',
驾驶证: '驾',
驾驶员从业资格证: '业',
押运员从业资格证: '押'
}
- Step 5: Commit
git add web-admin/src/utils/pl-personnel-cert.ts
git commit -m "feat(pl): align frontend personnel cert required rules with split design"
Task 4: 人员页下拉选项
Files:
-
Modify:
web-admin/src/views/pl/personnel/index.vue:431-438 -
Step 1: 更新
certNameOptions
const certNameOptions = [
'身份证',
'驾驶证',
'驾驶员从业资格证',
'押运员从业资格证',
'押运员证',
'健康证'
]
说明:移除泛称 从业资格证、危化品从业资格证(避免误选;历史/other 证书仍可手输)。
- Step 2: Commit
git add web-admin/src/views/pl/personnel/index.vue
git commit -m "feat(pl): update personnel cert name dropdown for split qualifications"
Task 5: 历史数据迁移脚本
Files:
-
Create:
backend/scripts/migration_20260713_pl_personnel_cert_split.sql -
Step 1: 创建迁移 SQL
-- 人员从业资格证拆分:cert_name 重命名(可重复执行)
-- 前置 spec: docs/superpowers/specs/2026-07-13-pl-personnel-cert-split-design.md
-- 1) 押运员证 → 押运员从业资格证(全部 personnel 证书)
UPDATE pl_cert c
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
SET c.cert_name = '押运员从业资格证',
c.update_time = NOW()
WHERE c.del_flag = 0
AND c.owner_type = 'personnel'
AND c.cert_name = '押运员证';
-- 2) 泛称从业资格证 → 驾驶员从业资格证(driver / both,且非 both 单条歧义场景见注释)
UPDATE pl_cert c
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
SET c.cert_name = '驾驶员从业资格证',
c.update_time = NOW()
WHERE c.del_flag = 0
AND c.owner_type = 'personnel'
AND c.cert_name = '从业资格证'
AND p.personnel_type IN ('driver', 'both');
-- 3) 泛称从业资格证 → 押运员从业资格证(escort)
UPDATE pl_cert c
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
SET c.cert_name = '押运员从业资格证',
c.update_time = NOW()
WHERE c.del_flag = 0
AND c.owner_type = 'personnel'
AND c.cert_name = '从业资格证'
AND p.personnel_type = 'escort';
-- 4) both 人员:若迁移后仍缺驾/押任一从业证,供运营补录(验证查询,不修改数据)
SELECT
p.id AS personnel_id,
p.personnel_name,
p.personnel_type,
GROUP_CONCAT(c.cert_name ORDER BY c.cert_name SEPARATOR '、') AS cert_names
FROM pl_personnel p
LEFT JOIN pl_cert c
ON c.owner_id = p.id
AND c.owner_type = 'personnel'
AND c.del_flag = 0
WHERE p.del_flag = 0
AND p.personnel_type = 'both'
GROUP BY p.id, p.personnel_name, p.personnel_type
HAVING SUM(CASE WHEN c.cert_name IN ('驾驶员从业资格证') THEN 1 ELSE 0 END) = 0
OR SUM(CASE WHEN c.cert_name IN ('押运员从业资格证', '押运员证') THEN 1 ELSE 0 END) = 0;
-- 5) 危化品从业资格证:不自动迁移(人工清单)
SELECT
p.id AS personnel_id,
p.personnel_name,
p.personnel_type,
c.id AS cert_id,
c.cert_name
FROM pl_cert c
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
WHERE c.del_flag = 0
AND c.owner_type = 'personnel'
AND c.cert_name = '危化品从业资格证';
-- 6) both + 仍保留泛称「从业资格证」的歧义记录(需人工处理,不自动改)
SELECT
p.id AS personnel_id,
p.personnel_name,
c.id AS cert_id,
c.cert_name
FROM pl_cert c
INNER JOIN pl_personnel p ON p.id = c.owner_id AND p.del_flag = 0
WHERE c.del_flag = 0
AND c.owner_type = 'personnel'
AND c.cert_name = '从业资格证'
AND p.personnel_type = 'both';
- Step 2: 在测试库执行并保存清单
mysql -h <host> -u <user> -p <database> < backend/scripts/migration_20260713_pl_personnel_cert_split.sql
将步骤 4–6 的 SELECT 结果导出给运营。
- Step 3: Commit
git add backend/scripts/migration_20260713_pl_personnel_cert_split.sql
git commit -m "chore(pl): add migration script for personnel cert name split"
Task 6: 字典同步(可选)
Files:
-
Modify:
docs/sql/shanxia2.0.sql(pl_cert_name字典段) -
Step 1: 追加字典项(若不存在)
-- pl_cert_name 字典增量(执行前检查是否已存在)
INSERT INTO sys_dict_data (id, dict_label, dict_value, tag_type, dict_type, tenant_no, order_num, del_flag, create_time, update_time)
SELECT 'PL_CERT_DRIVER_QUAL', '驾驶员从业资格证', '驾驶员从业资格证', 'primary', 'pl_cert_name', 'shanxia2.0', 6, 0, NOW(), NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE id = 'PL_CERT_DRIVER_QUAL');
INSERT INTO sys_dict_data (id, dict_label, dict_value, tag_type, dict_type, tenant_no, order_num, del_flag, create_time, update_time)
SELECT 'PL_CERT_ESCORT_QUAL', '押运员从业资格证', '押运员从业资格证', 'warning', 'pl_cert_name', 'shanxia2.0', 7, 0, NOW(), NOW()
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM sys_dict_data WHERE id = 'PL_CERT_ESCORT_QUAL');
- Step 2: Commit(若做了字典变更)
Task 7: 文档与图谱
Files:
-
Modify:
docs/superpowers/specs/2026-07-12-pl-required-cert-slots-design.md(§1.3 表格 + D5/D6 加注「已由 2026-07-13 spec 替代」) -
Modify:
docs/superpowers/specs/2026-07-13-pl-personnel-cert-split-design.md(状态改为「已批准」) -
Step 1: 更新旧 spec 废止说明
-
Step 2: 运行 graphify 更新
cd /Users/zlwu/work/00code/shanxia2.0 && ./scripts/update-graph.sh backend
- Step 3: Commit
git add docs/superpowers/specs/
git commit -m "docs(pl): mark personnel cert split spec approved and supersede 2026-07-12 D5/D6"
验收步骤
A. 自动化
# 后端
cd backend && pytest tests/test_pl_personnel_cert_required.py -v
# 前端 lint(可选)
cd web-admin && pnpm lint
B. 人员主数据页 /#/pl/base-info/personnel
| # | 操作 | 预期 |
|---|---|---|
| 1 | 新增 → 选「驾驶员」 | 预置 3 槽:身份证、驾驶证、驾驶员从业资格证;徽章占位 身驾业 |
| 2 | 缺「驾驶员从业资格证」保存 | 阻断,提示「押运员从业资格证」或「驾驶员从业资格证」缺失文案 |
| 3 | 新增 → 选「押运员」 | 预置 2 槽;列表徽章 身押(非「业」) |
| 4 | 证书名选手输「押运员证」且三字段齐全 | 满足押运员必备,可保存 |
| 5 | 新增 → 选「驾驶员兼押运员」 | 预置 4 槽;徽章 身驾业押 |
| 6 | both 仅填 3 证(缺押运员侧) | 保存阻断 |
| 7 | 手输泛称「从业资格证」 | 不能满足任一从业必备槽 |
C. 车辆关联(山峡自有车)
| # | 操作 | 预期 |
|---|---|---|
| 8 | 关联 escort 角色,人员仅有押运员从业资格证(或押运员证) | 车辆保存通过 escort 证书校验 |
| 9 | 关联 driver 角色,人员缺「驾驶员从业资格证」 | 车辆保存阻断 |
D. 迁移后数据
| # | 检查 | 预期 |
|---|---|---|
| 10 | SELECT cert_name FROM pl_cert WHERE cert_name='押运员证' AND owner_type='personnel' |
0 行(已 rename) |
| 11 | both 缺证清单(迁移 SQL §4) | 运营已补录或确认无遗漏 |
| 12 | 危化品从业资格证清单(迁移 SQL §5) | 运营已人工归类 |
E. API 直调(可选)
# PUT /api/web/admin/transport/personnel/{id}
# personnelType=both, certList 仅含 3 证 → 期望 400,detail 含「押运员从业资格证」
Spec 覆盖自检
| Spec 要求 | 对应 Task |
|---|---|
| D1 拆分必备证种 | Task 2, 3 |
| D2 兼岗 4 证 | Task 2, 3 |
| D3 徽章 业/押 | Task 3 Step 4 |
| D4 押运员证别名 | Task 2, 3 match 函数 |
| D5 严格匹配 | Task 2, 3 删除模糊规则 |
| D6/D7 双校验 | Task 2 + 3 + 验收 B/E |
| D8 迁移 | Task 5 |
| 验收 1–8 | §验收步骤 B–D |
工作量
| Task | 预估 |
|---|---|
| Task 1–2 后端 | 2h |
| Task 3–4 前端 | 1h |
| Task 5 迁移 | 1h |
| Task 6–7 文档 | 0.5h |
| 验收走查 | 1h |
| 合计 | 约 5.5h |