feat/m3-increments #1

Merged
liuwenxin merged 100 commits from feat/m3-increments into main 2026-10-19 04:59:57 -04:00
5 changed files with 54 additions and 19 deletions
Showing only changes of commit 7008de0c69 - Show all commits

View File

@ -114,6 +114,10 @@ def require_admin(current_user: Annotated[User, Depends(get_current_user)]) -> U
AdminUser = Annotated[User, Depends(require_admin)]
# 仅要求登录态、不限角色(#17 答复 2026-10-19:薪酬敏感配置「查看可、修改仅
# 管理员」——参数目录/历史/预览等只读端点用此依赖)
LoginUser = Annotated[User, Depends(get_current_user)]
def require_roles(*role_codes: str):
"""要求当前用户具备任一指定角色(依赖工厂)。

View File

@ -1,8 +1,9 @@
"""计提参数管理接口(需求 #28:参数配置化 + 版本管理 + 留痕)。
全部要求管理员权限(开放问题 #5 的当前默认,后续可放宽只读);
写操作(提交/撤回)经 `RequestMetaDep` 带入请求元信息写入操作日志,
变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
权限分层(澄清 #17 业务答复 2026-10-19「查看可、修改仅管理员」):只读
端点(目录/变更记录/批次明细/对比/导出/预览/历史)要求登录即可;写操作
(提交/撤回)仅管理员。写操作经 `RequestMetaDep` 带入请求元信息写入操作
日志,变更批次本身另行留痕(变更人/时间/项/旧值/新值/生效日期)——双保险。
导出为文件流(非统一信封),前端以 blob 接收。
"""
@ -12,7 +13,7 @@ from typing import Annotated
from fastapi import APIRouter, Path, Query
from fastapi.responses import StreamingResponse
from app.api.deps import AdminUser, DbSession, RequestMetaDep
from app.api.deps import AdminUser, DbSession, LoginUser, RequestMetaDep
from app.core.response import ApiResponse, PageResult, ok
from app.schemas.param import (
ParamBatchDetailOut,
@ -37,7 +38,7 @@ router = APIRouter(prefix="/params", tags=["系统管理 · 计提参数"])
)
def get_catalog(
session: DbSession,
_: AdminUser,
_: LoginUser,
as_of: Annotated[date | None, Query(description="业务日期,默认今日")] = None,
) -> ApiResponse[list[ParamCatalogItemOut]]:
"""全部启用参数的定义元数据与截至 asOf 的生效值(前端按分组展示)。"""
@ -63,7 +64,7 @@ def submit_change(
)
def list_changes(
session: DbSession,
_: AdminUser,
_: LoginUser,
query: Annotated[ParamBatchQuery, Query()],
) -> ApiResponse[PageResult[ParamBatchOut]]:
"""按生效日期区间分页查询变更批次(创建时间倒序)。"""
@ -75,7 +76,7 @@ def list_changes(
)
def get_batch(
session: DbSession,
_: AdminUser,
_: LoginUser,
batch_id: Annotated[int, Path(ge=1)],
) -> ApiResponse[ParamBatchDetailOut]:
"""批次头 + 逐项旧值 → 新值。"""
@ -97,7 +98,7 @@ def withdraw_batch(
@router.get("/compare", response_model=ApiResponse[list[ParamDiffOut]], summary="两日期快照对比")
def compare(
session: DbSession,
_: AdminUser,
_: LoginUser,
from_date: Annotated[date, Query(description="起始业务日期(含)")],
to_date: Annotated[date, Query(description="截止业务日期(含)")],
) -> ApiResponse[list[ParamDiffOut]]:
@ -108,7 +109,7 @@ def compare(
@router.get("/export", summary="导出变更记录 Excel")
def export_changes(
session: DbSession,
_: AdminUser,
_: LoginUser,
start_date: Annotated[date | None, Query(description="生效日期起(含)")] = None,
end_date: Annotated[date | None, Query(description="生效日期止(含)")] = None,
) -> StreamingResponse:
@ -125,7 +126,7 @@ def export_changes(
@router.post("/preview", response_model=ApiResponse[ParamPreviewOut], summary="计算预览")
def preview(
session: DbSession,
_: AdminUser,
_: LoginUser,
payload: ParamPreviewIn,
) -> ApiResponse[ParamPreviewOut]:
"""输入业务场景(岗位/产品/发货/回款/金额),按回款到账日取参数实时试算。"""
@ -139,7 +140,7 @@ def preview(
)
def get_history(
session: DbSession,
_: AdminUser,
_: LoginUser,
param_code: Annotated[str, Path(max_length=64)],
query: Annotated[ParamHistoryQuery, Query()],
) -> ApiResponse[PageResult[ParamHistoryOut]]:

View File

@ -437,12 +437,23 @@ class TestPreview:
class TestPermission:
"""权限:普通用户全部 403。"""
"""权限分层(澄清 #17 答复 2026-10-19):查看可(登录即可)、修改仅管理员。"""
def test_普通用户访问目录与提交_403(self, client: TestClient, make_user, auth_headers) -> None:
def test_普通用户查看目录_200(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
resp = client.get(_PARAMS, headers=headers)
assert resp.status_code == 200
assert len(resp.json()["data"]) == 19
def test_未登录访问目录_403(self, client: TestClient) -> None:
# LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401
# 均属拦截;显式断言不泄露数据即可)
assert client.get(_PARAMS).status_code in (401, 403)
def test_普通用户提交变更_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.get(_PARAMS, headers=headers).status_code == 403
resp = client.post(
f"{_PARAMS}/changes",
json={
@ -452,3 +463,8 @@ class TestPermission:
headers=headers,
)
assert resp.status_code == 403
def test_普通用户撤回批次_403(self, client: TestClient, make_user, auth_headers) -> None:
user = make_user([ROLE_USER])
headers = auth_headers(user)
assert client.delete(f"{_PARAMS}/changes/1", headers=headers).status_code == 403

View File

@ -62,10 +62,17 @@ const routes: RouteRecordRaw[] = [
meta: { title: '岗位角色', group: '系统管理', requiresAdmin: true, order: 5 },
},
{
// 计提参数:查看放开到管理员/财务/领导(#17 答复「查看可、修改仅
// 管理员」);页面内写按钮按 admin 角色显隐
path: '/system/params',
name: 'system-params',
component: () => import('@/views/system/params/index.vue'),
meta: { title: '计提参数', group: '系统管理', requiresAdmin: true, order: 6 },
meta: {
title: '计提参数',
group: '系统管理',
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
order: 6,
},
},
{
path: '/system/settings',

View File

@ -15,6 +15,8 @@ import type {
import AppPagination from '@/components/table/AppPagination.vue'
import { useConfirmAction } from '@/composables/useConfirmAction'
import { usePagedList } from '@/composables/usePagedList'
import { useUserStore } from '@/store/user'
import { ROLE_ADMIN } from '@/utils/constants'
import { handledError } from '@/utils/error'
/**
@ -24,9 +26,13 @@ import { handledError } from '@/utils/error'
* - 目录按「分组 → 参数 → 维度值」展示当前生效值;修改先进「待提交」,
* 以批次提交(一次一批,逐项留痕旧值/新值)
* - 支持立即生效或指定日期生效;未生效批次可整批撤回
* - 权限分层(澄清 #17 答复 2026-10-19):查看对财务/领导放开(只读),
* 修改仅管理员——写按钮按角色显隐,越权仍有后端 403 兜底
* - 图表/导出:统计用 ECharts(用户指定),导出为 xlsx 文件流
*/
const { confirmAndRun } = useConfirmAction()
const userStore = useUserStore()
const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN))
// ==================== 目录 ====================
@ -472,6 +478,7 @@ onMounted(() => {
待改为 {{ pendingOf(item, v.dimension_value)!.new_value }}
</el-tag>
<el-button
v-if="isAdmin"
link
type="primary"
size="small"
@ -487,7 +494,7 @@ onMounted(() => {
</td>
<td>
<el-button
v-if="!item.values.length"
v-if="isAdmin && !item.values.length"
link
type="primary"
size="small"
@ -504,8 +511,8 @@ onMounted(() => {
</el-collapse>
</el-card>
<!-- 待提交批次 -->
<el-card v-if="pendingList.length" shadow="never" class="pending-card">
<!-- 待提交批次(仅管理员可见可操作) -->
<el-card v-if="isAdmin && pendingList.length" shadow="never" class="pending-card">
<template #header>
<div class="pending-head">
<b>待提交变更({{ pendingList.length }} 项)</b>
@ -670,7 +677,7 @@ onMounted(() => {
<el-table-column label="操作" width="80">
<template #default="{ row }">
<el-button
v-if="row.effective_date > localToday()"
v-if="isAdmin && row.effective_date > localToday()"
link
type="danger"
size="small"