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 }}
+
@@ -82,12 +77,52 @@
加载更多…
已经到底了
+
+
+
+