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>
This commit is contained in:
zlwuandCursor committed 2026-09-03 16:19:16 +08:00
1 parent 513d3b0fb3
commit 27c1cbba15
1 file changed
+566
@@ -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 隐藏,避免误伤非归档页。