From abcdf793c4f9c2faae7bf1193eb597efd602c297 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 21 Aug 2026 11:15:35 +0800 Subject: [PATCH] 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 --- .../2026-08-21-pl-driver-transport-history.md | 590 ++++++++++++++++++ 1 file changed, 590 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-21-pl-driver-transport-history.md diff --git a/docs/superpowers/plans/2026-08-21-pl-driver-transport-history.md b/docs/superpowers/plans/2026-08-21-pl-driver-transport-history.md new file mode 100644 index 000000000..71e9dd04c --- /dev/null +++ b/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//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(`/public/pl/transport/${token}`), + getHistory: (token: string, date: string) => + publicPlGet(`/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 + + + + +
+
+ 运输记录 + + 保存回执 + +
+ + 运输记录 + +
+``` + +注意:原先 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 + + + +``` + +--- + +### 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` 相等时高亮 +- `` 或 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?