docs(pl): 三方/山峡车辆人员分册与粘贴建档实现计划
按已批准设计拆为证书门禁、菜单路由、粘贴提交与承运商去准入等可执行任务。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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?
|
||||
Reference in new issue
Block a user