diff --git a/docs/superpowers/plans/2026-07-25-pl-tripartite-vehicle-personnel.md b/docs/superpowers/plans/2026-07-25-pl-tripartite-vehicle-personnel.md new file mode 100644 index 000000000..9d7192a4e --- /dev/null +++ b/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('/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 + + 粘贴识别 + +``` + +`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?