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:
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`,避免两套合计逻辑。
|
||||
Reference in new issue
Block a user