feat(oa): add permission-controlled shared finance remarks to done center
Share one finance remark per approval instance across Web and mobile. Require the explicit permission and the current tenant user's own done task for every read and write; keep general instance responses free of financial remarks. Add idempotent MySQL migration without automatic role grants, cross-end editors, isolated backend and frontend tests, and rollout requirements. Refs #7; platform validation and target database migration remain pending.
This commit is contained in:
1 parent
86df360f35
commit
0e64b413ce
15 files changed
+1188
-78
No files matched your search
@@ -2,7 +2,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.crud import oa_instance_draft as oid_crud
|
||||
from app.crud import oa_receive_account as ora_crud
|
||||
from app.models.models import SysUser
|
||||
from app.services.oa_receive_account import normalize_account_fields
|
||||
from app.services import oa_finance_remark as finance_remark_svc
|
||||
|
||||
router = APIRouter(prefix="/web/admin/oa-approval", tags=["OA审批"])
|
||||
|
||||
@@ -1054,6 +1055,44 @@ async def list_oa_instance_form_attachments(
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
class OaFinanceRemarkBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True, extra="forbid")
|
||||
finance_remark: str = Field(..., alias="financeRemark", max_length=512, strict=True)
|
||||
|
||||
|
||||
@router.get("/instances/{instance_id}/finance-remark")
|
||||
async def get_oa_finance_remark(
|
||||
instance_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
access = finance_remark_svc.resolve_access(db, user)
|
||||
return success_response(finance_remark_svc.get_finance_remark(db, access, instance_id))
|
||||
except finance_remark_svc.OaFinanceRemarkError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.put("/instances/{instance_id}/finance-remark")
|
||||
async def update_oa_finance_remark(
|
||||
instance_id: str,
|
||||
body: OaFinanceRemarkBody,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
try:
|
||||
access = finance_remark_svc.resolve_access(db, user)
|
||||
result = finance_remark_svc.update_finance_remark(db, access, instance_id, body.finance_remark)
|
||||
db.commit()
|
||||
return success_response(result, message="财务备注已保存")
|
||||
except finance_remark_svc.OaFinanceRemarkError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
|
||||
@router.get("/instances/{instance_id}/bank-receipts")
|
||||
async def list_oa_bank_receipts(
|
||||
instance_id: str,
|
||||
@@ -1365,7 +1404,8 @@ async def list_oa_tasks(
|
||||
from app.services.oa_engine import OaEngineError, list_tasks
|
||||
|
||||
try:
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
access = finance_remark_svc.resolve_access(db, user) if kind == "done" else None
|
||||
tenant_no = access.tenant_no if access else _get_tenant_no(db, user)
|
||||
data = list_tasks(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
@@ -1381,7 +1421,13 @@ async def list_oa_tasks(
|
||||
page=page,
|
||||
page_size=pageSize,
|
||||
)
|
||||
return success_response({"list": data.get("list") or [], "total": int(data.get("total") or 0)})
|
||||
result = {"list": data.get("list") or [], "total": int(data.get("total") or 0)}
|
||||
if access is not None:
|
||||
finance_remark_svc.add_list_remarks(db, access, result["list"])
|
||||
result["canManageFinanceRemark"] = access.can_manage
|
||||
return success_response(result)
|
||||
except finance_remark_svc.OaFinanceRemarkError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
||||
except OaEngineError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@@ -920,6 +920,7 @@ class OaInstance(Base):
|
||||
default="running",
|
||||
comment="running/returned/completed/rejected/cancelled",
|
||||
)
|
||||
finance_remark = Column(String(512), nullable=True, comment="已办财务备注(专用权限控制)")
|
||||
source_instance_id = Column(String(64), nullable=True, comment="撤销重提原实例ID")
|
||||
current_round = Column(Integer, nullable=False, default=1, comment="当前提交轮次")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""已办中心实例级财务备注;权限与本人已办范围同时满足才可访问。"""
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.models import OaInstance, OaTask, SysUser
|
||||
from app.services.web_auth_roles import iter_user_menu_perm_codes
|
||||
|
||||
PERMISSION = "oa:done:finance-remark"
|
||||
DONE_STATUSES = ("approved", "rejected", "returned", "transferred")
|
||||
|
||||
|
||||
class OaFinanceRemarkError(ValueError):
|
||||
def __init__(self, status_code: int, message: str):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FinanceRemarkAccess:
|
||||
user_id: str
|
||||
tenant_no: str
|
||||
can_manage: bool
|
||||
|
||||
|
||||
def resolve_access(db: Session, user: dict) -> FinanceRemarkAccess:
|
||||
# 不使用缺省租户、客户端租户或角色名称作为敏感字段的授权依据。
|
||||
uid = str(user.get("user_id") or "").strip()
|
||||
identity = db.query(SysUser.id, SysUser.tenant_no).filter(
|
||||
SysUser.id == uid, SysUser.del_flag == 0, SysUser.status == "1"
|
||||
).first()
|
||||
if not identity or not str(identity.tenant_no or "").strip():
|
||||
raise OaFinanceRemarkError(403, "用户不可用")
|
||||
tenant = str(identity.tenant_no).strip()
|
||||
granted = iter_user_menu_perm_codes(db, uid, tenant_no=tenant)
|
||||
return FinanceRemarkAccess(uid, tenant, PERMISSION in granted)
|
||||
|
||||
|
||||
def _accessible_instances(db: Session, access: FinanceRemarkAccess):
|
||||
own_done = db.query(OaTask.id).filter(
|
||||
OaTask.instance_id == OaInstance.id,
|
||||
OaTask.tenant_no == access.tenant_no,
|
||||
OaTask.assignee_user_id == access.user_id,
|
||||
OaTask.task_type == "approval",
|
||||
OaTask.status.in_(DONE_STATUSES),
|
||||
OaTask.del_flag == 0,
|
||||
).exists()
|
||||
return db.query(OaInstance).filter(
|
||||
OaInstance.tenant_no == access.tenant_no,
|
||||
OaInstance.del_flag == 0,
|
||||
own_done,
|
||||
)
|
||||
|
||||
|
||||
def _require_instance(db: Session, access: FinanceRemarkAccess, instance_id: str) -> OaInstance:
|
||||
if not access.can_manage:
|
||||
raise OaFinanceRemarkError(403, "无权查看或修改财务备注")
|
||||
instance = _accessible_instances(db, access).filter(OaInstance.id == instance_id).first()
|
||||
if instance is None:
|
||||
raise OaFinanceRemarkError(404, "审批单不在本人已办范围内")
|
||||
return instance
|
||||
|
||||
|
||||
def _remark_data(instance: OaInstance) -> Dict[str, str]:
|
||||
return {"instanceId": instance.id, "financeRemark": instance.finance_remark or ""}
|
||||
|
||||
|
||||
def get_finance_remark(db: Session, access: FinanceRemarkAccess, instance_id: str) -> Dict[str, str]:
|
||||
return _remark_data(_require_instance(db, access, instance_id))
|
||||
|
||||
|
||||
def update_finance_remark(
|
||||
db: Session, access: FinanceRemarkAccess, instance_id: str, finance_remark: str
|
||||
) -> Dict[str, str]:
|
||||
instance = _require_instance(db, access, instance_id)
|
||||
if not isinstance(finance_remark, str) or len(finance_remark) > 512:
|
||||
raise OaFinanceRemarkError(422, "财务备注最多 512 字")
|
||||
instance.finance_remark = finance_remark
|
||||
instance.update_by = access.user_id
|
||||
instance.update_time = datetime.now()
|
||||
db.flush()
|
||||
return _remark_data(instance)
|
||||
|
||||
|
||||
def add_list_remarks(db: Session, access: FinanceRemarkAccess, rows: List[Dict[str, Any]]) -> None:
|
||||
# 即使调用方未来调整序列化,也先移除敏感字段,再按权限补充。
|
||||
for row in rows:
|
||||
row.pop("financeRemark", None)
|
||||
if not access.can_manage:
|
||||
return
|
||||
ids = {row.get("instanceId") for row in rows if row.get("instanceId")}
|
||||
if not ids:
|
||||
return
|
||||
instances = _accessible_instances(db, access).filter(OaInstance.id.in_(ids)).all()
|
||||
remarks = {inst.id: inst.finance_remark or "" for inst in instances}
|
||||
for row in rows:
|
||||
if row.get("instanceId") in remarks:
|
||||
row["financeRemark"] = remarks[row["instanceId"]]
|
||||
@@ -0,0 +1,195 @@
|
||||
"""OA 财务备注:真实隔离数据库和 HTTP 路由的读写/权限契约。"""
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.api.oa_approval import router
|
||||
from app.core.auth import get_current_user
|
||||
from app.core.database import Base, get_db
|
||||
from app.models.models import (
|
||||
OaApp, OaAppVersion, OaInstance, OaTask,
|
||||
SysUser, SysRole, SysUserRole, SysMenu, SysRoleMenu,
|
||||
SysPost, SysPostRole, SysUserPost,
|
||||
)
|
||||
|
||||
PERM = 'oa:done:finance-remark'
|
||||
PREFIX = '/web/admin/oa-approval'
|
||||
URL = PREFIX + '/instances/i1/finance-remark'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def case():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
tables = (OaApp, OaAppVersion, OaInstance, OaTask, SysUser, SysRole,
|
||||
SysUserRole, SysMenu, SysRoleMenu, SysPost, SysPostRole, SysUserPost)
|
||||
Base.metadata.create_all(engine, tables=[m.__table__ for m in tables])
|
||||
db = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)()
|
||||
for uid, tenant in [('u1', 'a'), ('u2', 'a'), ('outsider', 'a'), ('other', 'b')]:
|
||||
db.add(SysUser(id=uid, user_no=uid, nick_name=uid, password='unused', tenant_no=tenant, status='1'))
|
||||
db.add_all([
|
||||
OaApp(id='app', app_name='报销', tenant_no='a'),
|
||||
OaAppVersion(id='v1', app_id='app', version_no=1, tenant_no='a'),
|
||||
SysRole(id='finance', role_name='财务', tenant_no='a', status='1'),
|
||||
SysMenu(id='menu', menu_name='财务备注', parent_id='done', menu_type='F', perms=PERM, tenant_no='a', status='1'),
|
||||
SysRoleMenu(id='rm', role_id='finance', menu_id='menu', tenant_no='a'),
|
||||
])
|
||||
for uid in ['u1', 'u2', 'outsider']:
|
||||
db.add(SysUserRole(id='ur-'+uid, user_id=uid, role_id='finance', tenant_no='a'))
|
||||
for iid, tenant in [('i1', 'a'), ('i2', 'a'), ('foreign', 'b')]:
|
||||
inst = OaInstance(id=iid, app_id='app', app_version_id='v1', title=iid,
|
||||
initiator_user_id='u1', tenant_no=tenant, status='completed', form_data_json='{}')
|
||||
inst.finance_remark = '原备注'
|
||||
db.add(inst)
|
||||
for tid, uid, iid, tenant, status in [
|
||||
('t1', 'u1', 'i1', 'a', 'approved'), ('t2', 'u2', 'i1', 'a', 'rejected'),
|
||||
('pending', 'u1', 'i2', 'a', 'pending'), ('foreign-task', 'u1', 'foreign', 'b', 'approved'),
|
||||
]:
|
||||
db.add(OaTask(id=tid, instance_id=iid, assignee_user_id=uid, tenant_no=tenant,
|
||||
task_type='approval', status=status))
|
||||
db.commit()
|
||||
user = {'user_id': 'u1', 'tenant_no': 'a', 'role': 'admin'}
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_current_user] = lambda: user
|
||||
app.dependency_overrides[get_db] = lambda: db
|
||||
with TestClient(app) as client:
|
||||
yield db, client, user
|
||||
db.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_shared_save_reload_and_clear(case):
|
||||
db, client, user = case
|
||||
result = client.get(URL)
|
||||
assert result.status_code == 200
|
||||
assert result.json()['data'] == {'instanceId': 'i1', 'financeRemark': '原备注'}
|
||||
for text in ['财务核对完成\n<标签按原文显示>', '汉' * 512, '']:
|
||||
user['user_id'] = 'u1'
|
||||
response = client.put(URL, json={'financeRemark': text})
|
||||
assert response.status_code == 200
|
||||
assert response.json()['code'] == 200
|
||||
db.expire_all()
|
||||
assert db.get(OaInstance, 'i1').finance_remark == text
|
||||
assert db.get(OaInstance, 'i1').update_by == 'u1'
|
||||
user['user_id'] = 'u2'
|
||||
assert client.get(URL).json()['data']['financeRemark'] == text
|
||||
rows = client.get(PREFIX + '/tasks/done').json()['data']
|
||||
assert rows['canManageFinanceRemark'] is True
|
||||
assert rows['list'][0]['financeRemark'] == text
|
||||
|
||||
|
||||
@pytest.mark.parametrize('role', ['admin', 'super_admin'])
|
||||
def test_no_permission_hides_list_and_denies_both_routes(case, role):
|
||||
db, client, user = case
|
||||
db.get(SysRoleMenu, 'rm').del_flag = 1
|
||||
db.commit()
|
||||
user['role'] = role
|
||||
data = client.get(PREFIX + '/tasks/done').json()['data']
|
||||
assert data['canManageFinanceRemark'] is False
|
||||
assert len(data['list']) == 1
|
||||
assert 'financeRemark' not in data['list'][0]
|
||||
assert client.get(URL).status_code == 403
|
||||
assert client.put(URL, json={'financeRemark': '泄漏'}).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize('target', ['i2', 'foreign', 'missing'])
|
||||
def test_only_own_done_instances_are_accessible(case, target):
|
||||
_, client, _ = case
|
||||
url = PREFIX + '/instances/' + target + '/finance-remark'
|
||||
assert client.get(url).status_code == 404
|
||||
assert client.put(url, json={'financeRemark': '越权'}).status_code == 404
|
||||
|
||||
|
||||
def test_authorized_outsider_cannot_read_or_write(case):
|
||||
_, client, user = case
|
||||
user['user_id'] = 'outsider'
|
||||
assert client.get(URL).status_code == 404
|
||||
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('model,key', [(OaInstance, 'i1'), (OaTask, 't1')])
|
||||
def test_deleted_instance_or_done_task_denies_access(case, model, key):
|
||||
db, client, _ = case
|
||||
db.get(model, key).del_flag = 1
|
||||
db.commit()
|
||||
assert client.get(URL).status_code == 404
|
||||
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 404
|
||||
data = client.get(PREFIX + '/tasks/done').json()['data']
|
||||
assert all('financeRemark' not in row for row in data['list'])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status', ['approved', 'rejected', 'returned', 'transferred'])
|
||||
def test_all_existing_done_statuses_can_manage(case, status):
|
||||
db, client, _ = case
|
||||
db.get(OaTask, 't1').status = status
|
||||
db.commit()
|
||||
assert client.get(URL).status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status', ['pending', 'done', 'cancelled'])
|
||||
def test_non_done_statuses_cannot_manage(case, status):
|
||||
db, client, _ = case
|
||||
db.get(OaTask, 't1').status = status
|
||||
db.commit()
|
||||
assert client.get(URL).status_code == 404
|
||||
|
||||
|
||||
def test_forged_tenant_uses_real_user_tenant(case):
|
||||
_, client, user = case
|
||||
user['tenant_no'] = 'b'
|
||||
assert client.get(URL).json()['data']['financeRemark'] == '原备注'
|
||||
data = client.get(PREFIX + '/tasks/done').json()['data']
|
||||
assert [r['instanceId'] for r in data['list']] == ['i1']
|
||||
assert client.get(PREFIX + '/instances/foreign/finance-remark').status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('model,key,field,value', [
|
||||
(SysUser, 'u1', 'status', '0'), (SysUser, 'u1', 'del_flag', 1),
|
||||
(SysRole, 'finance', 'status', '0'), (SysRole, 'finance', 'tenant_no', 'b'),
|
||||
(SysMenu, 'menu', 'status', '0'), (SysMenu, 'menu', 'tenant_no', 'b'),
|
||||
(SysUserRole, 'ur-u1', 'tenant_no', 'b'),
|
||||
])
|
||||
def test_inactive_or_cross_tenant_grants_deny_access(case, model, key, field, value):
|
||||
db, client, _ = case
|
||||
setattr(db.get(model, key), field, value)
|
||||
db.commit()
|
||||
assert client.get(URL).status_code == 403
|
||||
assert client.put(URL, json={'financeRemark': '越权'}).status_code == 403
|
||||
|
||||
|
||||
def test_post_role_permission_union(case):
|
||||
db, client, _ = case
|
||||
db.get(SysUserRole, 'ur-u1').del_flag = 1
|
||||
db.add_all([
|
||||
SysPost(id='post', post_name='财务岗', post_code='finance', tenant_no='a', status='1'),
|
||||
SysUserPost(id='up', user_id='u1', post_id='post', tenant_no='a'),
|
||||
SysPostRole(id='pr', post_id='post', role_id='finance', tenant_no='a'),
|
||||
])
|
||||
db.commit()
|
||||
assert client.get(URL).status_code == 200
|
||||
db.get(SysPost, 'post').status = '0'
|
||||
db.commit()
|
||||
assert client.get(URL).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize('body', [
|
||||
{}, {'financeRemark': None}, {'financeRemark': 123}, {'financeRemark': 'a' * 513},
|
||||
{'financeRemark': 'note', 'tenantNo': 'b'}, {'financeRemark': 'note', 'status': 'running'},
|
||||
])
|
||||
def test_invalid_body_rejected_without_writes(case, body):
|
||||
db, client, _ = case
|
||||
assert client.put(URL, json=body).status_code == 422
|
||||
db.expire_all()
|
||||
assert db.get(OaInstance, 'i1').finance_remark == '原备注'
|
||||
|
||||
|
||||
def test_generic_instance_serialization_does_not_expose_finance_remark(case):
|
||||
db, client, _ = case
|
||||
from app.services.oa_engine import _instance_to_dict
|
||||
assert 'financeRemark' not in _instance_to_dict(db.get(OaInstance, 'i1'))
|
||||
for kind in ['mine', 'pending']:
|
||||
data = client.get(PREFIX + '/tasks/' + kind).json()['data']
|
||||
assert all('financeRemark' not in row for row in data['list'])
|
||||
Reference in new issue
Block a user