Przeglądaj źródła

docs(pl): 三方/山峡车辆人员分册与粘贴建档实现计划

按已批准设计拆为证书门禁、菜单路由、粘贴提交与承运商去准入等可执行任务。

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 3 miesięcy temu
rodzic
commit
db4907ec28

+ 888 - 0
docs/superpowers/plans/2026-07-25-pl-tripartite-vehicle-personnel.md

@@ -0,0 +1,888 @@
+# 三方/山峡车辆人员分册与粘贴建档 — 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:** 将基础信息库拆为「三方车辆 / 三方驾押人员 / 山峡自有车辆 / 山峡驾押人员」四菜单;三方不强制证书;提供粘贴识别建档;取消承运商审核准入门禁。
+
+**Architecture:** 列表页用固定过滤 + 路由 `meta.affiliationMode` 变体复用人员页;证书校验按 `vehicleType` / `personnelAffiliation` 分支;粘贴用前端规则解析 + 共享预览弹窗 + 后端批量提交(事务);承运商资格函数放宽为不再校验审核/资质/协议。
+
+**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus + TypeScript;MySQL 5.7;pytest
+
+**Spec:** `docs/superpowers/specs/2026-07-25-pl-tripartite-vehicle-personnel-design.md`
+
+---
+
+## 文件结构
+
+| 操作 | 文件 | 职责 |
+|------|------|------|
+| Modify | `backend/app/services/pl_cert_required_service.py` | 三方跳过证书校验;人员校验增加归属参数 |
+| Modify | `backend/app/api/pl/base_data.py` | 人员 create/update 传归属;承运商 approve/reject 停用;粘贴 commit 路由 |
+| Modify | `backend/app/services/pl_carrier_service.py` | `carrier_eligible` / `assert_*` 去掉审核/资质/协议门禁 |
+| Create | `backend/app/services/pl_paste_recognize_service.py` | 粘贴提交:冲突策略 + 事务写车/人/关联 |
+| Create | `backend/app/schemas/pl/paste_recognize.py` | 粘贴预览/提交 schema |
+| Create | `backend/tests/test_pl_tripartite_cert_gates.py` | 三方/山峡证书门禁单测 |
+| Create | `backend/tests/test_pl_carrier_eligible.py` | 承运商准入放宽单测 |
+| Create | `backend/tests/test_pl_paste_parse.py` | 解析纯函数单测(若解析放后端)或前端对应 vitest 可省略、用后端镜像 |
+| Create | `backend/scripts/migration_20260725_pl_tripartite_menus.sql` | 菜单改名/新增/排序 |
+| Modify | `backend/scripts/seed_sys_menu.sql` | 种子与迁移一致 |
+| Modify | `web-admin/src/router/modules/pl.ts` | 四菜单路由与标题 |
+| Modify | `web-admin/src/views/pl/vehicle/index.vue` | 固定三方;关证书强制;粘贴入口 |
+| Modify | `web-admin/src/views/pl/personnel/index.vue` | `affiliationMode`;山峡强制证书、三方不强制;粘贴入口 |
+| Create | `web-admin/src/utils/pl-paste-recognize.ts` | 客户文本规则解析 |
+| Create | `web-admin/src/components/business/pl-paste-recognize-dialog/index.vue` | 识别预览 + 冲突三选一 |
+| Modify | `web-admin/src/api/pl/index.ts` | `pasteRecognizeApi`;可保留但停用 carrier approve |
+| Modify | `web-admin/src/views/pl/carrier/index.vue` | 去掉审核 UI/操作 |
+| Modify | `web-admin/src/components/business/pl-carrier-view-dialog/index.vue` | 去掉审核状态展示 |
+| Modify | `web-admin/src/components/business/pl-plan-arrange-dialog/index.vue` | 去掉承运商拦截;停用 excludeIneligible |
+| Modify | `web-admin/src/components/business/pl-plan-form-dialog/index.vue` | 同上 excludeIneligible |
+| Modify | `web-admin/src/utils/pl-vehicle-cert.ts` / `pl-cert-slots.ts` | 三方不再 ensure 必备槽(随页面 prop) |
+
+---
+
+## 部署顺序
+
+```
+1. 执行 migration_20260725_pl_tripartite_menus.sql(菜单)
+2. 部署 backend(证书门禁 + 承运商准入 + 粘贴 API)
+3. 部署 web-admin
+4. 按 §验收 走查
+```
+
+---
+
+### Task 1: 后端 — 三方证书门禁单测(TDD)
+
+**Files:**
+- Create: `backend/tests/test_pl_tripartite_cert_gates.py`
+- Modify: `backend/app/services/pl_cert_required_service.py`
+
+- [ ] **Step 1: 编写失败测试**
+
+```python
+"""三方不强制证书;山峡仍强制。"""
+from __future__ import annotations
+
+import pytest
+from fastapi import HTTPException
+
+from app.services.pl_cert_required_service import (
+    validate_personnel_required_certs,
+    validate_vehicle_save_certs,
+)
+
+
+def test_carrier_personnel_skips_required_certs():
+    # 无证也应通过(新增 affiliation 参数后)
+    validate_personnel_required_certs(
+        [],
+        "driver",
+        personnel_affiliation="carrier",
+        subject_label="「张三」",
+    )
+
+
+def test_self_personnel_still_requires_certs():
+    with pytest.raises(HTTPException) as ei:
+        validate_personnel_required_certs(
+            [],
+            "driver",
+            personnel_affiliation="self",
+            subject_label="「李四」",
+        )
+    assert ei.value.status_code == 400
+    assert "必备证书" in ei.value.detail
+
+
+def test_validate_vehicle_save_certs_carrier_skips_vehicle_and_personnel_certs(monkeypatch):
+    """carrier 车:不跑车辆必备、不跑关联人员证书;仍可跑归属(此处 personnel_rows=None)。"""
+    called = {"self": 0, "carrier": 0, "links": 0}
+
+    import app.services.pl_cert_required_service as svc
+
+    monkeypatch.setattr(
+        svc,
+        "validate_self_vehicle_required_certs",
+        lambda *a, **k: called.__setitem__("self", called["self"] + 1),
+    )
+    monkeypatch.setattr(
+        svc,
+        "validate_carrier_vehicle_required_certs",
+        lambda *a, **k: called.__setitem__("carrier", called["carrier"] + 1),
+    )
+    monkeypatch.setattr(
+        svc,
+        "validate_vehicle_personnel_cert_links",
+        lambda *a, **k: called.__setitem__("links", called["links"] + 1),
+    )
+    monkeypatch.setattr(svc, "validate_vehicle_personnel_affiliation", lambda *a, **k: None)
+
+    validate_vehicle_save_certs(
+        db=None,  # affiliation/links 被 noop
+        tenant_no="t1",
+        vehicle_type="carrier",
+        trailer_no="皖LP359",
+        cert_rows=[],
+        personnel_rows=[],
+    )
+    assert called["carrier"] == 0
+    assert called["self"] == 0
+    assert called["links"] == 0
+```
+
+- [ ] **Step 2: 跑测确认失败**
+
+Run: `cd backend && source venv/bin/activate && pytest tests/test_pl_tripartite_cert_gates.py -v`  
+Expected: FAIL(`personnel_affiliation` 未定义或 carrier 仍校验)
+
+- [ ] **Step 3: 改 `pl_cert_required_service.py`**
+
+`validate_personnel_required_certs` 增加参数:
+
+```python
+def validate_personnel_required_certs(
+    cert_rows: Sequence[CertRow | dict],
+    personnel_type: str | None,
+    *,
+    personnel_affiliation: str | None = "self",
+    subject_label: str = "",
+) -> None:
+    affiliation = (personnel_affiliation or "self").strip()
+    if affiliation == VEHICLE_TYPE_CARRIER:
+        return
+    # ... 现有逻辑不变
+```
+
+`validate_vehicle_save_certs`:
+
+```python
+def validate_vehicle_save_certs(...):
+    vtype = (vehicle_type or "").strip()
+    if vtype == VEHICLE_TYPE_SELF:
+        validate_self_vehicle_required_certs(cert_rows, trailer_no=trailer_no)
+    # carrier:不再调用 validate_carrier_vehicle_required_certs
+    if vtype in (VEHICLE_TYPE_SELF, VEHICLE_TYPE_CARRIER) and personnel_rows:
+        validate_vehicle_personnel_affiliation(db, tenant_no, vtype, personnel_rows)
+        if vtype == VEHICLE_TYPE_SELF:
+            validate_vehicle_personnel_cert_links(db, tenant_no, personnel_rows)
+```
+
+- [ ] **Step 4: 改 `base_data.py` 人员 create/update**
+
+```python
+validate_personnel_required_certs(
+    payload.cert_list,
+    payload.personnel_type,
+    personnel_affiliation=payload.personnel_affiliation,
+    subject_label=f"「{payload.personnel_name}」",
+)
+```
+
+update 时:
+
+```python
+effective_affiliation = data.get(
+    "personnel_affiliation", personnel.personnel_affiliation
+)
+validate_personnel_required_certs(
+    effective_cert_rows,
+    effective_personnel_type,
+    personnel_affiliation=effective_affiliation,
+    subject_label=f"「{subject_name}」",
+)
+```
+
+- [ ] **Step 5: 跑测通过并提交**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_tripartite_cert_gates.py tests/test_pl_personnel_cert_required.py -v
+git add backend/app/services/pl_cert_required_service.py backend/app/api/pl/base_data.py backend/tests/test_pl_tripartite_cert_gates.py
+git commit -m "fix(pl): 三方车辆与人员保存跳过必备证书校验"
+```
+
+---
+
+### Task 2: 后端 — 承运商准入放宽
+
+**Files:**
+- Create: `backend/tests/test_pl_carrier_eligible.py`
+- Modify: `backend/app/services/pl_carrier_service.py`
+- Modify: `backend/app/api/pl/base_data.py`(approve/reject)
+- Modify: `web-admin` 调用点在后续 Task
+
+- [ ] **Step 1: 编写失败测试**
+
+```python
+from types import SimpleNamespace
+from app.services.pl_carrier_service import (
+    assert_carrier_eligible_for_transport,
+    carrier_eligible_for_transport,
+)
+
+
+def _carrier(**kwargs):
+    base = dict(
+        status="1",
+        audit_status="pending",
+        business_license_urls="",
+        road_transport_license_urls="",
+        transport_agreement_urls="",
+        transport_agreement_expire_date=None,
+    )
+    base.update(kwargs)
+    return SimpleNamespace(**base)
+
+
+def test_eligible_ignores_audit_qualification_agreement():
+    c = _carrier(audit_status="pending")
+    assert carrier_eligible_for_transport(c) is True
+    assert_carrier_eligible_for_transport(c)  # 不抛
+```
+
+- [ ] **Step 2: 跑测确认失败**
+
+Run: `pytest tests/test_pl_carrier_eligible.py -v`  
+Expected: FAIL
+
+- [ ] **Step 3: 改 `pl_carrier_service.py`**
+
+按产品确认:选择时不校验审核、资质、协议过期。`carrier_eligible_for_transport` 与 `assert_carrier_eligible_for_transport` **删除**对 `audit` / `qualification` / `agreement` 的判断。
+
+关于合作状态:规格「列表内均可选」——`assert` **不再**因 `status != "1"` 抛错(档案字段仍可维护)。若调用方列表仍传 `status=1` 仅为默认筛选项,保留无妨。
+
+```python
+def carrier_eligible_for_transport(carrier: PlCarrier, today: Optional[date] = None) -> bool:
+    return True
+
+
+def assert_carrier_eligible_for_transport(
+    carrier: PlCarrier,
+    *,
+    rearrange: bool = False,
+) -> None:
+    return
+```
+
+保留 `carrier_qualification_complete` / `carrier_agreement_expired` 供列表展示提示(非门禁)。
+
+- [ ] **Step 4: 停用审核 API**
+
+在 `base_data.py` 的 `approve_carrier` / `reject_carrier`:
+
+```python
+raise HTTPException(status_code=400, detail="承运商审核流程已停用")
+```
+
+创建承运商时仍可写 `audit_status="pending"`(兼容列),但业务不再依赖。更新资质时**删除**「已通过则打回 pending」逻辑(`qual_changed and audit_status == approved` 那段)。
+
+- [ ] **Step 5: 提交**
+
+```bash
+git add backend/app/services/pl_carrier_service.py backend/app/api/pl/base_data.py backend/tests/test_pl_carrier_eligible.py
+git commit -m "fix(pl): 取消承运商审核与资质协议准入门禁"
+```
+
+---
+
+### Task 3: 菜单迁移与路由四菜单
+
+**Files:**
+- Create: `backend/scripts/migration_20260725_pl_tripartite_menus.sql`
+- Modify: `backend/scripts/seed_sys_menu.sql`
+- Modify: `web-admin/src/router/modules/pl.ts`
+
+- [ ] **Step 1: 写迁移 SQL**
+
+```sql
+-- 三方车辆(原全部车辆信息)
+UPDATE sys_menu
+SET menu_name = '三方车辆', order_num = 2, remark = '承运商/三方车辆', update_time = NOW()
+WHERE id = 'PL_VEHICLE' AND del_flag = 0;
+
+-- 三方驾押人员(新建)
+INSERT INTO sys_menu (
+  id, menu_name, parent_id, menu_type, path, component, perms,
+  is_frame, visible, status, tenant_no, order_num, remark,
+  create_by, create_dept_id, create_time, update_by, update_time, del_flag
+) SELECT
+  'PL_PERSONNEL_CARRIER', '三方驾押人员', 'PL_GROUP_BASE', 'C',
+  'personnel-carrier', 'pl/personnel/index', 'pl:personnel-carrier:list',
+  1, '1', '1', 'shanxia2.0', 3, '非山峡驾押人员',
+  'system', '', NOW(), NULL, NOW(), 0
+FROM DUAL
+WHERE NOT EXISTS (
+  SELECT 1 FROM sys_menu WHERE id = 'PL_PERSONNEL_CARRIER' AND del_flag = 0
+);
+
+-- 山峡自有车辆
+UPDATE sys_menu
+SET order_num = 4, update_time = NOW()
+WHERE id = 'PL_SELF_VEHICLE' AND del_flag = 0;
+
+-- 山峡驾押人员(原驾押人员信息)
+UPDATE sys_menu
+SET menu_name = '山峡驾押人员', order_num = 5, update_time = NOW()
+WHERE id = 'PL_DRIVER' AND del_flag = 0;
+
+-- 运输地址库顺延
+UPDATE sys_menu
+SET order_num = 6, update_time = NOW()
+WHERE id = 'PL_TRANSPORT_ADDRESS' AND del_flag = 0;
+```
+
+将新菜单按需插入角色菜单关联(与现有 `PL_DRIVER` / `PL_VEHICLE` 同角色):实现时查 `sys_role_menu` 中含 `PL_DRIVER` 的 `role_id`,为每个 role 插入 `PL_PERSONNEL_CARRIER`(若不存在)。
+
+- [ ] **Step 2: 更新 `seed_sys_menu.sql` 对应行**(与迁移文案一致)
+
+- [ ] **Step 3: 改 `pl.ts` 基础信息库 children**
+
+```ts
+{
+  id: 'PL_VEHICLE',
+  name: 'PlVehicle',
+  path: 'vehicle',
+  component: '/pl/vehicle/index',
+  meta: {
+    title: '三方车辆',
+    keepAlive: true,
+    menuCode: 'pl:vehicle:list',
+    vehicleTypeLock: 'carrier'
+  }
+},
+{
+  id: 'PL_PERSONNEL_CARRIER',
+  name: 'PlPersonnelCarrier',
+  path: 'personnel-carrier',
+  component: '/pl/personnel/index',
+  meta: {
+    title: '三方驾押人员',
+    keepAlive: true,
+    menuCode: 'pl:personnel-carrier:list',
+    affiliationMode: 'carrier'
+  }
+},
+{
+  id: 'PL_SELF_VEHICLE',
+  // ...
+  meta: { title: '山峡自有车辆', keepAlive: true, menuCode: 'pl:self-vehicle:list' }
+},
+{
+  id: 'PL_DRIVER',
+  name: 'PlPersonnel',
+  path: 'personnel',
+  component: '/pl/personnel/index',
+  meta: {
+    title: '山峡驾押人员',
+    keepAlive: true,
+    menuCode: 'pl:driver:list',
+    affiliationMode: 'self'
+  }
+},
+```
+
+隐藏 redirect 的 `meta.title` 同步改名;新增 `personnel-carrier` redirect(若项目有旧路径兼容段)。
+
+- [ ] **Step 4: 本地执行迁移并提交**
+
+```bash
+# 在目标库执行 migration_20260725_pl_tripartite_menus.sql
+git add backend/scripts/migration_20260725_pl_tripartite_menus.sql backend/scripts/seed_sys_menu.sql web-admin/src/router/modules/pl.ts
+git commit -m "feat(pl): 基础信息库四菜单分册(三方/山峡)"
+```
+
+---
+
+### Task 4: 前端 — 三方车辆页固定 carrier + 关闭证书强制
+
+**Files:**
+- Modify: `web-admin/src/views/pl/vehicle/index.vue`
+
+- [ ] **Step 1: 列表默认且锁定 `vehicleType=carrier`**
+
+- `searchForm.vehicleType` 固定 `'carrier'`,筛选区隐藏「车辆类型」或禁用。
+- `fetchData` 始终传 `vehicleType: 'carrier'`。
+- 新增默认 `formData.vehicleType = 'carrier'`;弹窗隐藏类型切换;承运商仍必填。
+
+- [ ] **Step 2: 关闭证书强制**
+
+将:
+
+```vue
+:enforce-self-vehicle-required="formData.vehicleType === 'self'"
+:enforce-carrier-vehicle-required="formData.vehicleType === 'carrier'"
+```
+
+改为:
+
+```vue
+:enforce-self-vehicle-required="false"
+:enforce-carrier-vehicle-required="false"
+```
+
+(本页仅三方;山峡页 `self-vehicle` 不变。)
+
+保存前前端若有 `validateVehicleRequiredCerts` / carrier 槽位校验,三方路径直接跳过。
+
+- [ ] **Step 3: 标题文案**
+
+- `title-bar-heading` → `三方车辆`(或「三方车辆列表」,与规范竖条规则一致)。
+- 注释「全部车辆管理」改为「三方车辆」。
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add web-admin/src/views/pl/vehicle/index.vue
+git commit -m "feat(web-admin): 全部车辆页改为三方车辆并关闭证书强制"
+```
+
+---
+
+### Task 5: 前端 — 人员页 `affiliationMode`(山峡/三方)
+
+**Files:**
+- Modify: `web-admin/src/views/pl/personnel/index.vue`
+
+- [ ] **Step 1: 读取路由 meta**
+
+```ts
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+const affiliationMode = computed(() =>
+  (route.meta.affiliationMode as string) === 'carrier' ? 'carrier' : 'self'
+)
+const isCarrierPersonnel = computed(() => affiliationMode.value === 'carrier')
+```
+
+- [ ] **Step 2: 列表与表单锁定归属**
+
+- 列表:`fetchData` 固定 `personnelAffiliation: affiliationMode.value`;隐藏归属筛选项(或禁用)。
+- 新增/编辑:`formData.personnelAffiliation = affiliationMode.value`;归属表单项只读或隐藏。
+- 标题:`isCarrierPersonnel ? '三方驾押人员' : '山峡驾押人员'`(`title-bar-heading`)。
+
+- [ ] **Step 3: 证书强制按归属**
+
+```ts
+const requiredPersonnelCertNames = computed(() => {
+  if (isCarrierPersonnel.value) return []
+  return formData.personnelType
+    ? getRequiredPersonnelCertNamesByType(formData.personnelType)
+    : []
+})
+
+function validateCerts(): boolean {
+  if (!formData.personnelType) {
+    ElMessage.warning('请选择人员类型')
+    return false
+  }
+  if (isCarrierPersonnel.value) {
+    for (const cert of certList.value) {
+      if (!cert._required && !cert.certName?.trim()) {
+        ElMessage.warning('其他证书名称不能为空')
+        return false
+      }
+    }
+    return true
+  }
+  // 现有山峡必备校验...
+}
+```
+
+`mergePersonnelCertSlots` / `refreshRequiredSlots`:三方不注入必备槽(`required-cert-names=[]`)。
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add web-admin/src/views/pl/personnel/index.vue
+git commit -m "feat(web-admin): 驾押人员页按归属分册并仅山峡强制证书"
+```
+
+---
+
+### Task 6: 前端 — 承运商去审核 UI + 安排运输去拦截
+
+**Files:**
+- Modify: `web-admin/src/views/pl/carrier/index.vue`
+- Modify: `web-admin/src/components/business/pl-carrier-view-dialog/index.vue`
+- Modify: `web-admin/src/components/business/pl-plan-arrange-dialog/index.vue`
+- Modify: `web-admin/src/components/business/pl-plan-form-dialog/index.vue`
+- Modify: `web-admin/src/api/pl/index.ts`(可选:标注 approve 废弃)
+
+- [ ] **Step 1: 承运商列表**
+
+删除:审核状态筛选项、列表「审核状态」列、「审核通过」工具栏/查看操作、`canApprove` / `handleApprove` 相关逻辑。  
+保留:合作状态、资质附件、协议字段与「资质提示」**展示**(可选保留协议过期 Tag,仅提示不阻断)。
+
+- [ ] **Step 2: 查看弹窗**
+
+去掉「审核状态」字段;资质/协议可保留只读展示。
+
+- [ ] **Step 3: 安排运输**
+
+删除 `pl-plan-arrange-dialog` 中:
+
+```ts
+if (!carrier?.qualificationComplete) { ... }
+if (carrier.auditStatus !== 'approved') { ... }
+if (carrier.transportAgreementExpired) { ... }
+```
+
+并将
+
+```ts
+vehicleApi.list({ vehicleStatus: 'available', excludeIneligibleCarrier: true })
+```
+
+改为 `excludeIneligibleCarrier: false` 或不传(`pl-plan-form-dialog` 同步)。
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add web-admin/src/views/pl/carrier/index.vue \
+  web-admin/src/components/business/pl-carrier-view-dialog/index.vue \
+  web-admin/src/components/business/pl-plan-arrange-dialog/index.vue \
+  web-admin/src/components/business/pl-plan-form-dialog/index.vue
+git commit -m "feat(web-admin): 承运商去审核 UI 并移除安排运输准入拦截"
+```
+
+---
+
+### Task 7: 粘贴解析工具(纯函数 + 单测)
+
+**Files:**
+- Create: `web-admin/src/utils/pl-paste-recognize.ts`
+- Create: `backend/tests/test_pl_paste_text_parse.py`(后端镜像同一规则,供 commit 前校验可选)**或**仅前端;本计划前后端共用同一文本约定,后端 commit 信任预览 JSON。
+
+- [ ] **Step 1: 实现解析**
+
+```ts
+export interface PlPastePersonnelDraft {
+  personnelName: string
+  phoneNo?: string
+  idCardNo?: string
+  personnelType: 'driver' | 'escort'
+}
+
+export interface PlPasteVehicleDraft {
+  licensePlate: string
+  trailerNo?: string
+  loadCapacity?: number
+}
+
+export interface PlPasteRecognizeResult {
+  vehicle?: PlPasteVehicleDraft
+  driver?: PlPastePersonnelDraft
+  escort?: PlPastePersonnelDraft
+  warnings: string[]
+}
+
+const PLATE_LINE =
+  /^[\u4e00-\u9fa5A-Z]{1,2}[A-Z0-9]+(?:\s+[\u4e00-\u9fa5A-Z]{1,2}[A-Z0-9]+)?$/i
+
+export function parsePlCustomerVehicleText(raw: string): PlPasteRecognizeResult {
+  const warnings: string[] = []
+  const lines = raw
+    .split(/\r?\n/)
+    .map(l => l.trim())
+    .filter(Boolean)
+
+  let vehicle: PlPasteVehicleDraft | undefined
+  let driver: PlPastePersonnelDraft | undefined
+  let escort: PlPastePersonnelDraft | undefined
+  let section: 'none' | 'driver' | 'escort' = 'none'
+
+  for (const line of lines) {
+    if (!vehicle && PLATE_LINE.test(line.replace(/\s+/g, ' '))) {
+      const parts = line.split(/\s+/).filter(Boolean)
+      vehicle = {
+        licensePlate: parts[0] || '',
+        trailerNo: parts[1] || undefined
+      }
+      continue
+    }
+    const load = line.match(/核载[::]?\s*([\d.]+)\s*吨?/)
+    if (load && vehicle) {
+      vehicle.loadCapacity = Number(load[1])
+      continue
+    }
+    if (/^驾驶员[::]/.test(line)) {
+      section = 'driver'
+      driver = {
+        personnelName: line.replace(/^驾驶员[::]\s*/, '').trim(),
+        personnelType: 'driver'
+      }
+      continue
+    }
+    if (/^押运员[::]/.test(line)) {
+      section = 'escort'
+      escort = {
+        personnelName: line.replace(/^押运员[::]\s*/, '').trim(),
+        personnelType: 'escort'
+      }
+      continue
+    }
+    const phone = line.match(/^(?:手机号|电话)[::]?\s*(\d{11})$/)
+    if (phone && section === 'driver' && driver) {
+      driver.phoneNo = phone[1]
+      continue
+    }
+    if (phone && section === 'escort' && escort) {
+      escort.phoneNo = phone[1]
+      continue
+    }
+    const idCard = line.match(/^身份证[::]?\s*([0-9Xx]{15,18})$/)
+    if (idCard && section === 'driver' && driver) {
+      driver.idCardNo = idCard[1].toUpperCase()
+      continue
+    }
+    if (idCard && section === 'escort' && escort) {
+      escort.idCardNo = idCard[1].toUpperCase()
+      continue
+    }
+    warnings.push(`未识别行:${line}`)
+  }
+
+  return { vehicle, driver, escort, warnings }
+}
+```
+
+用文首示例手工断言(可在同文件底部注释期望,或加 `web-admin` 单测若项目已有 vitest;否则在 Task 9 手工验收)。
+
+- [ ] **Step 2: 提交**
+
+```bash
+git add web-admin/src/utils/pl-paste-recognize.ts
+git commit -m "feat(web-admin): 三方车辆客户文本粘贴解析工具"
+```
+
+---
+
+### Task 8: 后端 — 粘贴批量提交 API
+
+**Files:**
+- Create: `backend/app/schemas/pl/paste_recognize.py`
+- Create: `backend/app/services/pl_paste_recognize_service.py`
+- Modify: `backend/app/api/pl/base_data.py`(或新建 `paste_recognize.py` 并在 `app/api/__init__.py` + `main.py` 注册——若独立路由文件,按仓库惯例注册)
+
+- [ ] **Step 1: Schema**
+
+```python
+# backend/app/schemas/pl/paste_recognize.py
+from typing import Literal, Optional
+from pydantic import BaseModel, Field
+
+
+class ConflictAction(BaseModel):
+    key: Literal["vehicle", "driver", "escort"]
+    existing_id: str = Field(alias="existingId")
+    action: Literal["reuse", "overwrite", "cancel"]
+
+
+class PastePersonnelIn(BaseModel):
+    personnel_name: str = Field(alias="personnelName")
+    phone_no: Optional[str] = Field(default=None, alias="phoneNo")
+    id_card_no: Optional[str] = Field(default=None, alias="idCardNo")
+    personnel_type: Literal["driver", "escort"] = Field(alias="personnelType")
+
+    model_config = {"populate_by_name": True}
+
+
+class PasteVehicleIn(BaseModel):
+    license_plate: str = Field(alias="licensePlate")
+    trailer_no: Optional[str] = Field(default=None, alias="trailerNo")
+    load_capacity: Optional[float] = Field(default=None, alias="loadCapacity")
+    carrier_id: str = Field(alias="carrierId")
+    carrier_name: Optional[str] = Field(default=None, alias="carrierName")
+
+    model_config = {"populate_by_name": True}
+
+
+class PasteCommitBody(BaseModel):
+    mode: Literal["vehicle_bundle", "personnel_first"]
+    create_vehicle: bool = Field(default=True, alias="createVehicle")
+    vehicle: Optional[PasteVehicleIn] = None
+    driver: Optional[PastePersonnelIn] = None
+    escort: Optional[PastePersonnelIn] = None
+    conflicts: list[ConflictAction] = Field(default_factory=list)
+
+    model_config = {"populate_by_name": True}
+
+
+class PasteCommitResult(BaseModel):
+    vehicle_id: Optional[str] = Field(default=None, alias="vehicleId")
+    driver_id: Optional[str] = Field(default=None, alias="driverId")
+    escort_id: Optional[str] = Field(default=None, alias="escortId")
+
+    model_config = {"populate_by_name": True, "by_alias": True}
+```
+
+- [ ] **Step 2: 冲突探测接口(可选但推荐)**
+
+`POST /transport/paste-recognize/preview-conflicts`  
+入参:车牌、驾/押身份证 → 返回已存在 id 列表,供弹窗展示。
+
+或前端在确认前自行 `vehicleApi.list({ q: plate })` + `personnelApi.list` 查重(可接受)。**本计划采用前端查重 + 后端 commit 再校验**,避免半套状态。
+
+- [ ] **Step 3: Commit 服务要点**
+
+`commit_paste_recognize(db, tenant_no, user, body) -> PasteCommitResult`:
+
+1. 若任一 conflict.action == `cancel` → 400「已取消」。
+2. `personnel_first` 且 `create_vehicle=False`:只处理 driver/escort。
+3. 建车时 `carrier_id` 必填;`vehicle_type=carrier`;`cert_list=[]`;不跑证书校验。
+4. 建人:`personnel_affiliation=carrier`;`cert_list=[]`;跳过证书校验。
+5. `reuse`:用 existing_id;`overwrite`:更新姓名/手机/身份证/核载/挂车等预览字段。
+6. 车辆人员关联:driver → role=driver is_default=1;escort → role=escort。
+7. **整单事务**;异常 rollback。
+8. 车牌唯一、身份证查重:以 conflicts 为准;若未声明冲突但库中已存在 → 400 提示先处理冲突。
+
+- [ ] **Step 4: 注册路由**
+
+```python
+@router.post("/paste-recognize/commit", response_model=PasteCommitResult)
+def paste_recognize_commit(...):
+    ...
+```
+
+- [ ] **Step 5: 前端 API**
+
+```ts
+export const pasteRecognizeApi = {
+  commit: (data: PasteCommitBody) =>
+    request.post<PasteCommitResult>('/transport/paste-recognize/commit', data)
+}
+```
+
+- [ ] **Step 6: 提交**
+
+```bash
+git add backend/app/schemas/pl/paste_recognize.py \
+  backend/app/services/pl_paste_recognize_service.py \
+  backend/app/api/pl/base_data.py \
+  web-admin/src/api/pl/index.ts
+git commit -m "feat(pl): 三方粘贴识别批量提交接口"
+```
+
+---
+
+### Task 9: 前端 — `PlPasteRecognizeDialog` + 双入口
+
+**Files:**
+- Create: `web-admin/src/components/business/pl-paste-recognize-dialog/index.vue`
+- Modify: `web-admin/src/views/pl/vehicle/index.vue`
+- Modify: `web-admin/src/views/pl/personnel/index.vue`
+
+- [ ] **Step 1: 弹窗行为**
+
+Props:
+
+```ts
+mode: 'vehicle-bundle' | 'personnel-first'
+visible: boolean
+carrierOptions: Carrier[]
+```
+
+流程:
+
+1. 大文本框粘贴 →「识别」→ `parsePlCustomerVehicleText`。
+2. 表单展示车/驾/押可编辑字段 + 承运商 `el-select`(建车时必选)。
+3. `personnel-first`:`el-checkbox`「一并创建三方车辆并关联」**默认 true**(识别到车牌时启用;无车牌则禁用并 false)。
+4. 点确认前:按车牌/身份证查已有记录;有冲突则每条展示 Radio:复用 / 覆盖 / 取消。
+5. 组装 `PasteCommitBody` 调 `pasteRecognizeApi.commit`;成功提示并 `$emit('done')` 刷新列表。
+
+UI:`dialog-form-layout`、`label-position="top"`、底栏 Close/Check 图标;遮罩无 blur(沿用 `order-module-dialog-mask`)。
+
+- [ ] **Step 2: 三方车辆工具栏**
+
+在「已选 N 项」后增加主按钮旁的次要按钮:
+
+```vue
+<el-button @click="pasteVisible = true">
+  <el-icon><DocumentCopy /></el-icon>粘贴识别
+</el-button>
+```
+
+`mode="vehicle-bundle"`。
+
+- [ ] **Step 3: 三方人员工具栏**
+
+仅 `isCarrierPersonnel` 时显示同样按钮,`mode="personnel-first"`。
+
+- [ ] **Step 4: 提交**
+
+```bash
+git add web-admin/src/components/business/pl-paste-recognize-dialog/index.vue \
+  web-admin/src/views/pl/vehicle/index.vue \
+  web-admin/src/views/pl/personnel/index.vue
+git commit -m "feat(web-admin): 三方车辆/人员粘贴识别弹窗与入口"
+```
+
+---
+
+### Task 10: 回归验收与图谱更新
+
+- [ ] **Step 1: 按规格 §七验收**
+
+| # | 操作 | 期望 |
+|---|------|------|
+| 1 | 打开基础信息库 | 四菜单:三方车辆、三方驾押人员、山峡自有车辆、山峡驾押人员 |
+| 2 | 三方车辆无证保存 | 成功 |
+| 3 | 山峡自有车辆缺必备证保存 | 失败 |
+| 4 | 三方人员无证保存 | 成功 |
+| 5 | 山峡人员缺证保存 | 失败 |
+| 6 | 粘贴示例文本到三方车辆 | 生成车+驾+押;未选承运商不可确认 |
+| 7 | 粘贴到三方人员(勾选建车) | 人+车+关联;冲突三选一可用 |
+| 8 | 承运商页 | 无审核筛选/列/通过按钮 |
+| 9 | 安排运输选未审核承运商车辆 | 不因审核/资质/协议拦截 |
+| 10 | 三方车关联山峡人 | 归属不匹配报错 |
+
+- [ ] **Step 2: 自动化**
+
+```bash
+cd backend && source venv/bin/activate
+pytest tests/test_pl_tripartite_cert_gates.py tests/test_pl_carrier_eligible.py tests/test_pl_personnel_cert_required.py -v
+```
+
+- [ ] **Step 3: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+- [ ] **Step 4: 最终提交(若有遗漏)**
+
+```bash
+git add -A docs/superpowers/plans/2026-07-25-pl-tripartite-vehicle-personnel.md
+git status
+# 若有图谱变更一并提交
+git commit -m "docs(pl): 三方分册实现计划验收与图谱同步"
+```
+
+---
+
+## Spec 覆盖自检
+
+| 规格章节 | Task |
+|----------|------|
+| §1 四菜单分册 | Task 3, 4, 5 |
+| §2 证书规则 | Task 1, 4, 5 |
+| §3 粘贴识别 | Task 7, 8, 9 |
+| §4 承运商准入 | Task 2, 6 |
+| §5 错误/验收 | Task 8 冲突、Task 10 |
+| D10 归属匹配 | Task 1(保留 affiliation);Task 10#10 |
+
+## Placeholder 扫描
+
+无 TBD/TODO;冲突「取消」= 整单取消(commit 遇 cancel → 400)。
+
+---
+
+## 执行方式
+
+Plan complete and saved to `docs/superpowers/plans/2026-07-25-pl-tripartite-vehicle-personnel.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** — 每任务新开子代理,任务间复审,迭代快  
+
+**2. Inline Execution** — 本会话按 executing-plans 批量执行并设检查点  
+
+Which approach?