|
|
@@ -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 隐藏,避免误伤非归档页。
|