From a79dd9f61dcfb177bdc2e6fb57fa0e690145478d Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 27 Jul 2026 21:47:38 +0800 Subject: [PATCH] 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 --- .../2026-07-27-crm-product-brand-alias.md | 834 ++++++++++++++++++ 1 file changed, 834 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-27-crm-product-brand-alias.md diff --git a/docs/superpowers/plans/2026-07-27-crm-product-brand-alias.md b/docs/superpowers/plans/2026-07-27-crm-product-brand-alias.md new file mode 100644 index 000000000..9791d2632 --- /dev/null +++ b/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'` → `不限` +- 否则 → `—` + +- [ ] **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。