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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-25 10:41:25 +08:00
1 parent 47c1017923
commit db4907ec28
1 file changed
+888
@@ -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?