Sfoglia il codice sorgente

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>
zlwu 3 mesi fa
parent
commit
a79dd9f61d
1 ha cambiato i file con 834 aggiunte e 0 eliminazioni
  1. 834 0
      docs/superpowers/plans/2026-07-27-crm-product-brand-alias.md

+ 834 - 0
docs/superpowers/plans/2026-07-27-crm-product-brand-alias.md

@@ -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。