docs(crm): add product brand and alias implementation plan.
Break the approved design into sequenced tasks from DDL through archive inventory posting and UI. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
d7e0edf2d5
commit
a79dd9f61d
1 file changed
+834
@@ -0,0 +1,834 @@
|
||||
# 产品品牌与别名(含归档按品牌库存)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:** 为 CRM 产品增加按产品维护的品牌与别名;报价/合同行支持「指定/不限品牌」与展示名快照;运输计划带入品牌约定;归档时按品牌填入/出库量,并按「产品+品牌」写入库存台账与流水。
|
||||
|
||||
**Architecture:** 主数据用 `crm_product_brand` / `crm_product_alias` 子表;约定快照落在报价/合同/计划行(`brand_mode`/`brand_id`/`brand_name`);实绩落在 `pl_plan_product_brand_qty`;库存唯一键升级为 `(tenant_no, product_id, brand_id)`(无品牌 `brand_id=''`)。归档确认时校验品牌量并调用新建的 `apply_archive_inventory` 落账(现网有台账表但归档尚未写流水,本需求一并补齐)。
|
||||
|
||||
**Tech Stack:** FastAPI + SQLAlchemy + Pydantic v2;Vue 3 + Element Plus;MySQL 5.7;pytest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-27-crm-product-brand-alias-design.md`
|
||||
|
||||
**Scope note:** 覆盖产品→报价→合同→计划→归档→库存整条链。按 Task 顺序交付;Task 1–4 可先联调主数据,但**不要**在未完成 Task 8–9 时把「有品牌约定」的合同大量上线(避免中间态长期无品牌库存)。
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `backend/scripts/migrate_20260727_crm_product_brand_alias.sql` | DDL + 库存唯一键迁移 |
|
||||
| `docs/sql/shanxia2.0.sql` | 同步表结构快照 |
|
||||
| `backend/app/models/crm_models.py` | `CrmProductBrand`、`CrmProductAlias`;报价/合同行品牌字段 |
|
||||
| `backend/app/models/pl_models.py` | 计划行品牌字段;`PlPlanProductBrandQty`;库存品牌字段 |
|
||||
| `backend/app/models/__init__.py` | 导出新模型 |
|
||||
| `backend/app/schemas/crm/product.py` | 品牌/别名 body 与产品读写嵌套 |
|
||||
| `backend/app/crud/crm/product.py` | 产品 CRUD 读写子表 |
|
||||
| `backend/app/crud/crm/quote_product.py` | `_row_to_dict` + 保存品牌字段 |
|
||||
| `backend/app/crud/crm/contract_product.py` | 同上;从报价拷贝 |
|
||||
| `backend/app/schemas/pl/plan.py` | 计划产品/归档 body 扩展 |
|
||||
| `backend/app/services/pl_brand_archive.py` | 归档品牌量校验(纯函数 + DB 持久化入口) |
|
||||
| `backend/app/services/pl_inventory_service.py` | 按品牌加减库存、写流水、出库不足拦截 |
|
||||
| `backend/app/services/pl_plan_service.py` | `apply_archive_product_quantities` 串联品牌量与库存 |
|
||||
| `backend/app/api/pl/plan.py` | `create-from-contract` 拷贝品牌约定;归档调用 |
|
||||
| `backend/app/api/pl/inventory.py` | ledger/流水返回品牌 |
|
||||
| `backend/tests/test_pl_brand_archive.py` | 校验与库存落账单测 |
|
||||
| `web-admin/src/api/crm/product.ts` | 类型含 brands/aliases |
|
||||
| `web-admin/src/views/crm/product/index.vue` | 品牌/别名编辑 UI |
|
||||
| `web-admin/src/api/crm/quote/types.ts` | 行字段 |
|
||||
| `web-admin/src/api/crm/contract/types.ts` | 行字段 |
|
||||
| `web-admin/src/views/crm/quote/components/QuoteFormPanel.vue`(及 ProductSelector) | 展示名 + 品牌约定 |
|
||||
| `web-admin/src/views/crm/contract/components/ContractFormDialog.vue`(及 ProductSelector) | 同上 |
|
||||
| `web-admin/src/api/pl/index.ts` | PlanProduct / PlanArchiveBody |
|
||||
| `web-admin/src/components/business/pl-plan-archive-dialog/index.vue` | 按品牌填量 |
|
||||
| `web-admin/src/views/pl/inventory/ledger.vue` | 品牌列 |
|
||||
| `web-admin/src/views/pl/_shared/IoOrderListPage.vue` 等 | 展示实际品牌(若明细有字段) |
|
||||
|
||||
常量约定(全链路统一):
|
||||
|
||||
- `brand_mode`: `"required"` | `"any"` | `null`(无品牌产品)
|
||||
- 无品牌库存键:`brand_id = ""`,`brand_name = null` 或 `""`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: DDL 迁移脚本
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/scripts/migrate_20260727_crm_product_brand_alias.sql`
|
||||
- Modify: `docs/sql/shanxia2.0.sql`(执行后或手工同步 CREATE 定义)
|
||||
|
||||
- [ ] **Step 1: 写迁移 SQL**
|
||||
|
||||
```sql
|
||||
-- backend/scripts/migrate_20260727_crm_product_brand_alias.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `crm_product_brand` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`product_id` varchar(64) NOT NULL COMMENT '产品ID',
|
||||
`brand_name` varchar(100) NOT NULL COMMENT '品牌名称',
|
||||
`status` char(3) NOT NULL DEFAULT 'on' COMMENT '状态 on/off',
|
||||
`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_product_id` (`product_id`),
|
||||
KEY `idx_tenant` (`tenant_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM-产品品牌';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `crm_product_alias` (
|
||||
`id` varchar(64) NOT NULL COMMENT '主键ID',
|
||||
`product_id` varchar(64) NOT NULL COMMENT '产品ID',
|
||||
`alias_name` varchar(200) NOT NULL COMMENT '别名',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0',
|
||||
`order_num` int(11) DEFAULT NULL,
|
||||
`remark` varchar(512) DEFAULT NULL,
|
||||
`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_product_id` (`product_id`),
|
||||
KEY `idx_tenant` (`tenant_no`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='CRM-产品别名';
|
||||
|
||||
ALTER TABLE `crm_quote_product`
|
||||
ADD COLUMN `brand_mode` varchar(20) DEFAULT NULL COMMENT 'required/any' AFTER `product_name`,
|
||||
ADD COLUMN `brand_id` varchar(64) DEFAULT NULL COMMENT '指定品牌ID' AFTER `brand_mode`,
|
||||
ADD COLUMN `brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名快照' AFTER `brand_id`;
|
||||
|
||||
ALTER TABLE `crm_contract_product`
|
||||
ADD COLUMN `brand_mode` varchar(20) DEFAULT NULL COMMENT 'required/any' AFTER `product_name`,
|
||||
ADD COLUMN `brand_id` varchar(64) DEFAULT NULL COMMENT '指定品牌ID' AFTER `brand_mode`,
|
||||
ADD COLUMN `brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名快照' AFTER `brand_id`;
|
||||
|
||||
ALTER TABLE `pl_plan_product`
|
||||
ADD COLUMN `brand_mode` varchar(20) DEFAULT NULL COMMENT 'required/any' AFTER `product_name`,
|
||||
ADD COLUMN `brand_id` varchar(64) DEFAULT NULL COMMENT '指定品牌ID' AFTER `brand_mode`,
|
||||
ADD COLUMN `brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名快照' AFTER `brand_id`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `pl_plan_product_brand_qty` (
|
||||
`id` varchar(64) NOT NULL,
|
||||
`plan_id` varchar(64) NOT NULL,
|
||||
`plan_product_id` varchar(64) NOT NULL,
|
||||
`brand_id` varchar(64) NOT NULL DEFAULT '' COMMENT '空串=无品牌键',
|
||||
`brand_name` varchar(100) DEFAULT NULL,
|
||||
`qty` decimal(15,3) NOT NULL DEFAULT '0.000' COMMENT '本品牌归档实际数量',
|
||||
`tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0',
|
||||
`order_num` int(11) DEFAULT NULL,
|
||||
`remark` varchar(512) DEFAULT NULL,
|
||||
`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_plan_product` (`plan_product_id`),
|
||||
KEY `idx_plan` (`plan_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='计划产品归档品牌数量';
|
||||
|
||||
ALTER TABLE `pl_inventory`
|
||||
ADD COLUMN `brand_id` varchar(64) NOT NULL DEFAULT '' COMMENT '品牌ID,无品牌为空串' AFTER `product_id`,
|
||||
ADD COLUMN `brand_name` varchar(100) DEFAULT NULL COMMENT '品牌名称' AFTER `brand_id`;
|
||||
|
||||
ALTER TABLE `pl_inventory` DROP INDEX `uk_product_id`;
|
||||
ALTER TABLE `pl_inventory` ADD UNIQUE KEY `uk_product_brand` (`tenant_no`,`product_id`,`brand_id`,`del_flag`);
|
||||
|
||||
ALTER TABLE `pl_inventory_transaction`
|
||||
ADD COLUMN `brand_id` varchar(64) NOT NULL DEFAULT '' AFTER `product_id`,
|
||||
ADD COLUMN `brand_name` varchar(100) DEFAULT NULL AFTER `brand_id`;
|
||||
|
||||
ALTER TABLE `pl_inventory_warning`
|
||||
ADD COLUMN `brand_id` varchar(64) NOT NULL DEFAULT '' AFTER `product_id`,
|
||||
ADD COLUMN `brand_name` varchar(100) DEFAULT NULL AFTER `brand_id`;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在本地库执行并核对**
|
||||
|
||||
```bash
|
||||
# 按本地 .env 连接执行
|
||||
mysql -h... -u... -p... shanxia2.0 < backend/scripts/migrate_20260727_crm_product_brand_alias.sql
|
||||
```
|
||||
|
||||
Expected: 无错误;`SHOW CREATE TABLE pl_inventory` 可见 `uk_product_brand`。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/scripts/migrate_20260727_crm_product_brand_alias.sql docs/sql/shanxia2.0.sql
|
||||
git commit -m "chore(db): add product brand/alias and brand-level inventory schema"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: SQLAlchemy 模型
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/crm_models.py`
|
||||
- Modify: `backend/app/models/pl_models.py`
|
||||
- Modify: `backend/app/models/__init__.py`
|
||||
|
||||
- [ ] **Step 1: 在 `crm_models.py` 的 `CrmProduct` 后新增子表,并在 Quote/Contract Product 增加三列**
|
||||
|
||||
```python
|
||||
class CrmProductBrand(Base):
|
||||
__tablename__ = "crm_product_brand"
|
||||
id = Column(String(64), primary_key=True)
|
||||
product_id = Column(String(64), nullable=False)
|
||||
brand_name = Column(String(100), nullable=False)
|
||||
status = Column(String(3), nullable=False, default="on")
|
||||
# …公共字段同 CrmProduct 风格(tenant_no, order_num, remark, create_*, update_*, del_flag)
|
||||
|
||||
|
||||
class CrmProductAlias(Base):
|
||||
__tablename__ = "crm_product_alias"
|
||||
id = Column(String(64), primary_key=True)
|
||||
product_id = Column(String(64), nullable=False)
|
||||
alias_name = Column(String(200), nullable=False)
|
||||
# …公共字段
|
||||
```
|
||||
|
||||
在 `CrmQuoteProduct` / `CrmContractProduct` 于 `product_name` 后增加:
|
||||
|
||||
```python
|
||||
brand_mode = Column(String(20), nullable=True, comment="required/any")
|
||||
brand_id = Column(String(64), nullable=True)
|
||||
brand_name = Column(String(100), nullable=True)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: `pl_models.py` — `PlPlanProduct` 同样三列;新增 `PlPlanProductBrandQty`;`PlInventory`/`Transaction`/`Warning` 增加 `brand_id`/`brand_name`**
|
||||
|
||||
```python
|
||||
class PlPlanProductBrandQty(Base):
|
||||
__tablename__ = "pl_plan_product_brand_qty"
|
||||
id = Column(String(64), primary_key=True)
|
||||
plan_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)
|
||||
# …公共字段
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 导出模型并 commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/models/crm_models.py backend/app/models/pl_models.py backend/app/models/__init__.py
|
||||
git commit -m "feat(models): add product brand/alias and plan brand qty models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 产品 API — 品牌/别名读写
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/crm/product.py`
|
||||
- Modify: `backend/app/crud/crm/product.py`
|
||||
- Test: `backend/tests/test_crm_product_brand_alias.py`(可先测纯替换逻辑;若无集成测试基建,用 crud 层可测函数)
|
||||
|
||||
- [ ] **Step 1: Schema 增加嵌套类型并挂到 Create/Update/详情**
|
||||
|
||||
```python
|
||||
class ProductBrandItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
brand_name: str = Field(..., alias="brandName", max_length=100)
|
||||
status: str = Field(default="on")
|
||||
order_num: Optional[int] = Field(default=None, alias="orderNum")
|
||||
|
||||
class ProductAliasItem(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
id: Optional[str] = None
|
||||
alias_name: str = Field(..., alias="aliasName", max_length=200)
|
||||
order_num: Optional[int] = Field(default=None, alias="orderNum")
|
||||
|
||||
# ProductCreateBody / ProductUpdateBody 增加:
|
||||
# brands: Optional[List[ProductBrandItem]] = None
|
||||
# aliases: Optional[List[ProductAliasItem]] = None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在 `product.py` CRUD 的 `_row_to_dict` 附带 brands/aliases;create/update 全量替换子表(软删旧行 + 插入新行)**
|
||||
|
||||
替换规则:
|
||||
|
||||
- update 时:该 `product_id` 下原 `del_flag=0` 行置 `del_flag=1`,再按 payload 插入(有 id 且仍属于本产品可复用 id)。
|
||||
- 删除品牌前:若 `pl_inventory` 存在该 `brand_id` 且 `stock_qty>0`,或未归档计划行 `brand_id` 引用,则 raise HTTP 400(可抽 `_assert_brand_deletable`)。
|
||||
|
||||
- [ ] **Step 3: 手工或脚本验证**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate
|
||||
python -c "from app.schemas.crm.product import ProductCreateBody; print(ProductCreateBody.model_json_schema()['properties'].keys())"
|
||||
```
|
||||
|
||||
Expected: schema 含 `brands`/`aliases`(或 camelCase 别名)。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/crm/product.py backend/app/crud/crm/product.py
|
||||
git commit -m "feat(crm): persist product brands and aliases on create/update"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 产品管理前端 UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/crm/product.ts`(类型)
|
||||
- Modify: `web-admin/src/views/crm/product/index.vue`
|
||||
|
||||
- [ ] **Step 1: 类型增加 `brands` / `aliases`**
|
||||
|
||||
```ts
|
||||
export interface ProductBrandItem {
|
||||
id?: string
|
||||
brandName: string
|
||||
status?: string
|
||||
orderNum?: number
|
||||
}
|
||||
export interface ProductAliasItem {
|
||||
id?: string
|
||||
aliasName: string
|
||||
orderNum?: number
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 在新增/编辑弹窗(`dialog-form-layout`、`label-position="top"`)增加两块可编辑表格**
|
||||
|
||||
- 品牌:列「品牌名称」「状态」;按钮新增行;删除行
|
||||
- 别名:列「别名」;增删
|
||||
- `formData.brands` / `formData.aliases` 随提交一起 `fetchProductAdd` / `fetchProductUpdate`
|
||||
- 列表增加「品牌」列:`brands.map(b => b.brandName).join('、')`,过长省略
|
||||
|
||||
- [ ] **Step 3: 浏览器冒烟** — 新建产品带 2 品牌 1 别名 → 再编辑可见
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/crm/product.ts web-admin/src/views/crm/product/index.vue
|
||||
git commit -m "feat(web-admin): edit product brands and aliases in product form"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 报价/合同行 — 后端品牌约定
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/crud/crm/quote_product.py`
|
||||
- Modify: `backend/app/crud/crm/contract_product.py`
|
||||
- Modify: 报价/合同 create/update 组装明细处(`backend/app/crud/crm/quote.py`、`contract.py` 或对应 API)
|
||||
- Modify: 报价转合同拷贝逻辑(搜索 `quote_product` → `contract_product` 字段映射)
|
||||
|
||||
- [ ] **Step 1: `_row_to_dict` 增加**
|
||||
|
||||
```python
|
||||
"brandMode": row.brand_mode,
|
||||
"brandId": row.brand_id,
|
||||
"brandName": row.brand_name,
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 保存明细时写入三字段;校验**
|
||||
|
||||
```python
|
||||
def normalize_line_brand(*, brand_mode, brand_id, brand_name, product_has_brands: bool):
|
||||
if not product_has_brands:
|
||||
return None, None, None
|
||||
mode = brand_mode or "any"
|
||||
if mode == "required":
|
||||
if not brand_id or not brand_name:
|
||||
raise ValueError("指定品牌时必须选择品牌")
|
||||
return "required", brand_id, brand_name
|
||||
if mode == "any":
|
||||
return "any", None, None
|
||||
raise ValueError("无效的 brand_mode")
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 报价→合同、合同→计划 拷贝时带上三字段**
|
||||
|
||||
`create-from-contract`(`backend/app/api/pl/plan.py`)创建 `PlPlanProduct` 时:
|
||||
|
||||
```python
|
||||
brand_mode=cp.brand_mode,
|
||||
brand_id=cp.brand_id,
|
||||
brand_name=cp.brand_name,
|
||||
product_name=cp.product_name, # 已是展示名快照
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/crud/crm/quote_product.py backend/app/crud/crm/contract_product.py backend/app/crud/crm/quote.py backend/app/crud/crm/contract.py backend/app/api/pl/plan.py
|
||||
git commit -m "feat(crm): snapshot brand mode on quote and contract lines"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 报价/合同前端行交互
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/crm/quote/types.ts`
|
||||
- Modify: `web-admin/src/api/crm/contract/types.ts`
|
||||
- Modify: `web-admin/src/views/crm/quote/components/ProductSelector.vue`(及明细表格所在 Form)
|
||||
- Modify: `web-admin/src/views/crm/contract/components/ProductSelector.vue`(及 Form)
|
||||
- Modify: 详情弹窗只读展示品牌列
|
||||
|
||||
- [ ] **Step 1: 行类型增加 `brandMode?` `brandId?` `brandName?`**
|
||||
|
||||
- [ ] **Step 2: 选品后**
|
||||
|
||||
1. 拉取产品详情(含 brands/aliases)
|
||||
2. 有 aliases:产品名称用 `el-select`(选项:标准名 + 各 aliasName),`v-model` 绑行 `productName`
|
||||
3. 有 brands:`brandMode` 默认 `'any'`;`'required'` 时品牌下拉必填,写入 `brandId`/`brandName`
|
||||
4. 无 brands:不渲染品牌控件,提交时三字段为 `null`/省略
|
||||
|
||||
- [ ] **Step 3: 详情/打印区域显示**:指定 → 品牌名;不限 → 「不限品牌」
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/crm/quote web-admin/src/api/crm/contract web-admin/src/views/crm/quote web-admin/src/views/crm/contract
|
||||
git commit -m "feat(web-admin): choose display name and brand constraint on quote/contract lines"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 计划展示品牌约定
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/schemas/pl/plan.py`(`PlanProductBase` 增加三字段)
|
||||
- Modify: `web-admin/src/api/pl/index.ts`(`PlanProduct`)
|
||||
- Modify: 计划表单/详情产品表(如 `web-admin/src/components/business/pl-plan-form-dialog/index.vue`、`PlPlanFormProductTable`)
|
||||
|
||||
- [ ] **Step 1: Schema + TS 增加字段并确保 read API 序列化带出**
|
||||
|
||||
- [ ] **Step 2: 产品表增加「品牌」列**
|
||||
|
||||
- `brandMode==='required'` → 显示 `brandName`
|
||||
- `brandMode==='any'` → `<el-tag size="small">不限</el-tag>`
|
||||
- 否则 → `—`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/schemas/pl/plan.py web-admin/src/api/pl/index.ts web-admin/src/components/business/pl-plan-form-dialog
|
||||
git commit -m "feat(pl): show brand constraint on plan product rows"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 归档品牌量校验 + 持久化(TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/pl_brand_archive.py`
|
||||
- Create: `backend/tests/test_pl_brand_archive.py`
|
||||
- Modify: `backend/app/schemas/pl/plan.py` — `PlanArchiveProductItem`
|
||||
- Modify: `backend/app/services/pl_plan_service.py` — `apply_archive_product_quantities`
|
||||
|
||||
- [ ] **Step 1: 写失败测试**
|
||||
|
||||
```python
|
||||
# backend/tests/test_pl_brand_archive.py
|
||||
from decimal import Decimal
|
||||
import pytest
|
||||
from app.services.pl_brand_archive import validate_brand_qty_rows
|
||||
|
||||
def test_required_allows_only_that_brand():
|
||||
with pytest.raises(ValueError, match="指定品牌"):
|
||||
validate_brand_qty_rows(
|
||||
brand_mode="required",
|
||||
required_brand_id="b1",
|
||||
actual_qty=Decimal("10"),
|
||||
rows=[{"brandId": "b2", "brandName": "红四方", "qty": Decimal("10")}],
|
||||
)
|
||||
|
||||
def test_any_sum_must_match():
|
||||
with pytest.raises(ValueError, match="合计"):
|
||||
validate_brand_qty_rows(
|
||||
brand_mode="any",
|
||||
required_brand_id=None,
|
||||
actual_qty=Decimal("10"),
|
||||
rows=[
|
||||
{"brandId": "b1", "brandName": "君正", "qty": Decimal("6")},
|
||||
{"brandId": "b2", "brandName": "红四方", "qty": Decimal("3")},
|
||||
],
|
||||
)
|
||||
|
||||
def test_any_ok():
|
||||
validate_brand_qty_rows(
|
||||
brand_mode="any",
|
||||
required_brand_id=None,
|
||||
actual_qty=Decimal("10"),
|
||||
rows=[
|
||||
{"brandId": "b1", "brandName": "君正", "qty": Decimal("6")},
|
||||
{"brandId": "b2", "brandName": "红四方", "qty": Decimal("4")},
|
||||
],
|
||||
)
|
||||
|
||||
def test_no_brand_ignores_rows():
|
||||
validate_brand_qty_rows(
|
||||
brand_mode=None,
|
||||
required_brand_id=None,
|
||||
actual_qty=Decimal("5"),
|
||||
rows=[],
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 跑测确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_pl_brand_archive.py -v
|
||||
```
|
||||
|
||||
Expected: FAIL(模块不存在或函数未定义)
|
||||
|
||||
- [ ] **Step 3: 实现校验**
|
||||
|
||||
```python
|
||||
# backend/app/services/pl_brand_archive.py
|
||||
from decimal import Decimal
|
||||
|
||||
def validate_brand_qty_rows(*, brand_mode, required_brand_id, actual_qty: Decimal, rows: list[dict]) -> None:
|
||||
actual_qty = Decimal(str(actual_qty))
|
||||
if not brand_mode:
|
||||
return
|
||||
positive = [r for r in rows if Decimal(str(r.get("qty") or 0)) > 0]
|
||||
if brand_mode == "required":
|
||||
if len(positive) != 1 or positive[0].get("brandId") != required_brand_id:
|
||||
raise ValueError("指定品牌时只能填写该品牌数量")
|
||||
if Decimal(str(positive[0]["qty"])) != actual_qty:
|
||||
raise ValueError("指定品牌数量必须等于归档实际数量")
|
||||
return
|
||||
if brand_mode == "any":
|
||||
total = sum(Decimal(str(r.get("qty") or 0)) for r in rows)
|
||||
if total != actual_qty:
|
||||
raise ValueError("各品牌数量合计必须等于归档实际数量")
|
||||
return
|
||||
raise ValueError("无效的 brand_mode")
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 扩展归档 body**
|
||||
|
||||
```python
|
||||
class PlanArchiveBrandQtyItem(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 PlanArchiveProductItem(BaseModel):
|
||||
id: str
|
||||
actual_qty: Decimal = Field(..., alias="actualQty", ge=0)
|
||||
brand_qtys: Optional[list[PlanArchiveBrandQtyItem]] = Field(default=None, alias="brandQtys")
|
||||
model_config = {"populate_by_name": True}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 在 `apply_archive_product_quantities` 末尾**:校验 → 软删旧 `PlPlanProductBrandQty` → 插入新行(无品牌则跳过插入);行上 `actual_qty`/`inbound_qty`/`outbound_qty` 仍写合计
|
||||
|
||||
- [ ] **Step 6: pytest 通过后 commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/services/pl_brand_archive.py backend/tests/test_pl_brand_archive.py backend/app/schemas/pl/plan.py backend/app/services/pl_plan_service.py
|
||||
git commit -m "feat(pl): validate and persist archive quantities by brand"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 归档写库存(产品+品牌)— 新建落账服务
|
||||
|
||||
**现状:** `pl_inventory` / `pl_inventory_transaction` 已有读 API,归档路径未写流水。本 Task 新建落账。
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/app/services/pl_inventory_service.py`
|
||||
- Modify: `backend/tests/test_pl_brand_archive.py`(或新建 `test_pl_inventory_service.py`)
|
||||
- Modify: `backend/app/api/pl/plan.py` `archive_plan` / `apply_archive_product_quantities` 调用链
|
||||
- Modify: `backend/app/api/pl/inventory.py` + schemas(返回 brand 字段;按 product+brand 查)
|
||||
|
||||
- [ ] **Step 1: 测试 — 出库不足拦截;入库增加库存**
|
||||
|
||||
```python
|
||||
def test_apply_stock_change_outbound_insufficient():
|
||||
# 用简易 stub:内存 dict 模拟 get_or_create + update
|
||||
# 或 Session mock;优先测纯函数 decide_qty_delta + assert_enough
|
||||
from app.services.pl_inventory_service import assert_outbound_enough
|
||||
with pytest.raises(ValueError, match="库存不足"):
|
||||
assert_outbound_enough(stock_qty=Decimal("1"), outbound_qty=Decimal("2"), brand_label="君正")
|
||||
|
||||
def test_delta_for_plan_type():
|
||||
from app.services.pl_inventory_service import qty_delta_for_plan
|
||||
assert qty_delta_for_plan("inbound", Decimal("3")) == Decimal("3")
|
||||
assert qty_delta_for_plan("outbound", Decimal("3")) == Decimal("-3")
|
||||
assert qty_delta_for_plan("direct", Decimal("3")) == Decimal("0") # 直销是否动库存:首期不动,与产品确认;默认 0
|
||||
```
|
||||
|
||||
说明:若业务要求直销也扣库存,把 `direct` 改为负数并在本 Task 注释与 spec 对齐后改测试。
|
||||
|
||||
- [ ] **Step 2: 实现**
|
||||
|
||||
```python
|
||||
# backend/app/services/pl_inventory_service.py
|
||||
from decimal import Decimal
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.pl_models import PlInventory, PlInventoryTransaction
|
||||
|
||||
EMPTY_BRAND_ID = ""
|
||||
|
||||
def qty_delta_for_plan(plan_type: str, qty: Decimal) -> Decimal:
|
||||
if plan_type == "inbound":
|
||||
return Decimal(str(qty))
|
||||
if plan_type == "outbound":
|
||||
return -Decimal(str(qty))
|
||||
return Decimal("0") # direct: 首期不记账
|
||||
|
||||
def assert_outbound_enough(*, stock_qty: Decimal, outbound_qty: Decimal, brand_label: str) -> None:
|
||||
if Decimal(str(outbound_qty)) > Decimal(str(stock_qty)):
|
||||
raise ValueError(f"{brand_label}库存不足")
|
||||
|
||||
def get_or_create_inventory(db, *, tenant_no, product_id, product_no, product_name, brand_id, brand_name, unit, model, user_id, dept_id) -> PlInventory:
|
||||
brand_id = brand_id or EMPTY_BRAND_ID
|
||||
row = (
|
||||
db.query(PlInventory)
|
||||
.filter(
|
||||
PlInventory.tenant_no == tenant_no,
|
||||
PlInventory.product_id == product_id,
|
||||
PlInventory.brand_id == brand_id,
|
||||
PlInventory.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if row:
|
||||
return row
|
||||
row = PlInventory(
|
||||
id=generate_uuid(),
|
||||
tenant_no=tenant_no,
|
||||
product_id=product_id,
|
||||
brand_id=brand_id,
|
||||
brand_name=brand_name,
|
||||
product_no=product_no,
|
||||
product_name=product_name,
|
||||
model=model,
|
||||
unit=unit or "吨",
|
||||
stock_qty=0,
|
||||
stock_amount=0,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
|
||||
def apply_brand_stock_change(db: Session, *, tenant_no, plan, plan_product, brand_id, brand_name, qty, user_id, dept_id) -> None:
|
||||
delta = qty_delta_for_plan(plan.plan_type, Decimal(str(qty)))
|
||||
if delta == 0:
|
||||
return
|
||||
inv = get_or_create_inventory(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
product_id=plan_product.product_id,
|
||||
product_no=plan_product.product_no,
|
||||
product_name=plan_product.product_name,
|
||||
brand_id=brand_id or EMPTY_BRAND_ID,
|
||||
brand_name=brand_name,
|
||||
unit=plan_product.unit,
|
||||
model=plan_product.model,
|
||||
user_id=user_id,
|
||||
dept_id=dept_id,
|
||||
)
|
||||
if delta < 0:
|
||||
try:
|
||||
assert_outbound_enough(
|
||||
stock_qty=Decimal(str(inv.stock_qty or 0)),
|
||||
outbound_qty=-delta,
|
||||
brand_label=brand_name or plan_product.product_name,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
inv.stock_qty = Decimal(str(inv.stock_qty or 0)) + delta
|
||||
inv.update_by = user_id
|
||||
db.add(
|
||||
PlInventoryTransaction(
|
||||
id=generate_uuid(),
|
||||
tenant_no=tenant_no,
|
||||
product_id=plan_product.product_id,
|
||||
brand_id=brand_id or EMPTY_BRAND_ID,
|
||||
brand_name=brand_name,
|
||||
product_name=plan_product.product_name,
|
||||
transaction_type="inbound" if delta > 0 else "outbound",
|
||||
plan_id=plan.id,
|
||||
plan_no=plan.plan_no,
|
||||
qty=delta,
|
||||
unit_price=plan_product.inbound_price or plan_product.outbound_price or plan_product.tax_price or 0,
|
||||
amount=abs(delta) * Decimal(str(plan_product.inbound_price or plan_product.outbound_price or plan_product.tax_price or 0)),
|
||||
stock_after=inv.stock_qty,
|
||||
create_by=user_id,
|
||||
create_dept_id=dept_id or "",
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 归档成功路径调用** — 对每个产品:有 `brand_qtys` 则逐品牌调用;无品牌则 `brand_id=""` 用 `actual_qty` 调一次。
|
||||
**调整归档(已归档再改)**:需先按旧品牌量反向冲销再按新量入账,或「删旧流水+按差额调整」——首期建议:调整时按新旧 `brand_qty` 差量记账(实现 `diff_and_apply`);若过复杂,首期禁止已记账后再改数量(返回 400「已归档库存已记账,请联系管理员」)。**选定:首期调整归档若已产生该 plan 的流水,则按 plan_id 汇总冲销旧流水再按新量重记**(实现清晰)。
|
||||
|
||||
- [ ] **Step 4: inventory API Read 增加 brandId/brandName;warning-threshold 按 product+brand**
|
||||
|
||||
- [ ] **Step 5: pytest + commit**
|
||||
|
||||
```bash
|
||||
cd backend && source venv/bin/activate && pytest tests/test_pl_brand_archive.py tests/test_pl_inventory_service.py -v
|
||||
git add backend/app/services/pl_inventory_service.py backend/app/api/pl/plan.py backend/app/api/pl/inventory.py backend/app/schemas/pl/inventory.py backend/tests
|
||||
git commit -m "feat(pl): post brand-level inventory on plan archive"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 10: 归档弹窗按品牌填量 UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/api/pl/index.ts` — `PlanArchiveProductItem.brandQtys`
|
||||
- Modify: `web-admin/src/components/business/pl-plan-archive-dialog/index.vue`
|
||||
- 可能修改: `PlPlanFormProductTable` 或在归档弹窗内嵌品牌量表
|
||||
|
||||
- [ ] **Step 1: 打开归档时**,对 `brandMode` 有值的行展开品牌行:
|
||||
|
||||
- `required`:一行,品牌只读,`qty` 默认 = `actualQty`
|
||||
- `any`:拉取产品 brands 列表生成多行,`qty` 默认可 0;监听各品牌 qty 之和写回 `actualQty`(或校验时比较)
|
||||
- 无品牌:保持现有 `PlPlanFormProductTable` 只填 `actualQty`
|
||||
|
||||
- [ ] **Step 2: 提交 body**
|
||||
|
||||
```ts
|
||||
{
|
||||
products: productRows.map((p) => ({
|
||||
id: p.id,
|
||||
actualQty: p.actualQty,
|
||||
brandQtys: p.brandMode
|
||||
? (p.brandQtyRows || [])
|
||||
.filter((r) => Number(r.qty) > 0)
|
||||
.map((r) => ({ brandId: r.brandId, brandName: r.brandName, qty: r.qty }))
|
||||
: undefined
|
||||
})),
|
||||
freight: ...
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 前端合计不符时禁用提交并提示**
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/api/pl/index.ts web-admin/src/components/business/pl-plan-archive-dialog
|
||||
git commit -m "feat(web-admin): enter archive inbound/outbound qty by brand"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 11: 库存台账 / 流水 UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `web-admin/src/views/pl/inventory/ledger.vue`
|
||||
- Modify: 流水列表页(若独立;否则 inventory 下 transaction 视图)
|
||||
- Modify: `web-admin/src/api/pl/index.ts` inventory 类型
|
||||
|
||||
- [ ] **Step 1: 增加「品牌」列;无品牌显示 —**
|
||||
|
||||
- [ ] **Step 2: 预警阈值设置带上 brandId(与后端 Task 9 对齐)**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web-admin/src/views/pl/inventory web-admin/src/api/pl/index.ts
|
||||
git commit -m "feat(web-admin): show brand on inventory ledger and transactions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 12: 入出库单展示实际品牌(只读)
|
||||
|
||||
**Files:**
|
||||
- 若入出库明细来自计划产品:扩展 IO item 只读字段或从 `pl_plan_product_brand_qty` 聚合展示
|
||||
- Modify: `web-admin/src/views/pl/_shared/IoOrderListPage.vue` / 详情组件
|
||||
|
||||
- [ ] **Step 1: 后端 IO 详情附带品牌摘要**(如 `brandSummary: "君正 6 / 红四方 4"`)——优先简单字符串,避免大改 IO 表结构
|
||||
|
||||
- [ ] **Step 2: 前端详情展示该摘要**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add backend/app/api/pl/io_order.py web-admin/src/views/pl
|
||||
git commit -m "feat(pl): show archive brand breakdown on inbound/outbound orders"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 13: 历史名称清洗(可选脚本,需业务确认后执行)
|
||||
|
||||
**Files:**
|
||||
- Create: `backend/scripts/migrate_20260727_split_brand_from_product_name.py`(或 `.sql` + 人工清单)
|
||||
|
||||
- [ ] **Step 1: 导出名称匹配 `(...)` 的产品清单 CSV,给业务确认**
|
||||
|
||||
- [ ] **Step 2: 确认后:更新 `product_name` 去括号段;插入 `crm_product_brand`;**不改**历史报价/合同 `product_name` 快照
|
||||
|
||||
- [ ] **Step 3: Commit 脚本(未执行也可入库)**
|
||||
|
||||
```bash
|
||||
git add backend/scripts/migrate_20260727_split_brand_from_product_name.py
|
||||
git commit -m "chore(db): add optional script to split brand from product names"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 14: 字段一致性与回归
|
||||
|
||||
- [ ] **Step 1:** `cd backend && python scripts/check_field_consistency.py`(若覆盖 CRM product;按报错补 Create/Update/前端)
|
||||
|
||||
- [ ] **Step 2: 手工回归清单**
|
||||
|
||||
1. 无品牌产品:报价→合同→计划→归档→库存(`brand_id=''`)与现网一致
|
||||
2. 指定品牌:归档只能填该品牌;出库库存不足被拦
|
||||
3. 不限品牌:多品牌分摊合计校验;台账分品牌
|
||||
4. 别名:合同展示别名;产品标准名不变
|
||||
5. 调整归档:库存冲销后重记正确
|
||||
|
||||
- [ ] **Step 3: `./scripts/update-graph.sh all`(或 backend + frontend)后 commit 图谱若有变更**
|
||||
|
||||
- [ ] **Step 4: 最终 commit(若有修复)**
|
||||
|
||||
```bash
|
||||
git commit -m "fix: brand/alias field consistency and regression fixes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Spec 覆盖自检
|
||||
|
||||
| Spec 条目 | Task |
|
||||
|-----------|------|
|
||||
| 产品品牌/别名子表 | 1–4 |
|
||||
| 报价/合同 brand_mode 快照 + 展示名 | 5–6 |
|
||||
| 计划带入约束不拆行 | 5, 7 |
|
||||
| 归档按品牌填量 | 8, 10 |
|
||||
| 库存产品+品牌 + 出库强校验 | 9, 11 |
|
||||
| 入出库展示 | 12 |
|
||||
| 历史清洗 | 13 |
|
||||
| 首期不做全局品牌/客户别名/计划拆行 | 未列入 |
|
||||
|
||||
**直销单是否动库存:** Task 9 默认不动;若业务要扣,改 `qty_delta_for_plan("direct")` 并更新测试。
|
||||
|
||||
---
|
||||
|
||||
## 执行说明
|
||||
|
||||
实现时严格按 Task 顺序;每 Task 结束再开下一 Task。优先 subagent-driven-development。
|
||||
Reference in new issue
Block a user