525 lines
16 KiB
Markdown
525 lines
16 KiB
Markdown
# 人员从业资格证拆分 — 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: 编写失败测试**
|
||
|
||
```python
|
||
"""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: 运行测试确认失败**
|
||
|
||
```bash
|
||
cd backend && source venv/bin/activate && pytest tests/test_pl_personnel_cert_required.py -v
|
||
```
|
||
|
||
Expected: FAIL(常数仍为「从业资格证」、`both` 仅 3 项)
|
||
|
||
- [ ] **Step 3: Commit 测试文件**
|
||
|
||
```bash
|
||
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 行替换为:
|
||
|
||
```python
|
||
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 行替换为:
|
||
|
||
```python
|
||
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: 运行测试**
|
||
|
||
```bash
|
||
cd backend && pytest tests/test_pl_personnel_cert_required.py -v
|
||
```
|
||
|
||
Expected: PASS(5 passed)
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
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 行)**
|
||
|
||
```typescript
|
||
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 行)**
|
||
|
||
```typescript
|
||
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 行)**
|
||
|
||
```typescript
|
||
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 行)**
|
||
|
||
```typescript
|
||
export const PL_PERSONNEL_CERT_SHORT_LABELS: Record<string, string> = {
|
||
身份证: '身',
|
||
驾驶证: '驾',
|
||
驾驶员从业资格证: '业',
|
||
押运员从业资格证: '押'
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
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`**
|
||
|
||
```typescript
|
||
const certNameOptions = [
|
||
'身份证',
|
||
'驾驶证',
|
||
'驾驶员从业资格证',
|
||
'押运员从业资格证',
|
||
'押运员证',
|
||
'健康证'
|
||
]
|
||
```
|
||
|
||
说明:移除泛称 `从业资格证`、`危化品从业资格证`(避免误选;历史/other 证书仍可手输)。
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
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**
|
||
|
||
```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: 在测试库执行并保存清单**
|
||
|
||
```bash
|
||
mysql -h <host> -u <user> -p <database> < backend/scripts/migration_20260713_pl_personnel_cert_split.sql
|
||
```
|
||
|
||
将步骤 4–6 的 SELECT 结果导出给运营。
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
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: 追加字典项(若不存在)**
|
||
|
||
```sql
|
||
-- 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 更新**
|
||
|
||
```bash
|
||
cd /Users/zlwu/work/00code/shanxia2.0 && ./scripts/update-graph.sh backend
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add docs/superpowers/specs/
|
||
git commit -m "docs(pl): mark personnel cert split spec approved and supersede 2026-07-12 D5/D6"
|
||
```
|
||
|
||
---
|
||
|
||
## 验收步骤
|
||
|
||
### A. 自动化
|
||
|
||
```bash
|
||
# 后端
|
||
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 直调(可选)
|
||
|
||
```bash
|
||
# 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** |
|