docs: add implementation plan for driver transport history

Break down public history API, detail entry button, and list page;
keep existing driver feedback form unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-21 11:15:35 +08:00
1 parent ab46a8c999
commit abcdf793c4
1 file changed
+590
@@ -0,0 +1,590 @@
# 司机链接 · 运输记录 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:** 司机免登录页增加「运输记录」:按主车车牌聚合同租户计划,按取货/送货单日筛选(默认今天 + 今/明/后天快捷),点击进入对应司机详情;填写/回执 UI 与现状完全一致。
**Architecture:** 方案 1 — 用入口 `driver_access_token` 鉴权,解析主车 `license_plate` 后查询同车牌历史;列表项返回各单自有 token,前端跳转现有详情页;不改 feedback/attachments 契约。
**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Vant(公开 H5);pytest
**Spec:** `docs/superpowers/specs/2026-08-21-pl-driver-transport-history-design.md`
**Demo:** `docs/demo-driver-transport-history.html`
**约束:** 填写界面(产品回传数量、品牌芯片、附件、备注、保存回执)**不得改版**,仅在详情底部增加「运输记录」入口;归档/作废空状态文案保持现状。
---
## 文件结构
| 文件 | 职责 |
|------|------|
| `backend/app/schemas/pl/transport.py` | `PublicTransportHistoryRecordRead` / `PublicTransportHistoryPageRead` |
| `backend/app/api/pl/public_transport.py` | `GET /{token}/history`;车牌归一、日期命中查询、组装列表 |
| `backend/tests/test_pl_public_transport_history.py` | 纯函数/标志/日期解析单测 |
| `web-admin/src/api/pl/public-transport.ts` | `getHistory` + 类型 |
| `web-admin/src/router/routes/staticRoutes.ts` | 注册 `/public/pl/transport/:token/history` |
| `web-admin/src/views/public/pl/transport/index.vue` | 底部「运输记录」;voided/archived 也显示;error 不显示 |
| `web-admin/src/views/public/pl/transport/history.vue` | 新列表页(筛选 + 列表);点击跳转详情 |
加密排除:`/api/public/pl/transport` 已在 `API_ENCRYPT_EXCLUDE_PATHS` 前缀排除,无需改配置。
---
### Task 1: Schema + 日期/车牌辅助函数(TDD)
**Files:**
- Modify: `backend/app/schemas/pl/transport.py`
- Modify: `backend/app/api/pl/public_transport.py`(先加纯函数)
- Create: `backend/tests/test_pl_public_transport_history.py`
- [ ] **Step 1: 写失败单测**
```python
# backend/tests/test_pl_public_transport_history.py
from datetime import date, datetime
from app.api.pl.public_transport import (
_history_matches_date,
_normalize_main_plate,
_parse_history_date,
_history_status_label,
)
def test_normalize_main_plate_trims():
assert _normalize_main_plate(" 苏AD12345 ") == "苏AD12345"
assert _normalize_main_plate(None) == ""
assert _normalize_main_plate("") == ""
def test_parse_history_date_ok_and_bad():
assert _parse_history_date("2026-08-21") == date(2026, 8, 21)
assert _parse_history_date("2026/08/21") is None
assert _parse_history_date("") is None
def test_history_matches_date_or_logic():
day = date(2026, 8, 21)
assert _history_matches_date(datetime(2026, 8, 21, 9, 0), None, day) is True
assert _history_matches_date(None, datetime(2026, 8, 21, 18, 0), day) is True
assert _history_matches_date(
datetime(2026, 8, 20, 9, 0), datetime(2026, 8, 22, 18, 0), day
) is False
assert _history_matches_date(None, None, day) is False
def test_history_status_label():
assert _history_status_label(True, False) == "已作废"
assert _history_status_label(True, True) == "已作废"
assert _history_status_label(False, True) == "已归档"
assert _history_status_label(False, False) == "进行中"
```
- [ ] **Step 2: 跑测确认失败**
```bash
cd backend && source venv/bin/activate && pytest tests/test_pl_public_transport_history.py -v
```
Expected: FAIL(函数未定义)
- [ ] **Step 3: 实现辅助函数 + Schema**
在 `public_transport.py` 增加:
```python
from datetime import date as date_cls
def _normalize_main_plate(value: Optional[str]) -> str:
return (value or "").strip()
def _parse_history_date(raw: Optional[str]) -> Optional[date_cls]:
text = (raw or "").strip()
if not text:
return None
try:
return date_cls.fromisoformat(text)
except ValueError:
return None
def _as_date(value) -> Optional[date_cls]:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date_cls):
return value
return None
def _history_matches_date(pickup_time, delivery_time, day: date_cls) -> bool:
return _as_date(pickup_time) == day or _as_date(delivery_time) == day
def _history_status_label(plan_voided: bool, plan_archived: bool) -> str:
if plan_voided:
return "已作废"
if plan_archived:
return "已归档"
return "进行中"
```
在 `schemas/pl/transport.py` 的 `PublicTransportPageRead` 后追加:
```python
class PublicTransportHistoryRecordRead(BaseModel):
plan_id: str = Field(alias="planId")
plan_no: str = Field(alias="planNo")
transport_id: str = Field(alias="transportId")
driver_access_token: Optional[str] = Field(default=None, alias="driverAccessToken")
driver_url: Optional[str] = Field(default=None, alias="driverUrl")
license_plate: Optional[str] = Field(default=None, alias="licensePlate")
pickup_time: Optional[datetime] = Field(default=None, alias="pickupTime")
delivery_time: Optional[datetime] = Field(default=None, alias="deliveryTime")
pickup_address: Optional[str] = Field(default=None, alias="pickupAddress")
destination_address: Optional[str] = Field(default=None, alias="destinationAddress")
plan_voided: bool = Field(default=False, alias="planVoided")
plan_archived: bool = Field(default=False, alias="planArchived")
status_label: str = Field(default="进行中", alias="statusLabel")
model_config = {"populate_by_name": True}
class PublicTransportHistoryPageRead(BaseModel):
license_plate: Optional[str] = Field(default=None, alias="licensePlate")
date: str
records: list[PublicTransportHistoryRecordRead] = Field(default_factory=list)
model_config = {"populate_by_name": True}
```
- [ ] **Step 4: 再跑测**
```bash
cd backend && source venv/bin/activate && pytest tests/test_pl_public_transport_history.py -v
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add backend/tests/test_pl_public_transport_history.py backend/app/api/pl/public_transport.py backend/app/schemas/pl/transport.py
git commit -m "feat(pl): add transport history helpers and schemas"
```
---
### Task 2: 实现 `GET /{token}/history`
**Files:**
- Modify: `backend/app/api/pl/public_transport.py`
- [ ] **Step 1: 增加 `_build_driver_url` 与 history 端点**
在 `public_transport.py`(复用 config,与 `plan.py` 一致):
```python
from fastapi import Query
from sqlalchemy import or_, and_
from app.core.config import settings
from app.schemas.pl.transport import (
PublicTransportHistoryPageRead,
PublicTransportHistoryRecordRead,
# ...existing imports
)
def _build_driver_url(token: str) -> str:
origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/")
return f"{origin}/#/public/pl/transport/{token}"
@router.get("/{token}/history")
def get_public_transport_history(
token: str,
date: str = Query(..., description="筛选日 YYYY-MM-DD"),
db: Session = Depends(get_db),
):
transport = _get_transport_by_token(db, token)
day = _parse_history_date(date)
if day is None:
return error_response(400, "日期格式无效,请使用 YYYY-MM-DD")
plate = _normalize_main_plate(transport.license_plate)
if not plate:
page = PublicTransportHistoryPageRead(license_plate=None, date=day.isoformat(), records=[])
return success_response(page.model_dump(by_alias=True))
# 同租户、同主车车牌、已安排:经 pl_plan_transport 关联
rows = (
db.query(PlPlan, PlTransport)
.join(
PlPlanTransport,
and_(
PlPlanTransport.plan_id == PlPlan.id,
PlPlanTransport.del_flag == 0,
),
)
.join(
PlTransport,
and_(
PlTransport.id == PlPlanTransport.transport_id,
PlTransport.del_flag == 0,
),
)
.filter(
PlPlan.tenant_no == transport.tenant_no,
PlPlan.del_flag == 0,
PlPlan.transport_status == "arranged",
PlTransport.tenant_no == transport.tenant_no,
PlTransport.license_plate == plate,
or_(
and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day),
and_(PlPlan.delivery_time.isnot(None), func.date(PlPlan.delivery_time) == day),
),
)
.order_by(PlPlan.pickup_time.desc(), PlPlan.plan_date.desc(), PlPlan.create_time.desc())
.all()
)
# 去重:同一 plan 可能多条 link 时保留最新 transport(按查询顺序首次)
seen_plan: set[str] = set()
records: list[PublicTransportHistoryRecordRead] = []
for plan, tr in rows:
if plan.id in seen_plan:
continue
seen_plan.add(plan.id)
voided, archived = _resolve_driver_link_flags(plan, tr)
tok = (tr.driver_access_token or "").strip()
records.append(
PublicTransportHistoryRecordRead(
plan_id=plan.id,
plan_no=plan.plan_no or "",
transport_id=tr.id,
driver_access_token=tok or None,
driver_url=_build_driver_url(tok) if tok else None,
license_plate=tr.license_plate or plate,
pickup_time=plan.pickup_time,
delivery_time=plan.delivery_time,
pickup_address=plan.pickup_address,
destination_address=plan.destination_address or plan.destination_name,
plan_voided=voided,
plan_archived=archived,
status_label=_history_status_label(voided, archived),
)
)
page = PublicTransportHistoryPageRead(
license_plate=plate,
date=day.isoformat(),
records=records,
)
return success_response(page.model_dump(by_alias=True))
```
注意:文件顶部需 `from sqlalchemy import func`(若尚未导入)。路由须注册在 `/{token}/feedback` 等同级;FastAPI 中 `/{token}/history` 静态后缀与 `/{token}` 不冲突。
若 `PlTransport.license_plate` 偶发空但计划上有车牌:实现时在 filter 使用
`(PlTransport.license_plate == plate) | ((PlTransport.license_plate.is_(None) | (PlTransport.license_plate == "")) & (PlPlan.license_plate == plate))`
——仅当联调发现漏数时再加,首版以运输单车牌为准(与 Spec 一致)。
- [ ] **Step 2: 手工冒烟(有本地库时)**
```bash
# 用已知有效司机 token
curl -s "http://127.0.0.1:8000/api/public/pl/transport/<TOKEN>/history?date=$(date +%F)" | head
```
Expected: `code=0`,`data.records` 为数组。
- [ ] **Step 3: Commit**
```bash
git add backend/app/api/pl/public_transport.py
git commit -m "feat(pl): add public driver transport history API"
```
---
### Task 3: 前端 API 类型与 `getHistory`
**Files:**
- Modify: `web-admin/src/api/pl/public-transport.ts`
- [ ] **Step 1: 追加类型与方法**
```typescript
export interface PublicTransportHistoryRecord {
planId: string
planNo: string
transportId: string
driverAccessToken?: string
driverUrl?: string
licensePlate?: string
pickupTime?: string
deliveryTime?: string
pickupAddress?: string
destinationAddress?: string
planVoided?: boolean
planArchived?: boolean
statusLabel?: string
}
export interface PublicTransportHistoryPage {
licensePlate?: string
date: string
records: PublicTransportHistoryRecord[]
}
export const publicTransportApi = {
getPage: (token: string) => publicPlGet<PublicTransportPage>(`/public/pl/transport/${token}`),
getHistory: (token: string, date: string) =>
publicPlGet<PublicTransportHistoryPage>(`/public/pl/transport/${token}/history`, {
params: { date }
}),
// ...saveFeedback / uploadAttachment 保持不变
}
```
确认 `publicPlGet` 支持第二参 `params`(与项目其他 public API 一致);若不支持,改为
`` `/public/pl/transport/${token}/history?date=${encodeURIComponent(date)}` ``。
- [ ] **Step 2: Commit**
```bash
git add web-admin/src/api/pl/public-transport.ts
git commit -m "feat(pl): add public transport history API client"
```
---
### Task 4: 路由 + 详情页底部「运输记录」
**Files:**
- Modify: `web-admin/src/router/routes/staticRoutes.ts`
- Modify: `web-admin/src/views/public/pl/transport/index.vue`
- [ ] **Step 1: 注册 history 路由(放在详情路由旁)**
```typescript
{
path: '/public/pl/transport/:token/history',
name: 'PublicPlTransportHistory',
component: () => import('@views/public/pl/transport/history.vue'),
meta: { title: '运输记录', isHideTab: true, isPublicMobile: true }
},
{
path: '/public/pl/transport/:token',
name: 'PublicPlTransportDriver',
component: () => import('@views/public/pl/transport/index.vue'),
meta: { title: '运输计划', isHideTab: true, isPublicMobile: true }
},
```
先写 history 再写 `:token`,避免个别匹配器歧义(hash 模式下通常无妨,仍建议 history 在前)。
- [ ] **Step 2: 改详情页 footer(填写区逻辑不动)**
模板:
- `v-else-if="page?.planVoided"` / `planArchived` 空状态外层包一层,**空状态下方仍渲染 footer**:
```vue
<van-empty v-else-if="page?.planVoided" description="运输计划已作废" />
<van-empty v-else-if="page?.planArchived" description="运输计划已归档完成" />
<template v-else-if="page">
<!-- 现有取送货/产品/回执/备注 全部保持不动 -->
...
</template>
<footer
v-if="page && !errorMessage"
class="page-footer"
>
<div v-if="!page.planVoided && !page.planArchived" class="btn-row">
<van-button block round plain type="primary" @click="goHistory">运输记录</van-button>
<van-button type="primary" block round :loading="saving" @click="handleSave">
保存回执
</van-button>
</div>
<van-button
v-else
block
round
plain
type="primary"
@click="goHistory"
>
运输记录
</van-button>
</footer>
```
注意:原先 footer 在 `v-else-if="page"` 内部且 voided/archived 时不渲染;需把 footer **提到** 与 empty 同级,条件用 `page && !errorMessage`。`loading` 时不显示 footer。
```ts
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const token = computed(() => String(route.params.token || ''))
function goHistory() {
router.push({ name: 'PublicPlTransportHistory', params: { token: token.value } })
}
```
样式:`.btn-row { display:grid; grid-template-columns:1fr 1fr; gap:8px; }`(若尚无)。
**禁止**改动产品回传、BrandQtyChipsField、uploader、备注、`handleSave` 校验逻辑。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/router/routes/staticRoutes.ts web-admin/src/views/public/pl/transport/index.vue
git commit -m "feat(pl): add transport history entry on driver public page"
```
此时 `history.vue` 尚未创建会导致路由懒加载失败——若先提交,Task 5 必须紧接着完成;或本 Task 先放一个最小占位 `history.vue`(仅 nav + empty),Task 5 再补全。
**推荐:** Task 4 Step 2 同时创建占位:
```vue
<!-- web-admin/src/views/public/pl/transport/history.vue 占位 -->
<template>
<PublicMobileShell>
<van-nav-bar title="运输记录" left-arrow @click-left="$router.back()" />
<van-empty description="加载中…" />
</PublicMobileShell>
</template>
<script setup lang="ts">
import PublicMobileShell from '@/layouts/PublicMobileShell.vue'
</script>
```
---
### Task 5: 实现 `history.vue` 列表页
**Files:**
- Modify: `web-admin/src/views/public/pl/transport/history.vue`
- [ ] **Step 1: 完整实现列表(对齐 Demo 筛选行为)**
要点:
```ts
function formatYmd(d: Date) {
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${y}-${m}-${day}`
}
function addDays(base: Date, n: number) {
const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + n)
return d
}
const today = formatYmd(new Date())
const pickDate = ref(today)
async function load() {
loading.value = true
try {
const data = await publicTransportApi.getHistory(token.value, pickDate.value)
page.value = data
} catch (e: any) {
errorMessage.value = e?.message || '加载失败'
page.value = null
} finally {
loading.value = false
}
}
function setQuick(offset: 0 | 1 | 2) {
pickDate.value = formatYmd(addDays(new Date(), offset))
load()
}
function openRecord(row: PublicTransportHistoryRecord) {
const t = (row.driverAccessToken || '').trim()
if (!t) {
showFailToast('该记录暂无司机链接')
return
}
router.push({ name: 'PublicPlTransportDriver', params: { token: t } })
}
function goBack() {
router.push({ name: 'PublicPlTransportDriver', params: { token: token.value } })
}
```
UI:
- 顶栏:返回 → `goBack()`(回到进入列表时的 token 详情,不是 browser back,避免从 B 详情进列表再 back 到错误页)
- 快捷 chip:今天/明天/后天;与 `pickDate` 相等时高亮
- `<input type="date" v-model="pickDate" @change="load" />` 或 Vant 等价
- 卡片列表:`planNo`、`statusLabel` Tag、取货/送货日、线路(`pickupAddress → destinationAddress`)
- 无数据:`van-empty`「暂无运输记录」
样式参考 Demo / 现有 `transport/index.vue`(绿顶栏、inset card)。状态色:已作废红、已归档灰、进行中绿。
- [ ] **Step 2: 本地手动验收清单**
1. 打开有效司机链接 → 底部有「运输记录」+「保存回执」;填写区与改前一致。
2. 进列表默认今天;点明天/后天/改日期会刷新。
3. 点进行中记录 → 进入该单详情,可保存回执。
4. 归档/作废详情 → 仅空状态 +「运输记录」。
5. 无效 token → 无「运输记录」按钮。
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/views/public/pl/transport/history.vue
git commit -m "feat(pl): implement public driver transport history list page"
```
---
### Task 6: 图谱更新(若本会话改了代码)
**Files:** 无业务文件
- [ ] **Step 1:** 若实现阶段改了 backend/web-admin 代码,执行:
```bash
./scripts/update-graph.sh backend
# 或 frontend / all,按实际改动
```
- [ ] **Step 2:** 不必强制 commit 图谱;与项目惯例一致即可。
---
## Spec 覆盖自检
| Spec 项 | Task |
|---------|------|
| 主车车牌聚合 | Task 2 |
| 取货日 OR 送货日 = 单日 | Task 1–2 |
| 默认今天 + 今/明/后快捷 | Task 5 |
| 底部运输记录(含归档/作废) | Task 4 |
| 无「返回列表」 | Task 4–5 |
| 互通填写(跳转目标 token) | Task 5 `openRecord` |
| 填写 UI 与现状一致 | Task 4 明确禁止改表单 |
| 现场链接不改 | 无对应 Task(非目标) |
---
## 执行交接
Plan complete and saved to `docs/superpowers/plans/2026-08-21-pl-driver-transport-history.md`. Two execution options:
**1. Subagent-Driven (recommended)** — 每个 Task 开新子代理,Task 间复查
**2. Inline Execution** — 本会话按 executing-plans 连续做完
Which approach?