This commit is contained in:
zlwu committed 2026-07-14 19:53:35 +08:00
1 parent 0e60b88cba
commit 87f7f6bf38
34 files changed
+18326 -6165

No files matched your search

@@ -0,0 +1,524 @@
# 人员从业资格证拆分 — 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** |
@@ -45,8 +45,8 @@
| D2 | **必备字段:证号 + 有效期 + 附件** | 提醒天数维持默认 30,不作为必备条件 |
| D3 | **仅山峡自有车强制车辆证** | `vehicleType=self` 时校验主车 3 证、有挂车号时挂车 4 证;`carrier` 不校验 |
| D4 | **人员类型三选一** | `driver` 驾驶员 / `escort` 押运员 / `both` 驾驶员兼押运员;人员主数据必填 |
| D5 | **人员必备按类型** | driver、both → 身份证+驾驶证+从业资格证;escort → 身份证+从业资格证 |
| D6 | **从业资格证别名** | 「危化品从业资格证」等等价匹配「从业资格证」(沿用 `matchRequiredCertName`) |
| D5 | **人员必备按类型** | ~~driver、both → 身份证+驾驶证+从业资格证;escort → 身份证+从业资格证~~ **已由 `2026-07-13-pl-personnel-cert-split-design.md` 替代**:驾/押从业证拆分,both 须 4 证 |
| D6 | **从业资格证别名** | ~~「危化品从业资格证」等等价匹配「从业资格证」~~ **已废止**;押运员侧仅 `押运员证` 可匹配 `押运员从业资格证` |
| D7 | **保存一律阻断** | 历史数据缺必备证时,任何字段保存均须先补齐 |
| D8 | **前后端双校验** | 前端 UX + 后端 API 400,防绕过 |
| D9 | **关联人员角色兼容** | 车辆子表 `role` 保留;保存山峡自有车时校验人员类型与 role 可匹配(见 §三) |
@@ -74,9 +74,9 @@
| personnel_type | 展示文案 | 必备证书 |
|----------------|---------|---------|
| `driver` | 驾驶员 | 身份证、驾驶证、从业资格证 |
| `escort` | 押运员 | 身份证、从业资格证 |
| `both` | 驾驶员兼押运员 | 身份证、驾驶证、从业资格证 |
| `driver` | 驾驶员 | 身份证、驾驶证、驾驶员从业资格证 |
| `escort` | 押运员 | 身份证、押运员从业资格证 |
| `both` | 驾驶员兼押运员 | 身份证、驾驶证、驾驶员从业资格证、押运员从业资格证 |
常数来源:`PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES`、`PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES`。
@@ -0,0 +1,177 @@
# 运输管理 · 人员从业资格证拆分 — 设计文档
> **日期**: 2026-07-13
> **状态**: 已批准
> **前置**: `docs/superpowers/specs/2026-07-12-pl-required-cert-slots-design.md`(D5/D6 需废止并替换)
> **界面**: `/#/pl/base-info/personnel`(驾押人员信息)
## 背景
2026-07-12 实现将「从业资格证」视为**驾押共用**的一种证,并通过 `matchRequiredCertName` 模糊匹配(含「危化品从业资格证」、名称含「从业资格证」)。业务上这是**错误建模**:
- **驾驶员从业资格证** 与 **押运员从业资格证** 是两张不同的证
- **驾驶员兼押运员** 须同时持有上述两种从业资格证(共 4 项必备,非 3 项)
## 已确认决策
| # | 决策 | 说明 |
|---|------|------|
| D1 | **拆分必备证种** | 不再使用泛称「从业资格证」作为必备槽名称 |
| D2 | **兼岗 4 证** | `both` = 身份证 + 驾驶证 + 驾驶员从业资格证 + 押运员从业资格证 |
| D3 | **徽章 shortLabel** | 驾驶员从业资格证 → **业**;押运员从业资格证 → **押** |
| D4 | **押运员证别名** | 历史/录入名称「押运员证」**等价于**「押运员从业资格证」(匹配与迁移均认可) |
| D5 | **严格匹配从业证** | 取消「名称含从业资格证」的宽泛规则;两类从业证分别精确匹配(押运员侧含 D4 别名) |
| D6 | **保存一律阻断** | 沿用 2026-07-12 D7:缺必备证时任何保存均 400 / 前端 warning |
| D7 | **前后端双校验** | 规则以 util + service 两处硬编码保持一致 |
| D8 | **历史数据迁移** | 上线前执行 SQL/脚本,见 §三 |
## 一、修订后必备证书矩阵
| personnel_type | 展示文案 | 必备证书 | 列表徽章顺序 |
|----------------|---------|---------|-------------|
| `driver` | 驾驶员 | 身份证、驾驶证、驾驶员从业资格证 | 身、驾、业 |
| `escort` | 押运员 | 身份证、押运员从业资格证 | 身、押 |
| `both` | 驾驶员兼押运员 | 身份证、驾驶证、驾驶员从业资格证、押运员从业资格证 | 身、驾、业、押 |
每项必备仍要求:**证号 + 有效期 + 附件** 均非空(与 2026-07-12 §1.4 一致)。
### 1.1 证书名称匹配规则
| 必备槽目标名称 | 可接受的 cert_name |
|---------------|-------------------|
| 身份证 | 精确:`身份证` |
| 驾驶证 | 精确:`驾驶证` |
| 驾驶员从业资格证 | 精确:`驾驶员从业资格证` |
| 押运员从业资格证 | 精确:`押运员从业资格证`;别名:`押运员证`(D4) |
**不再接受**作为任一从业必备槽的匹配:
- 泛称 `从业资格证`
- `危化品从业资格证`(除非业务后续单独确认可映射到驾驶员侧,**本期不自动映射**)
- `name.includes('从业资格证')` 类模糊规则
### 1.2 与 2026-07-12 的差异
| 项 | 2026-07-12 | 本变更 |
|----|-----------|--------|
| driver 必备 | 身份证、驾驶证、从业资格证 | 身份证、驾驶证、**驾驶员**从业资格证 |
| escort 必备 | 身份证、从业资格证 | 身份证、**押运员**从业资格证 |
| both 必备 | 同 driver(3 项) | **4 项**(驾+押从业证均有) |
| 从业证匹配 | 模糊共用 | 分类型精确匹配 + 押运员证别名 |
## 二、实现范围
### 2.1 前端
**文件**: `web-admin/src/utils/pl-personnel-cert.ts`
- 常数:
- `PL_PERSONNEL_DRIVER_REQUIRED_CERT_NAMES` → `('身份证', '驾驶证', '驾驶员从业资格证')`
- `PL_PERSONNEL_ESCORT_REQUIRED_CERT_NAMES` → `('身份证', '押运员从业资格证')`
- `getRequiredPersonnelCertNamesByType`:
- `escort` → escort 列表
- `driver` → driver 列表
- `both` → **合并去重** driver ∪ escort(顺序:身份证、驾驶证、驾驶员从业资格证、押运员从业资格证)
- `matchRequiredCertName`:
- 删除对泛称「从业资格证」的模糊分支
- 新增:`押运员从业资格证` 可匹配 `押运员证`
- `驾驶员从业资格证` 仅精确匹配
- `PL_PERSONNEL_CERT_SHORT_LABELS`:
- `驾驶员从业资格证: '业'`
- `押运员从业资格证: '押'`
- 移除泛称 `从业资格证: '业'`
**文件**: `web-admin/src/views/pl/personnel/index.vue`
- `certNameOptions`:必备相关选项改为 `驾驶员从业资格证`、`押运员从业资格证`;保留 `押运员证` 于「其他证书」可选(或标注为别名,非必备槽名)
- 移除泛称 `从业资格证` 作为必备槽预置名(可选是否保留于自由添加下拉)
**连带组件**(逻辑随 util 自动生效,需回归):
- `pl-personnel-cert-badges`
- `pl-cert-form-list` / `pl-cert-slots`
- `pl-vehicle-personnel-view-list` / `PersonnelViewPanel`(车辆关联查看态缺失 suffix)
- `pl/self-vehicle`、`pl/vehicle`(关联人员证书校验提示)
### 2.2 后端
**文件**: `backend/app/services/pl_cert_required_service.py`
- `PERSONNEL_DRIVER_REQUIRED`、`PERSONNEL_ESCORT_REQUIRED` 与前端对齐
- `get_required_personnel_cert_names`:`both` 返回 4 项合并列表
- `match_required_cert_name`:与前端 D4/D5 一致
- `validate_vehicle_personnel_cert_links`:按 `role` 使用 driver/escort 必备列表(escort 侧含押运员从业资格证 + 押运员证别名)
### 2.3 字典(可选同步)
`sys_dict_data` / `pl_cert_name`:
- 新增:`驾驶员从业资格证`、`押运员从业资格证`
- `押运员证` 保留(作为别名来源,非必备槽展示名)
- 泛称 `从业资格证`、`危化品从业资格证`:可保留于字典供历史/other 证书,**不参与必备匹配**
## 三、历史数据迁移
### 3.1 证书名称重命名
| 原 cert_name | 迁移后 cert_name | 条件 |
|-------------|-----------------|------|
| `押运员证` | `押运员从业资格证` | 全部 personnel owner |
| `从业资格证` | `驾驶员从业资格证` | 该人员 `personnel_type IN ('driver', 'both')` |
| `从业资格证` | `押运员从业资格证` | 该人员 `personnel_type = 'escort'` |
| `从业资格证` | **不自动迁移** | `personnel_type = 'both'` 且仅有一条 → **导出人工清单**;或按业务规则复制为两条(若证号相同则拆两条记录,**需运营确认**,默认不自动复制) |
**both + 单条泛称「从业资格证」**:迁移脚本应 **输出待人工处理清单**(personnel_id、姓名、原 cert_name),不静默映射,避免一张证冒充两种从业证。
**危化品从业资格证**:本期 **不自动映射**;进入缺证清单,由运营按实际类型补录或重命名。
### 3.2 迁移脚本
- 路径建议:`backend/scripts/migration_20260713_pl_personnel_cert_split.sql`
- 要求:可重复执行(UPDATE 仅作用于未迁移行);执行后附 `SELECT` 验证 both 人员是否仍缺押运/驾从业证
### 3.3 上线顺序
1. 执行迁移脚本 + 人工处理 both ambiguous 清单
2. 部署前后端新规则
3. 运营抽查列表徽章(身驾业押)与保存阻断
## 四、业务规则(保持不变部分)
- 人员主数据:按 `personnel_type` 校验必备(`both` 为 4 项)
- 山峡自有车关联人员:按关联行 `role` 校验 driver/escort 必备子集
- `both` 类型人员主数据仍须驾驶证,即使仅关联为 escort(沿用 2026-07-12 §3.2 说明)
- 承运商车辆不强制车辆证;人员主数据始终强制
## 五、验收标准
1. 新增驾驶员:预置 3 必备槽(身份证、驾驶证、驾驶员从业资格证);缺任一项无法保存。
2. 新增押运员:预置 2 必备槽(身份证、押运员从业资格证);列表徽章 **身、押**。
3. 新增兼岗:预置 4 必备槽;列表徽章 **身、驾、业、押**;缺押运员从业资格证无法保存。
4. 录入证书名称为「押运员证」时,满足押运员从业资格证必备槽(D4)。
5. 泛称「从业资格证」**不能**满足任一从业必备槽(迁移后)。
6. 车辆关联 escort role:校验身份证 + 押运员从业资格证(含押运员证别名)。
7. 前后端错误文案一致,含缺失字段(证号/有效期/附件)。
8. 迁移脚本执行后,`押运员证` 行已更名为 `押运员从业资格证`。
## 六、工作量估算
| 模块 | 预估 |
|------|------|
| 前后端 util/service 规则对齐 | 0.5 天 |
| 人员页下拉/槽位 + 徽章回归 | 0.25 天 |
| 迁移脚本 + both 人工清单 | 0.25 天 |
| 车辆页关联校验回归 + 自测 | 0.25 天 |
| **合计** | **约 1~1.25 人天** |
## 七、风险
| 风险 | 缓解 |
|------|------|
| both 人员仅有一条泛称从业证 | 迁移输出人工清单,不上线前不部署新规则 |
| 兼岗人员大量缺「押运员从业资格证」 | 迁移押运员证 + 运营补录 |
| 危化品从业资格证无法自动归类 | 本期不映射,运营手动改为驾驶员/押运员从业资格证 |
## 八、OpenSpec
建议在 `transport-plan-management` 或新 change 中增补 Scenario,**废止** 2026-07-12 的 D5/D6 表述,引用本设计 §1 矩阵。