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>
This commit is contained in:
zlwuandCursor committed 2026-07-31 05:02:46 +08:00
1 parent 4e6e06a603
commit fb76940d15
1 file changed
+738
@@ -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`,避免两套合计逻辑。