Procházet zdrojové kódy

docs: add implementation plan for feedback brand qty

Break down site/driver brand quantity feedback into DDL, APIs,
shared chips UI, view-tab layout, and archive alignment tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu před 3 měsíci
rodič
revize
fb76940d15

+ 738 - 0
docs/superpowers/plans/2026-07-31-pl-feedback-brand-qty.md

@@ -0,0 +1,738 @@
+# 运输计划回传按品牌填量 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:** 现场链接与司机链接按产品回传品牌数量(芯片点选填量);三套数量独立存储;仅归档最终量入账;查看「运输信息」Tab 展示品牌量并调整补充/复制链接标题栏按钮。
+
+**Architecture:** 入/出库各增品牌量子表;司机增「按产品回传」表 + 品牌量子表;复用 `validate_brand_qty_rows`。前端抽取 `BrandQtyChipsField` 供公开页、补充弹窗、归档弹窗共用。归档仍写 `pl_plan_product_brand_qty`,不用现场/司机数据自动覆盖。
+
+**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus + Vant(公开页);MySQL 5.7;pytest
+
+**Spec:** `docs/superpowers/specs/2026-07-31-pl-feedback-brand-qty-design.md`  
+**Demo:** `docs/demos/pl-site-driver-brand-qty-demo.html`
+
+---
+
+## 文件结构
+
+| 文件 | 职责 |
+|------|------|
+| `backend/scripts/migrate_20260731_pl_feedback_brand_qty.sql` | 四张新表 DDL |
+| `docs/sql/shanxia2.0.sql` | 同步表结构快照 |
+| `backend/app/models/pl_models.py` | 四个新 ORM 模型 |
+| `backend/app/services/pl_feedback_brand_qty.py` | 现场/司机品牌量持久化与摘要格式化 |
+| `backend/app/schemas/pl/io_order.py` | feedback/item 读写扩展 `brandQtys` |
+| `backend/app/schemas/pl/transport.py` | 公开司机页产品与 feedback body |
+| `backend/app/schemas/pl/plan.py` | `PlanDriverFeedbackUpdateBody` 按产品扩展 |
+| `backend/app/services/pl_io_order_service.py` | `apply_site_feedback` + 读页附带品牌 |
+| `backend/app/api/pl/public_io_order.py` | 公共现场读写串联 |
+| `backend/app/api/pl/public_transport.py` | 公共司机按产品回传 |
+| `backend/app/services/pl_plan_service.py` | `apply_plan_driver_feedback` 扩展 |
+| `backend/app/api/pl/io_order.py`(或现有 patch) | 管理端入出库 patch 支持 brandQtys |
+| `backend/tests/test_pl_feedback_brand_qty.py` | 校验与持久化单测 |
+| `web-admin/src/components/business/brand-qty-chips-field/index.vue` | 芯片填量公共组件 |
+| `web-admin/src/api/pl/public-io-order.ts` | 类型与提交 body |
+| `web-admin/src/api/pl/public-transport.ts` | 类型与提交 body |
+| `web-admin/src/api/pl/index.ts` | 管理端类型 |
+| `web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue` | 现场公开页 |
+| `web-admin/src/views/public/pl/transport/index.vue` | 司机公开页 |
+| `web-admin/src/components/business/pl-plan-driver-feedback-dialog/index.vue` | 司机补充 |
+| `web-admin/src/components/business/pl-plan-view-tabs/index.vue` | 展示 + 标题栏按钮 + 入出库补充表单 |
+| `web-admin/src/components/business/pl-plan-archive-dialog/index.vue` | 归档区改芯片交互 |
+
+常量:
+
+- `brand_mode`: `"required"` \| `"any"` \| `null`
+- 摘要格式:与现有 IO `brandSummary` 一致 — `君正 16 / 红四方 12`
+
+---
+
+### Task 1: DDL 迁移四张子表
+
+**Files:**
+- Create: `backend/scripts/migrate_20260731_pl_feedback_brand_qty.sql`
+- Modify: `docs/sql/shanxia2.0.sql`(追加 CREATE,与脚本一致)
+
+- [ ] **Step 1: 写迁移 SQL**
+
+```sql
+-- backend/scripts/migrate_20260731_pl_feedback_brand_qty.sql
+
+CREATE TABLE IF NOT EXISTS `pl_inbound_order_item_brand_qty` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `order_id` varchar(64) NOT NULL COMMENT '入库单ID',
+  `order_item_id` varchar(64) NOT NULL COMMENT '入库明细ID',
+  `plan_product_id` varchar(64) NOT NULL COMMENT '计划产品行ID',
+  `brand_id` varchar(64) NOT NULL DEFAULT '' COMMENT '品牌ID',
+  `brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名称',
+  `qty` decimal(15,3) NOT NULL DEFAULT '0.000' COMMENT '本品牌数量',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
+  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
+  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `del_flag` smallint(6) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `idx_order_item` (`order_item_id`),
+  KEY `idx_order` (`order_id`),
+  KEY `idx_tenant` (`tenant_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='入库明细品牌数量';
+
+CREATE TABLE IF NOT EXISTS `pl_outbound_order_item_brand_qty` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `order_id` varchar(64) NOT NULL COMMENT '出库单ID',
+  `order_item_id` varchar(64) NOT NULL COMMENT '出库明细ID',
+  `plan_product_id` varchar(64) NOT NULL COMMENT '计划产品行ID',
+  `brand_id` varchar(64) NOT NULL DEFAULT '' COMMENT '品牌ID',
+  `brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名称',
+  `qty` decimal(15,3) NOT NULL DEFAULT '0.000' COMMENT '本品牌数量',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
+  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
+  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `del_flag` smallint(6) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `idx_order_item` (`order_item_id`),
+  KEY `idx_order` (`order_id`),
+  KEY `idx_tenant` (`tenant_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='出库明细品牌数量';
+
+CREATE TABLE IF NOT EXISTS `pl_transport_product_feedback` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `transport_id` varchar(64) NOT NULL COMMENT '运输单ID',
+  `plan_id` varchar(64) NOT NULL COMMENT '计划ID',
+  `plan_product_id` varchar(64) NOT NULL COMMENT '计划产品行ID',
+  `actual_qty` decimal(15,3) NOT NULL DEFAULT '0.000' COMMENT '该产品回传总量',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
+  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
+  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `del_flag` smallint(6) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `idx_transport` (`transport_id`),
+  KEY `idx_plan_product` (`plan_product_id`),
+  KEY `idx_tenant` (`tenant_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输单按产品回传';
+
+CREATE TABLE IF NOT EXISTS `pl_transport_product_brand_qty` (
+  `id` varchar(64) NOT NULL COMMENT '主键ID',
+  `transport_id` varchar(64) NOT NULL COMMENT '运输单ID',
+  `plan_product_id` varchar(64) NOT NULL COMMENT '计划产品行ID',
+  `feedback_id` varchar(64) DEFAULT NULL COMMENT '产品回传行ID',
+  `brand_id` varchar(64) NOT NULL DEFAULT '' COMMENT '品牌ID',
+  `brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名称',
+  `qty` decimal(15,3) NOT NULL DEFAULT '0.000' COMMENT '本品牌数量',
+  `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号',
+  `order_num` int(11) DEFAULT NULL COMMENT '排序号',
+  `remark` varchar(512) DEFAULT NULL COMMENT '备注',
+  `create_by` varchar(64) DEFAULT NULL,
+  `create_dept_id` varchar(64) NOT NULL DEFAULT '',
+  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `update_by` varchar(64) DEFAULT NULL,
+  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `del_flag` smallint(6) NOT NULL DEFAULT '0',
+  PRIMARY KEY (`id`),
+  KEY `idx_transport_product` (`transport_id`,`plan_product_id`),
+  KEY `idx_feedback` (`feedback_id`),
+  KEY `idx_tenant` (`tenant_no`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运输单产品回传品牌数量';
+```
+
+- [ ] **Step 2: 在本地库执行迁移**
+
+```bash
+cd backend && source venv/bin/activate
+# 使用项目惯用方式执行 SQL(mysql client 或现有 migrate 脚本)
+mysql ... < scripts/migrate_20260731_pl_feedback_brand_qty.sql
+```
+
+Expected: 四张表创建成功。
+
+- [ ] **Step 3: 将相同 CREATE 同步进 `docs/sql/shanxia2.0.sql` 合适位置(入出库表附近 / 运输表附近)**
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/scripts/migrate_20260731_pl_feedback_brand_qty.sql docs/sql/shanxia2.0.sql
+git commit -m "chore(db): add feedback brand qty tables for site and driver"
+```
+
+---
+
+### Task 2: ORM 模型
+
+**Files:**
+- Modify: `backend/app/models/pl_models.py`(在 `PlInboundOrderItem` / `PlOutboundOrderItem` / `PlTransport` 附近追加)
+- Modify: `backend/app/models/__init__.py`(若需导出)
+
+- [ ] **Step 1: 新增四个模型类**(字段与 DDL 一一对应,风格对齐 `PlPlanProductBrandQty`)
+
+```python
+class PlInboundOrderItemBrandQty(Base):
+    __tablename__ = "pl_inbound_order_item_brand_qty"
+    id = Column(String(64), primary_key=True)
+    order_id = Column(String(64), nullable=False)
+    order_item_id = Column(String(64), nullable=False)
+    plan_product_id = Column(String(64), nullable=False)
+    brand_id = Column(String(64), nullable=False, default="")
+    brand_name = Column(String(100), nullable=True)
+    qty = Column(DECIMAL(15, 3), nullable=False, default=0)
+    # ... 公共字段同 PlPlanProductBrandQty
+
+class PlOutboundOrderItemBrandQty(Base):
+    __tablename__ = "pl_outbound_order_item_brand_qty"
+    # 同 inbound
+
+class PlTransportProductFeedback(Base):
+    __tablename__ = "pl_transport_product_feedback"
+    id = Column(String(64), primary_key=True)
+    transport_id = Column(String(64), nullable=False)
+    plan_id = Column(String(64), nullable=False)
+    plan_product_id = Column(String(64), nullable=False)
+    actual_qty = Column(DECIMAL(15, 3), nullable=False, default=0)
+    # ... 公共字段
+
+class PlTransportProductBrandQty(Base):
+    __tablename__ = "pl_transport_product_brand_qty"
+    id = Column(String(64), primary_key=True)
+    transport_id = Column(String(64), nullable=False)
+    plan_product_id = Column(String(64), nullable=False)
+    feedback_id = Column(String(64), nullable=True)
+    brand_id = Column(String(64), nullable=False, default="")
+    brand_name = Column(String(100), nullable=True)
+    qty = Column(DECIMAL(15, 3), nullable=False, default=0)
+    # ... 公共字段
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add backend/app/models/pl_models.py backend/app/models/__init__.py
+git commit -m "feat(pl): add ORM models for feedback brand qty tables"
+```
+
+---
+
+### Task 3: 品牌量持久化服务 + 单测
+
+**Files:**
+- Create: `backend/app/services/pl_feedback_brand_qty.py`
+- Create: `backend/tests/test_pl_feedback_brand_qty.py`
+- Reuse: `backend/app/services/pl_brand_archive.py` 的 `validate_brand_qty_rows`
+
+- [ ] **Step 1: 写失败测试(摘要与 require-complete 行为)**
+
+```python
+# backend/tests/test_pl_feedback_brand_qty.py
+from decimal import Decimal
+import pytest
+from app.services.pl_feedback_brand_qty import format_brand_qty_summary, require_brand_qtys_if_needed
+
+def test_format_brand_qty_summary():
+    s = format_brand_qty_summary([
+        {"brandName": "君正", "qty": Decimal("16")},
+        {"brandName": "红四方", "qty": Decimal("12.000")},
+    ])
+    assert s == "君正 16 / 红四方 12"
+
+def test_require_skips_when_no_brand_mode():
+    require_brand_qtys_if_needed(
+        brand_mode=None,
+        required_brand_id=None,
+        actual_qty=Decimal("10"),
+        rows=[],
+        enforce=True,
+    )
+
+def test_require_enforces_when_completed():
+    with pytest.raises(ValueError):
+        require_brand_qtys_if_needed(
+            brand_mode="any",
+            required_brand_id=None,
+            actual_qty=Decimal("10"),
+            rows=[],
+            enforce=True,
+        )
+```
+
+- [ ] **Step 2: 跑测确认失败**
+
+```bash
+cd backend && source venv/bin/activate && pytest tests/test_pl_feedback_brand_qty.py -v
+```
+
+Expected: FAIL import / 函数未定义。
+
+- [ ] **Step 3: 实现服务**
+
+```python
+# backend/app/services/pl_feedback_brand_qty.py
+from decimal import Decimal
+from typing import Any, Optional, Type
+from sqlalchemy.orm import Session
+from app.core.utils import generate_uuid
+from app.services.pl_brand_archive import validate_brand_qty_rows
+
+def format_brand_qty_summary(rows: list[dict]) -> Optional[str]:
+    parts = []
+    for r in rows:
+        qty = Decimal(str(r.get("qty") or 0))
+        if qty <= 0:
+            continue
+        name = (r.get("brandName") or r.get("brand_name") or "").strip() or "未命名品牌"
+        qty_text = format(qty.normalize(), "f")
+        parts.append(f"{name} {qty_text}")
+    return " / ".join(parts) if parts else None
+
+def require_brand_qtys_if_needed(*, brand_mode, required_brand_id, actual_qty, rows, enforce: bool) -> None:
+    if not brand_mode:
+        return
+    if not enforce:
+        return
+    positive = [r for r in rows if Decimal(str(r.get("qty") or 0)) > 0]
+    if not positive:
+        raise ValueError("有品牌产品须填写至少一个品牌数量")
+    validate_brand_qty_rows(
+        brand_mode=brand_mode,
+        required_brand_id=required_brand_id,
+        actual_qty=Decimal(str(actual_qty)),
+        rows=rows,
+    )
+
+def persist_io_item_brand_qtys(
+    db: Session,
+    *,
+    model: Type[Any],
+    tenant_no: str,
+    order_id: str,
+    order_item_id: str,
+    plan_product_id: str,
+    brand_mode: Optional[str],
+    rows: list[dict],
+    user_id: Optional[str] = None,
+) -> None:
+    existing = (
+        db.query(model)
+        .filter(model.order_item_id == order_item_id, model.tenant_no == tenant_no, model.del_flag == 0)
+        .all()
+    )
+    for row in existing:
+        row.del_flag = 1
+        row.update_by = user_id
+    if not brand_mode:
+        return
+    for idx, item in enumerate(rows):
+        qty = Decimal(str(item.get("qty") or 0))
+        if qty <= 0:
+            continue
+        db.add(model(
+            id=generate_uuid(),
+            tenant_no=tenant_no,
+            order_id=order_id,
+            order_item_id=order_item_id,
+            plan_product_id=plan_product_id,
+            brand_id=item.get("brandId") or item.get("brand_id") or "",
+            brand_name=item.get("brandName") or item.get("brand_name"),
+            qty=qty,
+            order_num=idx,
+            create_by=user_id,
+            create_dept_id="",
+            update_by=user_id,
+        ))
+```
+
+另实现 `persist_transport_product_feedback(...)`:软删该 transport 下旧 feedback + brand 行,再按 products 插入;最后 `transport.feedback_qty = sum(actual_qty)`。
+
+- [ ] **Step 4: 跑测通过**
+
+```bash
+pytest tests/test_pl_feedback_brand_qty.py -v
+```
+
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/services/pl_feedback_brand_qty.py backend/tests/test_pl_feedback_brand_qty.py
+git commit -m "feat(pl): add feedback brand qty persist helpers and tests"
+```
+
+---
+
+### Task 4: 现场公共 API — schema + apply + 读页
+
+**Files:**
+- Modify: `backend/app/schemas/pl/io_order.py`
+- Modify: `backend/app/services/pl_io_order_service.py`
+- Modify: `backend/app/api/pl/public_io_order.py`
+- Modify: 管理端入出库 patch schema(`IoOrderItemPatch` 同样加 `brand_qtys`)
+
+- [ ] **Step 1: 扩展 schema**
+
+```python
+class BrandQtyItem(BaseModel):
+    brand_id: str = Field(default="", alias="brandId")
+    brand_name: Optional[str] = Field(default=None, alias="brandName")
+    qty: Decimal = Field(..., ge=0)
+    model_config = {"populate_by_name": True}
+
+class PublicIoOrderItemFeedback(BaseModel):
+    id: str
+    actual_qty: Decimal = Field(alias="actualQty")
+    brand_qtys: Optional[list[BrandQtyItem]] = Field(default=None, alias="brandQtys")
+    model_config = {"populate_by_name": True}
+```
+
+`IoOrderItemRead` / 公共页 item dict 增加:`brandMode`、`brandId`、`brandName`、`brandOptions`、`brandQtys`、`brandSummary`(现场子表摘要,**不要**与归档 `pl_plan_product_brand_qty` 摘要混用;公共页以现场子表为准)。
+
+- [ ] **Step 2: 扩展 `apply_site_feedback`**
+
+签名增加 `item_brand_qtys: dict[str, list[dict]]` 与 `enforce_brand: bool`(`status == "completed"` 时 True)。对每条 item:
+
+1. 查 `PlPlanProduct` 得 `brand_mode` / `brand_id`
+2. `require_brand_qtys_if_needed(...)`
+3. 更新 `actual_qty`
+4. `persist_io_item_brand_qtys`(inbound/outbound 选对应 model)
+
+- [ ] **Step 3: 读页时加载 CRM brands 作 `brandOptions`;加载现场子表作 `brandQtys`**
+
+在 `order_header_to_dict` 或 `_build_page` 中批量查 `CrmProductBrand`(`product_id in ...`, `del_flag=0`, `status=on`)与现场品牌子表。
+
+注意:现有 `brandSummary` 来自归档表 — 列表页可继续展示归档摘要;**公共现场页**必须返回现场子表的 `brandQtys`。若同字段名冲突,公共页用现场数据覆盖 `brandSummary`。
+
+- [ ] **Step 4: `public_io_order.save_feedback` 把 `payload.items` 的 brandQtys 传入 `apply_site_feedback`**
+
+- [ ] **Step 5: 手动或加集成测:completed + any 品牌合计不符 → 400**
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add backend/app/schemas/pl/io_order.py backend/app/services/pl_io_order_service.py backend/app/api/pl/public_io_order.py
+git commit -m "feat(pl): site io-order feedback accepts and stores brand qtys"
+```
+
+---
+
+### Task 5: 前端公共组件 BrandQtyChipsField + 现场公开页
+
+**Files:**
+- Create: `web-admin/src/components/business/brand-qty-chips-field/index.vue`
+- Modify: `web-admin/src/api/pl/public-io-order.ts`
+- Modify: `web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue`
+
+- [ ] **Step 1: 实现 `BrandQtyChipsField`**
+
+Props:
+
+```ts
+{
+  brandMode?: 'any' | 'required' | null
+  requiredBrandId?: string | null
+  brandOptions: { brandId: string; brandName: string }[]
+  modelValue: { brandId: string; brandName: string; qty: number }[]  // 已选有量
+  disabled?: boolean
+  unit?: string
+}
+```
+
+Emit:`update:modelValue`、`update:actualQty`(合计)。
+
+行为:
+
+- `required`:只显示指定品牌芯片且强制选中,不可取消
+- `any`:点芯片 toggle;选中后显示数量输入
+- 合计变化时 emit `actualQty`
+- 无 `brandMode` 时组件不渲染(父级控制)
+
+公开页用 Vant 风格(chip 按钮 + van-field);管理端可复用同一组件或加 `variant="admin"`(Element 输入)。首期可用一套 CSS 变量适配,或拆 `BrandQtyChipsFieldMobile.vue` / 管理端内联 — **推荐单组件 + `variant: 'mobile' | 'admin'`**。
+
+- [ ] **Step 2: 更新 `public-io-order.ts` 类型**
+
+```ts
+export interface BrandQtyItem {
+  brandId: string
+  brandName?: string
+  qty: number
+}
+export interface PublicIoOrderItem {
+  // ...existing
+  brandMode?: string | null
+  brandId?: string | null
+  brandName?: string | null
+  brandOptions?: { brandId: string; brandName: string }[]
+  brandQtys?: BrandQtyItem[]
+}
+```
+
+`saveFeedback` items: `{ id, actualQty, brandQtys?: BrandQtyItem[] }`
+
+- [ ] **Step 3: 改造 `PublicIoOrderPage.vue`**
+
+- `editableItems` 增加 `brandMode`、`brandOptions`、`brandQtyRows`
+- 有 `brandMode` 时:实际数量 input `readonly`,绑芯片合计;下方挂 `BrandQtyChipsField`
+- 无品牌:保持现有可编辑总量
+- `handleSave`:`status === 'completed'` 时前端校验合计与至少一品牌;提交带 `brandQtys`
+
+- [ ] **Step 4: 浏览器手测入库/出库公开页(有品牌 / 无品牌)**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/components/business/brand-qty-chips-field web-admin/src/api/pl/public-io-order.ts web-admin/src/views/public/pl/_shared/PublicIoOrderPage.vue
+git commit -m "feat(web-admin): site public page brand qty chips entry"
+```
+
+---
+
+### Task 6: 司机公共 API + 公开页
+
+**Files:**
+- Modify: `backend/app/schemas/pl/transport.py`
+- Modify: `backend/app/api/pl/public_transport.py`
+- Modify: `backend/app/services/pl_feedback_brand_qty.py`(若 persist transport 未在 Task3 写完则补全)
+- Modify: `web-admin/src/api/pl/public-transport.ts`
+- Modify: `web-admin/src/views/public/pl/transport/index.vue`
+
+- [ ] **Step 1: Schema**
+
+```python
+class PublicTransportPlanProductRead(BaseModel):
+    plan_product_id: str = Field(alias="planProductId")
+    product_name: str = Field(alias="productName")
+    # ...existing model/package/unit/quantity/remark
+    brand_mode: Optional[str] = Field(default=None, alias="brandMode")
+    brand_id: Optional[str] = Field(default=None, alias="brandId")
+    brand_name: Optional[str] = Field(default=None, alias="brandName")
+    brand_options: list[BrandQtyOption] = Field(default_factory=list, alias="brandOptions")
+    actual_qty: Optional[Decimal] = Field(default=None, alias="actualQty")
+    brand_qtys: list[BrandQtyItem] = Field(default_factory=list, alias="brandQtys")
+
+class PublicTransportProductFeedbackItem(BaseModel):
+    plan_product_id: str = Field(alias="planProductId")
+    actual_qty: Decimal = Field(alias="actualQty", ge=0)
+    brand_qtys: Optional[list[BrandQtyItem]] = Field(default=None, alias="brandQtys")
+
+class PublicTransportFeedback(BaseModel):
+    feedback_images: Optional[list[str]] = Field(default=None, alias="feedbackImages")
+    feedback_remark: Optional[str] = Field(default=None, alias="feedbackRemark")
+    products: Optional[list[PublicTransportProductFeedbackItem]] = Field(default=None)
+    # feedback_qty 保留可选兼容;有 products 时忽略或覆盖为合计
+    feedback_qty: Optional[Decimal] = Field(default=None, alias="feedbackQty", ge=0)
+```
+
+- [ ] **Step 2: `_build_public_page` 填入 planProductId、品牌 options、已存 feedback**
+
+- [ ] **Step 3: `save_public_feedback` 调用 `persist_transport_product_feedback`,校验 brand_mode**
+
+- [ ] **Step 4: 前端司机页改为按产品卡片填量 + 芯片;去掉单一总数量框(或保留只读合计展示)**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add backend/app/schemas/pl/transport.py backend/app/api/pl/public_transport.py backend/app/services/pl_feedback_brand_qty.py web-admin/src/api/pl/public-transport.ts web-admin/src/views/public/pl/transport/index.vue
+git commit -m "feat(pl): driver public feedback by product and brand qty"
+```
+
+---
+
+### Task 7: 管理端司机补充弹窗 + driver-feedback API
+
+**Files:**
+- Modify: `backend/app/schemas/pl/plan.py` — `PlanDriverFeedbackUpdateBody`
+- Modify: `backend/app/services/pl_plan_service.py` — `apply_plan_driver_feedback`
+- Modify: `web-admin/src/api/pl/index.ts`
+- Modify: `web-admin/src/components/business/pl-plan-driver-feedback-dialog/index.vue`
+- Modify: `web-admin/src/components/business/pl-plan-view-tabs/index.vue`(传 products 入弹窗)
+
+- [ ] **Step 1: Body 扩展**
+
+```python
+class PlanDriverFeedbackProductItem(BaseModel):
+    plan_product_id: str = Field(alias="planProductId")
+    actual_qty: Decimal = Field(alias="actualQty", ge=0)
+    brand_qtys: Optional[list[PlanArchiveBrandQtyItem]] = Field(default=None, alias="brandQtys")
+
+class PlanDriverFeedbackUpdateBody(BaseModel):
+    feedback_remark: Optional[str] = Field(default=None, alias="feedbackRemark", max_length=500)
+    feedback_images: Optional[list[str]] = Field(default=None, alias="feedbackImages")
+    feedback_time: OptionalPlanDateTime = Field(default=None, alias="feedbackTime")
+    products: Optional[list[PlanDriverFeedbackProductItem]] = None
+    feedback_qty: Optional[Decimal] = Field(default=None, alias="feedbackQty", ge=0)  # 兼容
+```
+
+`apply_plan_driver_feedback`:有 `products` 则走 `persist_transport_product_feedback`(enforce=True);备注/附件/时间照旧。
+
+- [ ] **Step 2: 弹窗 UI**
+
+- 打开时用计划产品 + 已有司机回传初始化行
+- 每行:产品名、总量(只读若有品牌)、`BrandQtyChipsField`
+- 提交带 `products`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/app/schemas/pl/plan.py backend/app/services/pl_plan_service.py web-admin/src/api/pl/index.ts web-admin/src/components/business/pl-plan-driver-feedback-dialog/index.vue web-admin/src/components/business/pl-plan-view-tabs/index.vue
+git commit -m "feat(web-admin): driver feedback dialog brand qty entry"
+```
+
+---
+
+### Task 8: 入出库补充表单 + 查看展示 + 标题栏按钮
+
+**Files:**
+- Modify: `web-admin/src/components/business/pl-plan-view-tabs/index.vue`
+- Modify: `backend/app/services/pl_io_order_service.py`(管理端读 item 附现场 `brandQtys` / `brandSummary` 来自现场子表)
+- Modify: 入出库 `patch` API(若尚未支持 brandQtys)
+
+- [ ] **Step 1: 标题栏按钮**
+
+入/出库回传 `pl-plan-form-section-head`:
+
+```vue
+<div class="pl-plan-form-section-head">
+  <h3>...</h3>
+  <div class="pl-plan-form-section-head-actions">
+    <el-button
+      v-if="feedbackEditable && ioOrder && ioOrder.status !== 'voided'"
+      type="primary"
+      @click="openIoOrderEditDialog"
+    >
+      <el-icon><Edit /></el-icon>
+      {{ ioOrderHasFeedback ? '编辑' : '补充' }}
+    </el-button>
+    <el-button v-if="ioOrder?.siteUrl" @click="copyArrangeSiteUrl(ioOrder.siteUrl)">
+      <el-icon><CopyDocument /></el-icon>
+      复制现场链接
+    </el-button>
+  </div>
+</div>
+```
+
+删除正文底部原补充按钮块。
+
+司机回传标题栏:
+
+```vue
+<el-button v-if="feedbackEditable" type="primary" :disabled="!canEditDriverFeedback" @click="openDriverFeedbackDialog">
+  <el-icon><Edit /></el-icon>
+  {{ hasDriverFeedback ? '编辑' : '补充' }}
+</el-button>
+<el-button v-if="arrangeDriverUrl" @click="copyArrangeDriverUrl">
+  <el-icon><CopyDocument /></el-icon>
+  复制司机链接
+</el-button>
+```
+
+- [ ] **Step 2: 司机回传展示改为按产品表**
+
+列:产品、回传数量、品牌数量(摘要)、(备注/附件仍可挂运输单级)。
+
+数据来自 `pl_transport_product_feedback` + brand 子表(计划详情接口需带回,或 view-tabs 另请求)。若现接口不够,在 `planApi.getDetail` / transport links 聚合中附 `driverProductFeedbacks[]`。
+
+- [ ] **Step 3: 入出库明细表增加「品牌数量」列**(现场子表摘要)
+
+- [ ] **Step 4: `openIoOrderEditDialog` 表单每行挂 `BrandQtyChipsField`;`saveIoOrderEdit` 提交 `brandQtys`**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/components/business/pl-plan-view-tabs/index.vue backend/app/services/pl_io_order_service.py backend/app/api/pl/ backend/app/schemas/pl/io_order.py web-admin/src/api/pl/index.ts
+git commit -m "feat(web-admin): show brand qty on transport tab and highlight supplement actions"
+```
+
+---
+
+### Task 9: 归档弹窗芯片交互对齐(最终量仍独立)
+
+**Files:**
+- Modify: `web-admin/src/components/business/pl-plan-archive-dialog/index.vue`
+
+- [ ] **Step 1: 将现有「按品牌填量表格」替换为 `BrandQtyChipsField`(variant=admin)**
+
+- 仍只读写归档 `brandQtyRows` → 提交 `brandQtys` 到 `pl_plan_product_brand_qty`
+- **禁止**打开归档时用现场/司机 brandQtys 写入表单最终值
+- 可选:在产品行下方只读展示「现场摘要 / 司机摘要」对照(若不做对照,跳过,符合非强制)
+
+- [ ] **Step 2: 手测归档入账仍走现有 `apply_archive_inventory_for_plan`**
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/components/business/pl-plan-archive-dialog/index.vue
+git commit -m "feat(web-admin): align archive brand qty entry with chips UI"
+```
+
+---
+
+### Task 10: 回归与图谱
+
+**Files:**
+- `backend/tests/test_pl_brand_archive.py`(确认未破坏)
+- `backend/tests/test_pl_feedback_brand_qty.py`
+
+- [ ] **Step 1: 跑相关测试**
+
+```bash
+cd backend && source venv/bin/activate
+pytest tests/test_pl_brand_archive.py tests/test_pl_feedback_brand_qty.py -v
+```
+
+Expected: 全部 PASS
+
+- [ ] **Step 2: 手工清单**
+
+1. 无品牌产品:现场/司机仅总量  
+2. any:多品牌芯片落库  
+3. required:仅指定品牌  
+4. 现场与司机不同品牌量,查看 Tab 两侧独立;归档不被覆盖  
+5. 归档提交入账正确  
+6. 标题栏按钮顺序与 primary 高亮  
+7. 危化品证上传 / 运输计划查看共用 Tab 同步正确  
+
+- [ ] **Step 3: 更新 graphify**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add backend/tests web-admin/graphify-out backend/graphify-out
+git commit -m "test(pl): verify feedback brand qty and refresh graphify"
+```
+
+---
+
+## Spec 覆盖自检
+
+| Spec 要求 | Task |
+|-----------|------|
+| 现场链接品牌填量 | 4, 5 |
+| 司机链接按产品品牌填量 | 6 |
+| 无品牌不填明细 | 3, 5, 6 |
+| 三套独立;归档最终入账 | 3, 4, 6, 9 |
+| 独立子表 | 1, 2 |
+| 查看运输信息展示 | 8 |
+| 补充弹窗可录品牌 | 7, 8 |
+| 标题栏按钮布局与高亮 | 8 |
+| 归档芯片对齐且不自动覆盖 | 9 |
+| 芯片交互方案 B | 5 |
+
+---
+
+## 执行说明
+
+实现时严格按 Task 顺序;每 Task 结束再 Commit。公开页与管理端共用 `BrandQtyChipsField`,避免两套合计逻辑。