Jelajahi Sumber

docs: add pound QR public link implementation plan

Break the approved design into backend, shared QR view, public page,
copy-to-driver text, and driver footer tasks with TDD steps.

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong 2 bulan lalu
induk
melakukan
8994849292
1 mengubah file dengan 865 tambahan dan 0 penghapusan
  1. 865 0
      docs/superpowers/plans/2026-09-13-pound-qrcode-public-link.md

+ 865 - 0
docs/superpowers/plans/2026-09-13-pound-qrcode-public-link.md

@@ -0,0 +1,865 @@
+# 磅单二维码公开链接与司机页入口 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:** 复制给司机文案追加磅单二维码公开链接;独立公共页按出入库单 id 展示与管理端同款磅单二维码;司机填写页底栏增加「磅单二维码」按钮(多单先选再弹)。
+
+**Architecture:** 抽出 `PoundSlipQrcodeView` 供管理端弹窗 / 公共页 / 司机弹层共用;新增免登 `GET /api/public/db/inventory/{id}/pound-qr`;司机页 payload 增加 `poundQrcodeItems`;`buildDriverSummaryFromPlan` 追加多行链接块。
+
+**Tech Stack:** FastAPI · SQLAlchemy · Pydantic v2 · Vue 3 · Element Plus · Vant · pytest
+
+**Spec:** `docs/superpowers/specs/2026-09-13-pound-qrcode-public-link-design.md`
+
+**提交约定:** 默认每个 Task 末尾 commit;若用户要求暂不提交,可攒到全部完成后再提交。
+
+---
+
+## 文件地图
+
+| 职责 | 路径 |
+|------|------|
+| 公开磅单 QR Schema | Modify `backend/app/schemas/db/public_inventory.py` |
+| 公开磅单 QR API | Modify `backend/app/api/db/public_inventory.py` |
+| 标签/装配 helper | Create `backend/app/services/db/pound_qr_public.py` |
+| 公开 API 单测 | Create `backend/tests/test_public_db_pound_qr.py` |
+| 库存关联读模型扩展 | Modify `backend/app/schemas/pl/transport.py`(`TransportInventoryLinkRead`、`PublicTransportPageRead`、新建 `PublicPoundQrcodeItemRead`) |
+| 关联读构建 | Modify `backend/app/services/pl_inventory_link_read.py` |
+| arrange-info 填字段 | Modify `backend/app/api/pl/plan.py`、`backend/app/api/pl/transport.py`(所有 `build_transport_inventory_link_read` 调用处补 business/partner) |
+| 司机页填 items | Modify `backend/app/api/pl/public_transport.py` |
+| 纯展示组件 | Create `web-admin/src/components/business/db/PoundSlipQrcodeView.vue` |
+| 管理端弹窗改用 View | Modify `web-admin/src/components/business/db/DbPoundSlipQrcodeDialog.vue` |
+| 公开 API 客户端 | Modify `web-admin/src/api/db/public-inventory.ts` |
+| 公共页 | Create `web-admin/src/views/public/pl/pound-qrcode/index.vue` |
+| 静态路由 | Modify `web-admin/src/router/routes/staticRoutes.ts` |
+| 复制文案 | Modify `web-admin/src/utils/pl-driver-summary.ts` |
+| 复制入口 | Modify `web-admin/src/composables/usePlPlanArrangeActions.ts`、`web-admin/src/components/business/pl-plan-view-tabs/index.vue` |
+| FE 类型 | Modify `web-admin/src/api/pl/index.ts`、`web-admin/src/api/pl/public-transport.ts` |
+| 司机页 UI | Modify `web-admin/src/views/public/pl/transport/index.vue` |
+
+**不做:** token 化、限流、小程序、现场页底栏、改「复制磅单二维码」图片复制语义。
+
+---
+
+### Task 1: 后端公开 pound-qr Schema + helper + API + 单测
+
+**Files:**
+- Modify: `backend/app/schemas/db/public_inventory.py`
+- Create: `backend/app/services/db/pound_qr_public.py`
+- Modify: `backend/app/api/db/public_inventory.py`
+- Create: `backend/tests/test_public_db_pound_qr.py`
+
+- [ ] **Step 1: 写失败单测(helper + 404 路由)**
+
+创建 `backend/tests/test_public_db_pound_qr.py`:
+
+```python
+"""公开磅单二维码只读接口。"""
+from datetime import date
+from types import SimpleNamespace
+
+from fastapi.testclient import TestClient
+
+from app.main import app
+from app.services.db.pound_qr_public import (
+    build_pound_qr_label,
+    build_public_pound_qr_read,
+)
+
+client = TestClient(app)
+
+
+def test_build_pound_qr_label():
+    assert build_pound_qr_label("出库", "合肥德泽") == "出库 · 合肥德泽"
+    assert build_pound_qr_label("入库", "") == "入库"
+    assert build_pound_qr_label("", "安庆山峡") == "安庆山峡"
+
+
+def test_build_public_pound_qr_read_maps_fields():
+    row = SimpleNamespace(
+        id="inv-1",
+        date=date(2026, 9, 12),
+        business_type="出库",
+        vehicle_no="皖AM8516",
+        partner_company="合肥德泽化工科技有限公司",
+        product="30% 工业硫酸",
+        specification="30%",
+        quantity=10.0,
+        remark="注意安全",
+    )
+    read = build_public_pound_qr_read(row, quantity_unit="吨")
+    dump = read.model_dump(by_alias=True)
+    assert dump["id"] == "inv-1"
+    assert dump["date"] == "2026-09-12"
+    assert dump["businessType"] == "出库"
+    assert dump["vehicleNo"] == "皖AM8516"
+    assert dump["partnerCompany"] == "合肥德泽化工科技有限公司"
+    assert dump["product"] == "30% 工业硫酸"
+    assert dump["specification"] == "30%"
+    assert dump["quantity"] == 10.0
+    assert dump["quantityUnit"] == "吨"
+    assert dump["remarks"] == "注意安全"
+
+
+def test_pound_qr_missing_returns_404():
+    resp = client.get("/api/public/db/inventory/__missing_pound_qr_id__/pound-qr")
+    assert resp.status_code in (404, 200)
+    if resp.status_code == 200:
+        body = resp.json()
+        # 项目 success_response 包装:业务码非 0 或 data 为空视为失败
+        assert body.get("code") not in (0, 200, "0", "200") or body.get("data") is None
+```
+
+说明:若项目 404 以 HTTP 404 抛出则断言 `status_code == 404`;若以 `success_response`/`error_response` 包一层,按仓库现有公开接口惯例调整断言(对照 `public_inventory.get` 与 `HTTPException`)。**实现时以 `HTTPException(404)` 为准,与 `_get_inventory_by_token` 一致。**
+
+- [ ] **Step 2: 运行确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_public_db_pound_qr.py -v
+```
+
+Expected: FAIL(`pound_qr_public` 或 schema 未定义)
+
+- [ ] **Step 3: 实现 Schema**
+
+在 `backend/app/schemas/db/public_inventory.py` 追加:
+
+```python
+class PublicPoundQrRead(BaseModel):
+    id: str
+    date: Optional[str] = None
+    business_type: str = Field(alias="businessType")
+    vehicle_no: str = Field(alias="vehicleNo")
+    partner_company: str = Field(alias="partnerCompany")
+    product: str
+    specification: Optional[str] = None
+    quantity: float
+    quantity_unit: str = Field(default="吨", alias="quantityUnit")
+    remarks: Optional[str] = None
+    model_config = {"populate_by_name": True}
+```
+
+- [ ] **Step 4: 实现 helper**
+
+创建 `backend/app/services/db/pound_qr_public.py`:
+
+```python
+from __future__ import annotations
+
+from typing import Any, Optional
+
+from app.schemas.db.public_inventory import PublicPoundQrRead
+from app.services.db.mappers import inventory_date_to_api
+
+
+def build_pound_qr_label(business_type: str, partner_company: str) -> str:
+    bt = (business_type or "").strip()
+    pc = (partner_company or "").strip()
+    if bt and pc:
+        return f"{bt} · {pc}"
+    return bt or pc or "磅单"
+
+
+def build_public_pound_qr_read(row: Any, *, quantity_unit: Optional[str] = None) -> PublicPoundQrRead:
+    unit = (quantity_unit or "").strip() or "吨"
+    return PublicPoundQrRead(
+        id=row.id,
+        date=inventory_date_to_api(row.date) or None,
+        business_type=(row.business_type or "").strip() or "",
+        vehicle_no=(row.vehicle_no or "").strip(),
+        partner_company=(row.partner_company or "").strip(),
+        product=(row.product or "").strip(),
+        specification=(row.specification or None),
+        quantity=float(row.quantity or 0),
+        quantity_unit=unit,
+        remarks=(row.remark or None) if getattr(row, "remark", None) else None,
+    )
+```
+
+- [ ] **Step 5: 实现 API 路由**
+
+在 `backend/app/api/db/public_inventory.py`:
+
+1. import `PublicPoundQrRead`、`build_public_pound_qr_read`、`DbInboundOutbound`(已有)。
+2. **在** `@router.get("/{token}")` **之前**注册:
+
+```python
+@router.get("/{inventory_id}/pound-qr")
+def get_public_pound_qr(inventory_id: str, db: Session = Depends(get_db)):
+    row = (
+        db.query(DbInboundOutbound)
+        .filter(DbInboundOutbound.id == inventory_id, DbInboundOutbound.del_flag == 0)
+        .first()
+    )
+    if not row:
+        raise HTTPException(status_code=404, detail="链接无效或单据不存在")
+    read = build_public_pound_qr_read(row, quantity_unit="吨")
+    return success_response(read.model_dump(by_alias=True))
+```
+
+- [ ] **Step 6: 跑通单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_public_db_pound_qr.py -v
+```
+
+Expected: PASS(404 用例按 Step 1 最终断言)
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add backend/app/schemas/db/public_inventory.py \
+  backend/app/services/db/pound_qr_public.py \
+  backend/app/api/db/public_inventory.py \
+  backend/tests/test_public_db_pound_qr.py
+git commit -m "$(cat <<'EOF'
+feat(backend): add public pound-qr endpoint by inventory id
+
+Expose a read-only payload for the shared weighbridge QR card without auth.
+EOF
+)"
+```
+
+---
+
+### Task 2: 扩展 inventory link 与司机页 poundQrcodeItems
+
+**Files:**
+- Modify: `backend/app/schemas/pl/transport.py`
+- Modify: `backend/app/services/pl_inventory_link_read.py`
+- Modify: `backend/app/api/pl/plan.py`(arrange-info 组装循环)
+- Modify: `backend/app/api/pl/transport.py`(arrange 结果组装,凡调用 `build_transport_inventory_link_read` 处)
+- Modify: `backend/app/api/pl/public_transport.py`
+- Create: `backend/tests/test_pound_qr_label_items.py`(纯函数测 label;items 装配可测 helper)
+
+- [ ] **Step 1: 扩展 Schema**
+
+`TransportInventoryLinkRead` 增加可选字段:
+
+```python
+business_type: Optional[str] = Field(default=None, alias="businessType")
+partner_company: Optional[str] = Field(default=None, alias="partnerCompany")
+```
+
+新增:
+
+```python
+class PublicPoundQrcodeItemRead(BaseModel):
+    inventory_id: str = Field(alias="inventoryId")
+    business_type: str = Field(alias="businessType")
+    partner_company: str = Field(alias="partnerCompany")
+    label: str
+    model_config = {"populate_by_name": True}
+```
+
+`PublicTransportPageRead` 增加:
+
+```python
+pound_qrcode_items: list[PublicPoundQrcodeItemRead] = Field(
+    default_factory=list, alias="poundQrcodeItems"
+)
+```
+
+- [ ] **Step 2: 更新 `build_transport_inventory_link_read`**
+
+```python
+def build_transport_inventory_link_read(
+    db,
+    *,
+    plan_product_id: str,
+    product_name: str,
+    db_purchase_plan_id: str,
+    db_inventory_id: str,
+    batch_no: str,
+    plan_no: str,
+    stop_type: str | None = None,
+    stop_id: str | None = None,
+    business_type: str | None = None,
+    partner_company: str | None = None,
+) -> TransportInventoryLinkRead:
+    del db
+    return TransportInventoryLinkRead(
+        plan_product_id=plan_product_id,
+        product_name=product_name,
+        db_purchase_plan_id=db_purchase_plan_id,
+        db_inventory_id=db_inventory_id,
+        batch_no=batch_no,
+        plan_no=plan_no,
+        stop_type=stop_type,
+        stop_id=stop_id,
+        business_type=business_type,
+        partner_company=partner_company,
+    )
+```
+
+所有调用处在已有 `inventory` 对象时传入:
+
+```python
+business_type=(inventory.business_type if inventory else None),
+partner_company=(inventory.partner_company if inventory else None),
+```
+
+(`plan.py` arrange-info 循环已查 `inventory`;`transport.py` 同样在有 inventory 时补齐。用 `rg "build_transport_inventory_link_read"` 扫全仓改全。)
+
+- [ ] **Step 3: 在 `pound_qr_public.py` 增加司机 items 装配**
+
+```python
+def build_pound_qrcode_items_for_plan(
+    db,
+    *,
+    tenant_no: str,
+    plan_id: str,
+    transport_id: str,
+) -> list:
+    from app.models.db_models import DbInboundOutbound
+    from app.models.pl_models import PlTransportDbLink
+    from app.schemas.pl.transport import PublicPoundQrcodeItemRead
+
+    links = (
+        db.query(PlTransportDbLink)
+        .filter(
+            PlTransportDbLink.tenant_no == tenant_no,
+            PlTransportDbLink.plan_id == plan_id,
+            PlTransportDbLink.transport_id == transport_id,
+            PlTransportDbLink.del_flag == 0,
+        )
+        .all()
+    )
+    items = []
+    seen = set()
+    for link in links:
+        inv_id = (link.db_inventory_id or "").strip()
+        if not inv_id or inv_id in seen:
+            continue
+        if not (link.db_purchase_plan_id or "").strip():
+            continue
+        inv = (
+            db.query(DbInboundOutbound)
+            .filter(
+                DbInboundOutbound.id == inv_id,
+                DbInboundOutbound.tenant_no == tenant_no,
+                DbInboundOutbound.del_flag == 0,
+            )
+            .first()
+        )
+        if not inv:
+            continue
+        seen.add(inv_id)
+        bt = (inv.business_type or "").strip()
+        pc = (inv.partner_company or "").strip()
+        items.append(
+            PublicPoundQrcodeItemRead(
+                inventory_id=inv.id,
+                business_type=bt,
+                partner_company=pc,
+                label=build_pound_qr_label(bt, pc),
+            )
+        )
+    return items
+```
+
+说明:`db_purchase_plan_id` 非空与前端 `dibangInventoryRecords` 过滤一致(勾选生成地磅单)。
+
+- [ ] **Step 4: `public_transport._build_public_page` 填入 items**
+
+在构造 `PublicTransportPageRead(...)` 前:
+
+```python
+pound_items = []
+if plan:
+    from app.services.db.pound_qr_public import build_pound_qrcode_items_for_plan
+    pound_items = build_pound_qrcode_items_for_plan(
+        db,
+        tenant_no=transport.tenant_no,
+        plan_id=plan.id,
+        transport_id=transport.id,
+    )
+```
+
+并传入 `pound_qrcode_items=pound_items`。
+
+- [ ] **Step 5: 单测 label + purchase_plan 过滤逻辑(可抽纯函数测 seen/filter)**
+
+在 `backend/tests/test_pound_qr_label_items.py`:
+
+```python
+from app.services.db.pound_qr_public import build_pound_qr_label
+
+def test_label_join():
+    assert " · " in build_pound_qr_label("出库", "A")
+```
+
+(items 装配依赖 DB,本 Task 以手工联调 + Task 1 风格为主;若有现成 DB fixture 可补集成测,无则跳过。)
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/schemas/pl/transport.py \
+  backend/app/services/pl_inventory_link_read.py \
+  backend/app/services/db/pound_qr_public.py \
+  backend/app/api/pl/plan.py \
+  backend/app/api/pl/transport.py \
+  backend/app/api/pl/public_transport.py \
+  backend/tests/test_pound_qr_label_items.py
+git commit -m "$(cat <<'EOF'
+feat(backend): expose pound QR items on driver transport page
+
+Enrich inventory links with business type/partner and attach
+poundQrcodeItems for the public driver footer button.
+EOF
+)"
+```
+
+---
+
+### Task 3: 抽出 `PoundSlipQrcodeView` 并改管理端弹窗
+
+**Files:**
+- Create: `web-admin/src/components/business/db/PoundSlipQrcodeView.vue`
+- Modify: `web-admin/src/components/business/db/DbPoundSlipQrcodeDialog.vue`
+
+- [ ] **Step 1: 新建 View 组件**
+
+`PoundSlipQrcodeView.vue` props:
+
+```ts
+defineProps<{
+  vehicleNo: string
+  partnerCompany: string
+  productLabel: string  // 已拼好「名称(规格)(数量 单位)」或按行拆分
+  quantityText: string  // 若拆分:数量展示
+  remarks?: string
+  dateText: string
+  idLabelPrefix: string  // 「出库单ID:」或「入库单ID:」
+  inventoryId: string
+  qrObjectUrl: string
+}>()
+```
+
+模板结构从现有 Dialog 的 `.pound-slip-capture-inline` 内两张 `capture-card` 原样搬出(车牌 / 往来单位 / 物料 / 备注 / id+二维码+说明)。**不**包含 `el-dialog` / loading。
+
+更稳妥的 props 对齐现弹窗字段拆分:
+
+```ts
+{
+  vehicleNo: string
+  partnerCompany: string
+  productLine: string   // formatQrcodeProductLabel + (qty unit)
+  remarks?: string
+  dateText: string
+  businessType: string  // 入库|出库
+  inventoryId: string
+  qrObjectUrl: string
+}
+```
+
+物料行内部:`{{ productLine }}`;id 行:`{{ dateText }} {{ businessType === '出库' ? '出库单ID:' : '入库单ID:' }}{{ inventoryId }}`。
+
+- [ ] **Step 2: Dialog 改为加载后渲染 View**
+
+`DbPoundSlipQrcodeDialog.vue`:
+
+- 保留 `loadQrcode` / `copyQrcodeImage` / `inventoryApi.get`
+- template body 改为:
+
+```vue
+<div v-loading="loading" class="db-qrcode-body">
+  <PoundSlipQrcodeView
+    v-if="captureRow && captureQrObjectUrl"
+    :vehicle-no="captureRow.vehicleNo"
+    :partner-company="captureRow.partnerCompany"
+    :product-line="formatProductLine(captureRow)"
+    :remarks="captureRow.remarks || ''"
+    :date-text="captureRow.date"
+    :business-type="captureRow.businessType"
+    :inventory-id="captureRow.id"
+    :qr-object-url="captureQrObjectUrl"
+  />
+</div>
+```
+
+`formatProductLine` = 现 `formatQrcodeProductLabel` + `(${quantity} ${quantityUnitLabel})`。
+
+- [ ] **Step 3: 本地冒烟**
+
+管理端打开任一带地磅单的计划 →「复制磅单二维码」→ 弹窗样式与改前一致,复制图片仍成功。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add web-admin/src/components/business/db/PoundSlipQrcodeView.vue \
+  web-admin/src/components/business/db/DbPoundSlipQrcodeDialog.vue
+git commit -m "$(cat <<'EOF'
+refactor(web-admin): extract PoundSlipQrcodeView for shared QR card
+
+Keep admin dialog behavior while enabling public page and driver popup reuse.
+EOF
+)"
+```
+
+---
+
+### Task 4: 公共页路由 + API 客户端 + 页面
+
+**Files:**
+- Modify: `web-admin/src/api/db/public-inventory.ts`
+- Create: `web-admin/src/views/public/pl/pound-qrcode/index.vue`
+- Modify: `web-admin/src/router/routes/staticRoutes.ts`
+
+- [ ] **Step 1: API 客户端**
+
+在 `public-inventory.ts` 增加类型与方法:
+
+```ts
+export interface PublicPoundQr {
+  id: string
+  date?: string
+  businessType: string
+  vehicleNo: string
+  partnerCompany: string
+  product: string
+  specification?: string
+  quantity: number
+  quantityUnit?: string
+  remarks?: string
+}
+
+// publicInventoryApi 或独立 export:
+getPoundQr: (inventoryId: string) =>
+  publicPlGet<PublicPoundQr>(`/public/db/inventory/${inventoryId}/pound-qr`),
+```
+
+二维码图 URL 生成与 Dialog 相同:
+
+```ts
+`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(id)}`
+```
+
+- [ ] **Step 2: 静态路由**
+
+在 `/public/db/inventory/:token` **之前**插入(避免被其它规则影响;与 catch-all 无关):
+
+```ts
+{
+  path: '/public/pl/pound-qrcode/:inventoryId',
+  name: 'PublicPlPoundQrcode',
+  component: () => import('@views/public/pl/pound-qrcode/index.vue'),
+  meta: { title: '磅单二维码', isHideTab: true, isPublicMobile: true }
+},
+```
+
+- [ ] **Step 3: 公共页**
+
+`views/public/pl/pound-qrcode/index.vue`:
+
+- `PublicMobileShell` + 深色背景容器(对齐 `.db-qrcode-overlay`:`background: rgba(0,0,0,0.92)`)
+- 标题:加载后按 `businessType` 显示「出库磅单二维码」/「入库磅单二维码」
+- `onMounted`:读 `route.params.inventoryId` → `getPoundQr` → fetch 二维码 blob → `PoundSlipQrcodeView`
+- 失败:`van-empty` description=`链接无效或单据不存在` 或接口 message
+- 引入 `@/styles/db-module.scss` 中磅单相关类(若公共页未全局引入,在本页 `import '@/styles/db-module.scss'` 或仅依赖已有全局;检查 `main.ts` / 布局是否已引入 `db-module.scss`,没有则本页 import)
+
+产品行拼接逻辑复制 Dialog 的 `formatQrcodeProductLabel`。
+
+- [ ] **Step 4: 手工打开**
+
+`/#/public/pl/pound-qrcode/{真实出入库单id}` 应显示黄牌卡。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/api/db/public-inventory.ts \
+  web-admin/src/views/public/pl/pound-qrcode/index.vue \
+  web-admin/src/router/routes/staticRoutes.ts
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add public pound QR page by inventory id
+
+Drivers can open a standalone link that renders the shared weighbridge QR card.
+EOF
+)"
+```
+
+---
+
+### Task 5: 「复制给司机」文案追加磅单链接
+
+**Files:**
+- Modify: `web-admin/src/utils/pl-driver-summary.ts`
+- Modify: `web-admin/src/api/pl/index.ts`(`TransportInventoryLink` 类型加 `businessType?` / `partnerCompany?`)
+- Modify: `web-admin/src/composables/usePlPlanArrangeActions.ts`
+- Modify: `web-admin/src/components/business/pl-plan-view-tabs/index.vue`
+
+- [ ] **Step 1: 扩展 `buildDriverSummaryFromPlan`**
+
+在 options 增加:
+
+```ts
+poundQrcodeLinks?: { label: string; url: string }[]
+```
+
+在 `lines.push(\`司机链接:...\`)` **之后**:
+
+```ts
+const links = (options.poundQrcodeLinks || []).filter((x) => x?.url)
+if (links.length) {
+  lines.push('')
+  lines.push('磅单二维码链接:')
+  for (const item of links) {
+    lines.push(`${item.label}:${item.url}`)
+  }
+}
+```
+
+- [ ] **Step 2: 小工具函数(可放同文件或 composable)**
+
+```ts
+export function buildPoundQrcodePublicUrl(inventoryId: string, origin = window.location.origin) {
+  return `${origin}/#/public/pl/pound-qrcode/${encodeURIComponent(inventoryId)}`
+}
+
+export function buildPoundQrcodeLinksFromInventoryRecords(
+  records: { dbInventoryId: string; dbPurchasePlanId?: string; businessType?: string; partnerCompany?: string }[],
+  origin = window.location.origin
+) {
+  return records
+    .filter((r) => r.dbPurchasePlanId?.trim() && r.dbInventoryId?.trim())
+    .map((r) => {
+      const bt = (r.businessType || '').trim()
+      const pc = (r.partnerCompany || '').trim()
+      const label = bt && pc ? `${bt} · ${pc}` : bt || pc || '磅单'
+      return { label, url: buildPoundQrcodePublicUrl(r.dbInventoryId, origin) }
+    })
+}
+```
+
+- [ ] **Step 3: `usePlPlanArrangeActions.buildPlanSummaryText`**
+
+合并 options 时自动附带:
+
+```ts
+poundQrcodeLinks:
+  options?.poundQrcodeLinks ??
+  buildPoundQrcodeLinksFromInventoryRecords(dibangInventoryRecords.value),
+```
+
+(若 options 显式传空数组则尊重。)
+
+- [ ] **Step 4: `pl-plan-view-tabs` 的 `copyDriverSummaryForArrange`**
+
+同样传入 `poundQrcodeLinks`:从 `props.arrangeInventoryRecords`(若无此 prop,用已有 arrange inventory 来源;**查该组件 props**——若只有 site/driver,则改为调用 `usePlPlanArrangeActions` 的 `copyPlanSummary`,或增加 `arrangeInventoryRecords` prop)。
+
+实现时:`rg "copyDriverSummaryForArrange|arrangeInventory|inventoryRecords" web-admin/src/components/business/pl-plan-view*`,保证两条复制入口都带链接。列表页「复制给司机」走 `usePlPlanArrangeActions.copyPlanSummary` 即可。
+
+- [ ] **Step 5: 手工验证**
+
+有地磅单计划:复制文本末尾出现标题 + 多行。无地磅单:无该段。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add web-admin/src/utils/pl-driver-summary.ts \
+  web-admin/src/api/pl/index.ts \
+  web-admin/src/composables/usePlPlanArrangeActions.ts \
+  web-admin/src/components/business/pl-plan-view-tabs/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): append pound QR links in copy-to-driver text
+
+Include multi-line public pound QR URLs after the driver link when dibang slips exist.
+EOF
+)"
+```
+
+---
+
+### Task 6: 司机页底栏第三钮 + 弹层
+
+**Files:**
+- Modify: `web-admin/src/api/pl/public-transport.ts`
+- Modify: `web-admin/src/views/public/pl/transport/index.vue`
+
+- [ ] **Step 1: 类型**
+
+`PublicTransportPage` 增加:
+
+```ts
+poundQrcodeItems?: {
+  inventoryId: string
+  businessType: string
+  partnerCompany: string
+  label: string
+}[]
+```
+
+- [ ] **Step 2: 底栏 UI**
+
+在 `footer` 的可编辑分支(`!planVoided && !planArchived`):
+
+```vue
+<div class="btn-row" :class="{ 'btn-row--three': hasPoundQr }">
+  <van-button ... @click="goHistory">运输记录</van-button>
+  <van-button
+    v-if="hasPoundQr"
+    block
+    round
+    plain
+    type="primary"
+    @click="handleOpenPoundQr"
+  >
+    磅单二维码
+  </van-button>
+  <van-button type="primary" block round :loading="saving" @click="handleSave">
+    保存回执
+  </van-button>
+</div>
+```
+
+作废/归档分支:若 `hasPoundQr`,在「运输记录」旁或下方仍提供「磅单二维码」按钮(spec D:只读核验)。建议:
+
+```vue
+<div v-else class="btn-row" :class="{ 'btn-row--three': hasPoundQr }">
+  <van-button ... @click="goHistory">运输记录</van-button>
+  <van-button v-if="hasPoundQr" ... @click="handleOpenPoundQr">磅单二维码</van-button>
+</div>
+```
+
+`hasPoundQr = computed(() => (page.value?.poundQrcodeItems?.length || 0) > 0)`
+
+样式:三钮时缩小字号/gap(可参考 Demo;`.btn-row { display:flex; gap:8px }` 已有则加 `--three` 子按钮 `flex:1; font-size:13px`)。
+
+- [ ] **Step 3: 交互逻辑**
+
+```ts
+import { showActionSheet, showFailToast } from 'vant'
+import { publicInventoryApi } from '@/api/db/public-inventory'
+import PoundSlipQrcodeView from '@/components/business/db/PoundSlipQrcodeView.vue'
+
+const poundPopupVisible = ref(false)
+const poundLoading = ref(false)
+const poundView = ref<null | { /* View props */ }>(null)
+
+async function openPoundForId(inventoryId: string) {
+  poundLoading.value = true
+  poundPopupVisible.value = true
+  poundView.value = null
+  try {
+    const res = await publicInventoryApi.getPoundQr(inventoryId)
+    const row = (res as any).data ?? res
+    // fetch qr blob → objectUrl
+    // set poundView props + format product line
+  } catch (e: any) {
+    showFailToast(e?.message || '加载二维码失败')
+    poundPopupVisible.value = false
+  } finally {
+    poundLoading.value = false
+  }
+}
+
+async function handleOpenPoundQr() {
+  const items = page.value?.poundQrcodeItems || []
+  if (!items.length) return
+  if (items.length === 1) {
+    await openPoundForId(items[0].inventoryId)
+    return
+  }
+  const action = await showActionSheet({
+    actions: items.map((i) => ({ name: i.label })),
+    cancelText: '取消'
+  }).catch(() => null)
+  if (!action) return
+  const idx = items.findIndex((i) => i.label === action.name) // 或用 actions 带 value
+  // 更好:actions: items.map(i => ({ name: i.label, inventoryId: i.inventoryId }))
+  // Vant ActionSheet onSelect 返回 action 对象
+  await openPoundForId(selected.inventoryId)
+}
+```
+
+弹层:
+
+```vue
+<van-popup
+  v-model:show="poundPopupVisible"
+  position="center"
+  round
+  :style="{ width: '92%', background: 'transparent' }"
+  class="pound-qr-popup"
+>
+  <div class="pound-qr-popup__mask">
+    <div class="pound-qr-popup__title">{{ poundTitle }}</div>
+    <PoundSlipQrcodeView v-if="poundView" v-bind="poundView" />
+  </div>
+</van-popup>
+```
+
+遮罩深色:`background: rgba(0,0,0,0.92)`;关闭用 popup 自带点击遮罩。
+
+- [ ] **Step 4: 手工验证**
+
+- 有 1 条:点按钮直接弹  
+- 有多条:ActionSheet → 弹层  
+- 无条:无按钮  
+- 与独立公共页样式一致  
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/api/pl/public-transport.ts \
+  web-admin/src/views/public/pl/transport/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add pound QR button on public driver transport page
+
+Show a third footer action that opens the shared weighbridge QR card popup.
+EOF
+)"
+```
+
+---
+
+### Task 7: 收尾校验 + 图谱
+
+**Files:** 无新功能文件;跑测与图谱
+
+- [ ] **Step 1: 后端单测**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_public_db_pound_qr.py tests/test_pound_qr_label_items.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 2: 对照 spec 验收清单**
+
+逐条勾选 spec §8:单张文案、多张选择、无单隐藏、管理端复制图片、作废只读、删单 404。
+
+- [ ] **Step 3: 更新 graphify**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+或分别 `backend` / `frontend`。
+
+- [ ] **Step 4: Commit 图谱(若有 diff)**
+
+```bash
+git add backend/graphify-out web-admin/graphify-out graphify-out
+git commit -m "chore: update graphify after pound QR public link"
+```
+
+---
+
+## Spec 覆盖自检
+
+| Spec 项 | Task |
+|---------|------|
+| D1/D4 多行文案格式 | Task 5 |
+| D2 司机多选再弹 | Task 6 |
+| D3 公开 id 路由 | Task 4 |
+| D5 无单隐藏 | Task 5 + 6 |
+| D6 三钮同行 | Task 6 |
+| D7 方案 1 组件+API | Task 1–4 |
+| D8 作废仍可看 | Task 6 作废分支仍显示按钮 |
+| 公开字段白名单 | Task 1 Schema |
+| 管理端弹窗不回退 | Task 3 |
+| Demo 仅参考 | 不改代码路径 |
+
+**Placeholder 扫描:** 无 TBD;Task 5 中 view-tabs prop 名称要求实现时用 `rg` 确认,已写明查找命令。
+
+**类型一致性:** `poundQrcodeItems` / `PublicPoundQr` / `poundQrcodeLinks` 命名与 spec 一致;API 路径 `/public/db/inventory/{id}/pound-qr`。