|
|
@@ -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?
|