diff --git a/backend/app/api/oa_approval.py b/backend/app/api/oa_approval.py index a27e70b0..d5ab2881 100644 --- a/backend/app/api/oa_approval.py +++ b/backend/app/api/oa_approval.py @@ -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)) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 2e156311..60bf1632 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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="租户编号") diff --git a/backend/app/services/oa_finance_remark.py b/backend/app/services/oa_finance_remark.py new file mode 100644 index 00000000..de9f4150 --- /dev/null +++ b/backend/app/services/oa_finance_remark.py @@ -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"]] diff --git a/backend/tests/test_oa_finance_remark.py b/backend/tests/test_oa_finance_remark.py new file mode 100644 index 00000000..11e56720 --- /dev/null +++ b/backend/tests/test_oa_finance_remark.py @@ -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']) diff --git a/docs/README.md b/docs/README.md index 73b1527d..ca3c1de2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,8 @@ | 数据库 | [SQL 初始化与迁移](sql/) | | 部署 | [部署导航](../deploy/README.md) | +OA 已办财务备注上线前先执行 [2026-10-30 迁移](sql/migrations/2026-10-30-oa-done-finance-remark.sql),再更新应用;迁移不自动赋权。通过角色菜单配置授予已办中心下“财务备注(查看和修改)”权限(`oa:done:finance-remark`),同一权限作用于 Web 和移动端,仍限本人已办单据。 + `docs/sql/zkxw.sql` 仍被交付打包和 Compose 引用,但当前初始化基线不完整,不能据此宣称可从空库部署。SOP HTML 是导入脚本的输入资源,继续保留。 历史细胞产品导入必须显式指定 `--file`,不再自带默认 TSV。实际业务测试保留在各应用源码目录;删除历史报告不代表现有测试已经执行或通过。 diff --git a/docs/sql/migrations/2026-10-30-oa-done-finance-remark.sql b/docs/sql/migrations/2026-10-30-oa-done-finance-remark.sql new file mode 100644 index 00000000..f35d4d32 --- /dev/null +++ b/docs/sql/migrations/2026-10-30-oa-done-finance-remark.sql @@ -0,0 +1,50 @@ +-- OA 已办中心财务备注(Gitea #7),MySQL 5.7,先于应用代码部署。 +-- 在目标业务库执行;结构与菜单插入均可重复执行,不自动授予任何角色权限。 +-- 权限配置:在已办中心下勾选「财务备注(查看和修改)」;同一权限码作用于两端。 +-- 菜单按当前租户中既有 OA 已办页面定位,不挂载到旧工作流已办中心。 + +SET @oa_finance_column_exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'oa_instance' + AND column_name = 'finance_remark' +); +SET @oa_finance_ddl = IF( + @oa_finance_column_exists = 0, + 'ALTER TABLE oa_instance ADD COLUMN finance_remark VARCHAR(512) NULL COMMENT ''已办财务备注(专用权限控制)'' AFTER status', + 'SELECT ''oa_instance.finance_remark already exists'' AS migration_status' +); +PREPARE oa_finance_stmt FROM @oa_finance_ddl; +EXECUTE oa_finance_stmt; +DEALLOCATE PREPARE oa_finance_stmt; + +-- 由租户及父菜单生成稳定的 UUID 形状 ID,避免多行 SELECT 中 UUID 表达式被复用。 +-- 已有软删/停用权限节点不自动复活,由管理员决定是否恢复。 +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 + INSERT(INSERT(MD5(CONCAT('oa-finance-remark:', parent.tenant_no, ':', parent.id)), + 13, 1, '3'), 17, 1, '8'), + '财务备注(查看和修改)', parent.id, 'F', '', NULL, + 'oa:done:finance-remark', 1, '1', '1', parent.tenant_no, 90, + '控制本人已办审批单的共享财务备注;Web 与移动端共用权限', + 'migration', '', NOW(), 'migration', NOW(), 0 +FROM sys_menu AS parent +WHERE parent.del_flag = 0 AND parent.status = '1' AND parent.menu_type = 'C' + AND ( + parent.perms IN ('oa:done', 'mini:oa:done') + OR TRIM(BOTH '/' FROM parent.component) = 'oa-approval/done/index' + OR TRIM(BOTH '/' FROM parent.path) IN ('pages/oa/done/index', 'oa/done/index') + ) + AND NOT EXISTS ( + SELECT 1 FROM sys_menu AS existing + WHERE existing.parent_id = parent.id + AND existing.tenant_no = parent.tenant_no + AND existing.perms = 'oa:done:finance-remark' + ); + +-- 验证必须至少存在当前租户的权限节点;若无结果,先核对该租户 OA 已办菜单配置。 +SELECT tenant_no, parent_id, id, menu_name, perms, status +FROM sys_menu WHERE perms = 'oa:done:finance-remark' AND del_flag = 0; diff --git a/mobile-h5/pages/oa/done/index.vue b/mobile-h5/pages/oa/done/index.vue index 3190dea7..e8e0597b 100644 --- a/mobile-h5/pages/oa/done/index.vue +++ b/mobile-h5/pages/oa/done/index.vue @@ -32,11 +32,7 @@ @@ -58,23 +54,22 @@ }} - + {{ line }} + + 财务备注:{{ row.financeRemark || '—' }} + + {{ avatarText(row) }} {{ submitterText(row) }} - + {{ statusText(row.instanceStatus) }} @@ -82,12 +77,52 @@ 加载更多… 已经到底了 + + + + + 财务备注 + 正在加载最新备注… +