Просмотр исходного кода

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 <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
27c1cbba15
1 измененных файлов с 566 добавлено и 0 удалено
  1. 566 0
      docs/superpowers/plans/2026-09-03-pl-plan-archive-price-permission.md

+ 566 - 0
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
+<PlPlanViewDialog
+  show-archive-io-fields
+  :show-archive-price-fields="canViewPrice"
+  ...
+/>
+```
+
+- [ ] **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 隐藏,避免误伤非归档页。