From 27c1cbba15cc1dd3f02a0115b5dd8b3c16c4084b Mon Sep 17 00:00:00 2001 From: zlwu Date: Thu, 3 Sep 2026 16:19:16 +0800 Subject: [PATCH] docs: add plan-archive price permission implementation plan Break the approved design into SQL, backend mask/API, and web-admin permission wiring tasks with verification steps. Co-authored-by: Cursor --- ...-09-03-pl-plan-archive-price-permission.md | 566 ++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-03-pl-plan-archive-price-permission.md diff --git a/docs/superpowers/plans/2026-09-03-pl-plan-archive-price-permission.md b/docs/superpowers/plans/2026-09-03-pl-plan-archive-price-permission.md new file mode 100644 index 000000000..0b782bdd5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-03-pl-plan-archive-price-permission.md @@ -0,0 +1,566 @@ +# 运输计划归档价格权限 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:** 在现有【运输计划归档】页按按钮权限控制「归档操作」与「单价/金额可见」,前端隐藏 + 后端写拦截/读脱敏,不新建独立菜单页。 + +**Architecture:** 复用 `pl:plan-archive:archive`,新增 `pl:plan-archive:price`。后端用 `get_user_menu_codes`(对齐 `force-follow-status`)校验写接口;用纯函数对 `PlanWithProducts` 脱敏产品归档价与计划运费价。前端用 `fetchGetMenuCodes` 控制按钮/列/查看弹窗/导出列。 + +**Tech Stack:** FastAPI · SQLAlchemy · `sys_menu`/`sys_role_menu` · Vue 3 · Element Plus · `fetchGetMenuCodes` + +**Spec:** `docs/superpowers/specs/2026-09-03-pl-plan-archive-price-permission-design.md` + +--- + +## File map + +| 文件 | 职责 | +|------|------| +| `docs/sql/migrations/2026-09-03-pl-plan-archive-price-perm.sql` | 插入 `PL_PLAN_ARCHIVE_PRICE`;为已有归档页角色补授权 | +| `docs/sql/shanxia2.0.sql` | 基线同步插入 `sys_menu` | +| `backend/app/services/pl_plan_price_mask.py` | 权限判断 + `PlanWithProducts` 脱敏纯函数 | +| `backend/tests/test_pl_plan_price_mask.py` | 脱敏单测 | +| `backend/app/api/pl/plan.py` | archive 写校验;list/get/export 调用脱敏/去列 | +| `web-admin/src/views/pl/plan-archive/index.vue` | 加载权限;控按钮/列/弹窗 props/导出 | +| `web-admin/src/components/business/pl-plan-view-dialog/index.vue` | 透传 `showArchivePriceFields` | +| `web-admin/src/components/business/pl-plan-view-tabs/index.vue` | 价字段受 `showArchivePriceFields`;运费区隐藏价 | +| `web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue` | 归档 IO 价列受 `showArchivePriceFields` | +| `web-admin/src/components/business/pl-plan-archive-brand-view-block/index.vue` | 查看态品牌表可隐藏单价金额列 | +| `web-admin/src/components/business/pl-plan-transport-feedback-blocks/index.vue` | 若透传 showArchiveIoFields,同步价格开关 | + +**已知副作用(按设计接受):** 无 `pl:plan-archive:price` 时,`list_plans`/`get_plan` 会清空 `freightAmount`/`totalAmount` 及产品归档价。运输计划主列表/查看若也调同一接口,运费金额会对无该权限用户不可见。运费结算等独立模块本期不改。 + +--- + +### Task 1: 菜单 SQL + 角色补授权 + +**Files:** +- Create: `docs/sql/migrations/2026-09-03-pl-plan-archive-price-perm.sql` +- Modify: `docs/sql/shanxia2.0.sql`(在 `PL_PLAN_ARCHIVE_Q` 附近追加 `PL_PLAN_ARCHIVE_PRICE`) + +- [ ] **Step 1: 写增量迁移** + +```sql +-- 运输计划归档:查看单价金额按钮权限 +-- 设计: docs/superpowers/specs/2026-09-03-pl-plan-archive-price-permission-design.md + +SET NAMES utf8mb4; + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'PL_PLAN_ARCHIVE_PRICE', '查看单价金额', 'PL_PLAN_ARCHIVE', 'F', '', '', + 'pl:plan-archive:price', 1, '1', '1', 'shanxia2.0', 4, NULL, + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_PLAN_ARCHIVE_PRICE' AND `del_flag` = 0 +); + +-- 已拥有归档页(父菜单)的角色:补 price +INSERT INTO `sys_role_menu` ( + `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + REPLACE(UUID(), '-', ''), + rm.role_id, + 'PL_PLAN_ARCHIVE_PRICE', + rm.tenant_no, + 0, + NULL, + 'system', + '', + NOW(), + NULL, + NOW(), + 0 +FROM `sys_role_menu` rm +WHERE rm.menu_id = 'PL_PLAN_ARCHIVE' + AND rm.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_menu` x + WHERE x.role_id = rm.role_id + AND x.menu_id = 'PL_PLAN_ARCHIVE_PRICE' + AND x.del_flag = 0 + ); + +-- 已拥有「归档」按钮的角色:补 price(防止仅有按钮无父菜单行时漏补) +INSERT INTO `sys_role_menu` ( + `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + REPLACE(UUID(), '-', ''), + rm.role_id, + 'PL_PLAN_ARCHIVE_PRICE', + rm.tenant_no, + 0, + NULL, + 'system', + '', + NOW(), + NULL, + NOW(), + 0 +FROM `sys_role_menu` rm +WHERE rm.menu_id = 'PL_PLAN_ARCHIVE_A' + AND rm.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_menu` x + WHERE x.role_id = rm.role_id + AND x.menu_id = 'PL_PLAN_ARCHIVE_PRICE' + AND x.del_flag = 0 + ); +``` + +- [ ] **Step 2: 同步 `docs/sql/shanxia2.0.sql` 的 `sys_menu` INSERT** + +在现有 `PL_PLAN_ARCHIVE_Q` 行后增加与上相同字段的 `PL_PLAN_ARCHIVE_PRICE` 插入(`order_num=4`,`perms=pl:plan-archive:price`)。 + +- [ ] **Step 3: Commit** + +```bash +git add docs/sql/migrations/2026-09-03-pl-plan-archive-price-perm.sql docs/sql/shanxia2.0.sql +git commit -m "chore(sql): add pl:plan-archive:price menu and backfill roles" +``` + +--- + +### Task 2: 后端脱敏纯函数(TDD) + +**Files:** +- Create: `backend/app/services/pl_plan_price_mask.py` +- Create: `backend/tests/test_pl_plan_price_mask.py` + +- [ ] **Step 1: Write the failing test** + +```python +from datetime import date, datetime +from decimal import Decimal + +from app.schemas.pl.plan import ( + PlanLoadRead, + PlanProductRead, + PlanStopProductRead, + PlanWithProducts, +) +from app.services.pl_plan_price_mask import mask_plan_with_products_prices + + +def _minimal_plan(**kwargs): + base = dict( + id="p1", + plan_no="P001", + create_date=date(2026, 9, 1), + total_amount=Decimal("100.00"), + freight_amount=Decimal("50.00"), + tenant_no="t1", + create_time=datetime(2026, 9, 1), + update_time=datetime(2026, 9, 1), + products=[], + deliveries=[], + ) + base.update(kwargs) + return PlanWithProducts.model_validate(base) + + +def test_mask_clears_plan_and_product_prices(): + product = PlanProductRead.model_validate( + { + "id": "pp1", + "planId": "p1", + "tenantNo": "t1", + "createTime": "2026-09-01T00:00:00", + "updateTime": "2026-09-01T00:00:00", + "productId": "g1", + "productNo": "N1", + "productName": "尿素", + "inboundPrice": "12.5", + "inboundAmount": "125", + "outboundPrice": "13", + "outboundAmount": "130", + "brandQtys": [ + {"brandId": "b1", "brandName": "君正", "qty": "10", "unitPrice": "12.5"} + ], + } + ) + stop_product = PlanStopProductRead.model_validate( + { + "id": "sp1", + "planId": "p1", + "tenantNo": "t1", + "createTime": "2026-09-01T00:00:00", + "updateTime": "2026-09-01T00:00:00", + "productId": "g1", + "productNo": "N1", + "productName": "尿素", + "inboundPrice": "12.5", + "inboundAmount": "125", + "brandQtys": [{"brandId": "b1", "qty": "10", "unitPrice": "12.5"}], + } + ) + plan = _minimal_plan( + products=[product], + load=PlanLoadRead.model_validate( + { + "id": "l1", + "planId": "p1", + "stopKind": "load", + "products": [stop_product], + "tenantNo": "t1", + "createTime": "2026-09-01T00:00:00", + "updateTime": "2026-09-01T00:00:00", + } + ), + ) + out = mask_plan_with_products_prices(plan) + assert out.total_amount == Decimal("0") + assert out.freight_amount == Decimal("0") + assert out.products[0].inbound_price is None + assert out.products[0].inbound_amount is None + assert out.products[0].outbound_price is None + assert out.products[0].outbound_amount is None + assert out.products[0].brand_qtys[0].unit_price is None + assert out.load.products[0].inbound_price is None + assert out.load.products[0].brand_qtys[0].unit_price is None + + +def test_mask_is_noop_when_disabled(): + plan = _minimal_plan(total_amount=Decimal("9"), freight_amount=Decimal("8")) + out = mask_plan_with_products_prices(plan, enabled=False) + assert out.total_amount == Decimal("9") + assert out.freight_amount == Decimal("8") +``` + +若 schema 必填字段与上不符,按 `backend/app/schemas/pl/plan.py` 补齐,保持断言语义。 + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_pl_plan_price_mask.py -v +``` + +Expected: FAIL(模块不存在) + +- [ ] **Step 3: Implement `backend/app/services/pl_plan_price_mask.py`** + +```python +from __future__ import annotations + +from decimal import Decimal + +from sqlalchemy.orm import Session + +from app.core.menu_guard import SUPER_ADMIN_ROLE, get_user_menu_codes +from app.schemas.pl.plan import PlanWithProducts + +ARCHIVE_PRICE_PERM = "pl:plan-archive:price" +ARCHIVE_PERM = "pl:plan-archive:archive" + + +def user_has_menu_perm(db: Session, user, perm: str) -> bool: + if getattr(user, "role", None) == SUPER_ADMIN_ROLE: + return True + codes = get_user_menu_codes(db, user.id) + return perm in codes + + +def user_can_view_archive_prices(db: Session, user) -> bool: + return user_has_menu_perm(db, user, ARCHIVE_PRICE_PERM) + + +def user_can_archive_plan(db: Session, user) -> bool: + return user_has_menu_perm(db, user, ARCHIVE_PERM) + + +def _mask_brand_qtys(brand_qtys): + if not brand_qtys: + return brand_qtys + return [bq.model_copy(update={"unit_price": None}) for bq in brand_qtys] + + +def _mask_product_like(item): + return item.model_copy( + update={ + "inbound_price": None, + "inbound_amount": None, + "outbound_price": None, + "outbound_amount": None, + "brand_qtys": _mask_brand_qtys(getattr(item, "brand_qtys", None)), + } + ) + + +def mask_plan_with_products_prices( + plan: PlanWithProducts, *, enabled: bool = True +) -> PlanWithProducts: + if not enabled: + return plan + products = [_mask_product_like(p) for p in (plan.products or [])] + load = plan.load + if load is not None and getattr(load, "products", None): + load = load.model_copy( + update={"products": [_mask_product_like(p) for p in load.products]} + ) + deliveries = [] + for d in plan.deliveries or []: + if getattr(d, "products", None): + deliveries.append( + d.model_copy( + update={"products": [_mask_product_like(p) for p in d.products]} + ) + ) + else: + deliveries.append(d) + return plan.model_copy( + update={ + "total_amount": Decimal("0"), + "freight_amount": Decimal("0"), + "products": products, + "load": load, + "deliveries": deliveries, + } + ) +``` + +说明:`PlanRead.total_amount` / `freight_amount` 非 Optional,脱敏用 `Decimal("0")`。 + +- [ ] **Step 4: Run tests** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_pl_plan_price_mask.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/services/pl_plan_price_mask.py backend/tests/test_pl_plan_price_mask.py +git commit -m "feat(backend): add plan archive price mask helpers" +``` + +--- + +### Task 3: 接线 plan API(写拦截 + 读脱敏 + 导出去列) + +**Files:** +- Modify: `backend/app/api/pl/plan.py` + +- [ ] **Step 1: import 与导出字段常量** + +```python +from app.services.pl_plan_price_mask import ( + mask_plan_with_products_prices, + user_can_archive_plan, + user_can_view_archive_prices, +) + +ARCHIVE_PRICE_EXPORT_FIELDS = frozenset( + {"unitPrice", "amount", "freightPrice", "freightAmount"} +) +``` + +- [ ] **Step 2: `list_plans` / `get_plan` 返回前** + +```python +can_price = user_can_view_archive_prices(db, user) +plan_item = mask_plan_with_products_prices(plan_item, enabled=not can_price) +# get_plan: +result = mask_plan_with_products_prices(result, enabled=not can_price) +``` + +`archive` / `archive-draft` 成功响应同样脱敏。 + +- [ ] **Step 3: `archive_plan` / `archive_plan_draft` 开头** + +```python +if not user_can_archive_plan(db, user): + raise HTTPException(status_code=403, detail="无运输计划归档操作权限") +``` + +- [ ] **Step 4: 归档导出** + +```python +cols = filter_export_columns(PLAN_ARCHIVE_EXPORT_COLUMNS, body.columns) +if not user_can_view_archive_prices(db, user): + cols = [c for c in cols if c.field not in ARCHIVE_PRICE_EXPORT_FIELDS] +``` + +确认 `ExportColumn` 使用 `.field`(与现有定义一致)。 + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api/pl/plan.py +git commit -m "feat(backend): gate archive writes and mask plan prices by menu code" +``` + +--- + +### Task 4: 查看链路拆分数量与价格展示 + +**Files:** +- Modify: `web-admin/src/components/business/pl-plan-view-dialog/index.vue` +- Modify: `web-admin/src/components/business/pl-plan-view-tabs/index.vue` +- Modify: `web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue` +- Modify: `web-admin/src/components/business/pl-plan-archive-brand-view-block/index.vue` +- Modify: `web-admin/src/components/business/pl-plan-transport-feedback-blocks/index.vue`(若向下传 IO 价) + +- [ ] **Step 1: Dialog / Tabs 增加 prop** + +```ts +/** 归档查看:展示单价与金额;未传时跟随 showArchiveIoFields */ +showArchivePriceFields?: boolean +``` + +```ts +const effectiveShowArchivePrice = computed( + () => props.showArchivePriceFields ?? props.showArchiveIoFields +) +``` + +Dialog 透传给 Tabs。 + +- [ ] **Step 2: 运费区** + +仅当归档查看且无价权限时隐藏运费价字段: + +```ts +const hideArchiveMoney = + props.showArchiveIoFields && !effectiveShowArchivePrice.value +``` + +`freightViewFields` 在 `hideArchiveMoney` 时 omit「运输单价」「运费金额」两项(不展示占位 `—`)。 + +- [ ] **Step 3: 产品表** + +`PlPlanFormProductTable`:新增 `showArchivePriceFields`(默认 `true`)。单价/金额列: + +`v-if="showArchiveIoFields && showArchivePriceFields && ..."` + +数量列仍跟 `showArchiveIoFields`。 + +段次表:`:show-stop-archive-price="effectiveShowArchivePrice"`。 + +- [ ] **Step 4: 品牌查看块** + +增加 `showPrice?: boolean`(默认 `true`)。为 `false` 时不渲染单价、金额列。Tabs 传入 `:show-price="effectiveShowArchivePrice"`。 + +- [ ] **Step 5: Commit** + +```bash +git add web-admin/src/components/business/pl-plan-view-dialog/index.vue \ + web-admin/src/components/business/pl-plan-view-tabs/index.vue \ + web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue \ + web-admin/src/components/business/pl-plan-archive-brand-view-block/index.vue \ + web-admin/src/components/business/pl-plan-transport-feedback-blocks/index.vue +git commit -m "feat(web-admin): split archive qty vs price display flags" +``` + +--- + +### Task 5: 归档列表页接线权限 + +**Files:** +- Modify: `web-admin/src/views/pl/plan-archive/index.vue` + +- [ ] **Step 1: 加载权限** + +```ts +import { fetchGetMenuCodes } from '@/api/auth' + +const canArchive = ref(false) +const canViewPrice = ref(false) + +async function loadArchivePermissions() { + try { + const perm = await fetchGetMenuCodes() + const all = !!perm?.all || !!perm?.isSuperAdmin + const codes = perm?.menuCodes || [] + canArchive.value = all || codes.includes('pl:plan-archive:archive') + canViewPrice.value = all || codes.includes('pl:plan-archive:price') + } catch { + canArchive.value = false + canViewPrice.value = false + } +} +``` + +`onMounted` 中先 `loadArchivePermissions()`。 + +- [ ] **Step 2: 按钮** + +工具栏与查看弹窗 hero 的归档/调整按钮加 `v-if="canArchive"`。 + +- [ ] **Step 3: 列与统计** + +无 `canViewPrice` 时 `listColumnDefs` 不含 `productUnitPrice`/`productAmount`;持久化 key 升为 `pl-plan-archive-list-columns-v6`;表格列与统计行双条件排除。 + +- [ ] **Step 4: ViewDialog** + +```vue + +``` + +- [ ] **Step 5: 导出** + +前端若传 `columns`,剔除价字段;**以后端 Task 3 过滤为准**。 + +- [ ] **Step 6: Commit** + +```bash +git add web-admin/src/views/pl/plan-archive/index.vue +git commit -m "feat(web-admin): gate archive actions and price columns by menu codes" +``` + +--- + +### Task 6: 验收与图谱 + +- [ ] **Step 1: 开发库执行** `docs/sql/migrations/2026-09-03-pl-plan-archive-price-perm.sql` + +- [ ] **Step 2: 手工对照设计 §7 场景 A/C/D/E** + +- [ ] **Step 3: 更新图谱** + +```bash +./scripts/update-graph.sh backend +./scripts/update-graph.sh frontend +``` + +- [ ] **Step 4: Commit graphify(若有变更)** + +```bash +git add backend/graphify-out web-admin/graphify-out +git commit -m "chore: update graphify after plan-archive price permission" +``` + +--- + +## Spec coverage checklist + +| 设计要求 | Task | +|----------|------| +| 新建 `pl:plan-archive:price` + 角色补授权 | 1 | +| 无 archive → 隐藏按钮 / 写 403 | 3, 5 | +| 无 price → 列表/查看/品牌/运费价隐藏 | 4, 5 | +| 无 price → 导出去列 | 3, 5 | +| 读接口脱敏 | 2, 3 | +| 不新建独立模块 | (全计划) | +| ERP / edit 本期不改 | (未列入) | + +## Self-review notes + +- 无 TBD 占位步骤。 +- `mask_plan_with_products_prices` 与 API 接线命名一致。 +- `freight_amount` 脱敏用 `0` 而非 `null`。 +- 归档查看用 `showArchiveIoFields && !price` 限制运费价 UI 隐藏,避免误伤非归档页。