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

16 KiB
Raw Blame History

人员从业资格证拆分 — 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