فهرست منبع

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>
zlwu 2 ماه پیش
والد
کامیت
abcdf793c4
1فایلهای تغییر یافته به همراه590 افزوده شده و 0 حذف شده
  1. 590 0
      docs/superpowers/plans/2026-08-21-pl-driver-transport-history.md

+ 590 - 0
docs/superpowers/plans/2026-08-21-pl-driver-transport-history.md

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