Files
shanxia2.0/docs/superpowers/plans/2026-07-13-pl-personnel-cert-split.md
T
2026-07-14 19:53:35 +08:00

525 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 人员从业资格证拆分 — 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** |