diff --git a/docs/superpowers/plans/2026-09-13-pound-qrcode-public-link.md b/docs/superpowers/plans/2026-09-13-pound-qrcode-public-link.md new file mode 100644 index 000000000..fd26fc925 --- /dev/null +++ b/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 +