From fb76940d158e1701f8c0370c74385d59ad37734d Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Fri, 31 Jul 2026 05:02:46 +0800 Subject: [PATCH] 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 --- .../plans/2026-07-31-pl-feedback-brand-qty.md | 738 ++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-31-pl-feedback-brand-qty.md diff --git a/docs/superpowers/plans/2026-07-31-pl-feedback-brand-qty.md b/docs/superpowers/plans/2026-07-31-pl-feedback-brand-qty.md new file mode 100644 index 000000000..9b1f9a2b2 --- /dev/null +++ b/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 +
+

...

+
+ + + {{ ioOrderHasFeedback ? '编辑' : '补充' }} + + + + 复制现场链接 + +
+
+``` + +删除正文底部原补充按钮块。 + +司机回传标题栏: + +```vue + + + {{ hasDriverFeedback ? '编辑' : '补充' }} + + + + 复制司机链接 + +``` + +- [ ] **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`,避免两套合计逻辑。