diff --git a/.cursor/mcp.json b/.cursor/mcp.json
index da39e4ff..ce0985e7 100644
--- a/.cursor/mcp.json
+++ b/.cursor/mcp.json
@@ -1,3 +1,7 @@
{
- "mcpServers": {}
+ "mcpServers": {
+ "figma": {
+ "url": "https://mcp.figma.com/mcp"
+ }
+ }
}
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000..4a9bcff9
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "figma": {
+ "type": "http",
+ "url": "https://mcp.figma.com/mcp"
+ }
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
index 7900f1f2..a345ca8a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -19,7 +19,14 @@
- 仅做任务必要的改动,不夹带无关重构或全仓库格式化;保留用户已有改动。
- **不得自行停止、启动或重启 `web-admin` 开发服务与 `backend` 服务。** 需要重启时告知用户,由用户操作。
- 提交使用 Conventional Commits,完整说明“改了什么、为什么”。
+- 需求开发与 Bug 修复:用户给出 Gitea 工单编号或链接时,先读取该工单描述、评论和验收条件再处理;未给编号时,先在当前工程 Gitea 仓库创建工单,再开发。继续当前已关联任务时复用原工单,不重复创建;已有授权下由 AI 创建和回写,不再要求用户代建。
+- 修改端不明确时,先查当前任务和工单的已确认范围;仍不明确则询问修改 Web、小程序、移动 OA、后端中的哪些端,还是所有相关端。确认前仅只读定位、回写工单待确认范围,不修改业务代码;截图不能代替确认。已有明确范围直接沿用,答复“所有端”须记录具体应用清单;共享后端/API 影响范围外的端时,说明影响并确认扩展后再实施。
+- 新界面、新弹窗及视觉改版,按 [UI 原型确认规则](openspec/changes/zkxw-engineering-standards/specs/ui-conventions/spec.md#requirement-界面与弹窗先经-figma-原型确认)先完成 Figma 原型并取得用户确认后开发;仅用户明确不用生成 Figma 原型时豁免。
+- 默认由 Agent 测试:自动进入任务分支/worktree,核对实际运行版本,完成测试与审查后合入 `V2.0`、提交推送、同步本地主分支并关闭 Gitea 工单。用户已授予这条常规收尾链路持续授权,不逐次询问合并;打 tag、部署与服务启停仍分别受原有授权限制。
+- 用户明确“我来测试”时,该任务由用户测试:Agent 不运行测试或构建,仍完成代码审查和 Git 核对;交付时提醒切换到具体分支,提供 worktree 路径、提交、前后端配套入口和验证要点。用户确认通过后直接合并推送、关闭工单;未确认或失败时不合并、不关闭。
+- 合并前核对验收提交与最新目标;目标更新或冲突处理改变代码时,按原测试责任补验,用户模式仍交用户验证。合并后代码树与验收版本一致不重复测试;确认远端同步成功后回写验收结果、合并提交和 PR 并关单。
- 不把真实凭据、业务导出、虚拟环境、构建产物和工具临时状态新增到版本管理。
+- 配置或使用账号时,按[运行凭据与 Agent 操作凭据](openspec/changes/zkxw-engineering-standards/specs/architecture-conventions/spec.md#requirement-运行凭据与-agent-操作凭据分离)区分来源:应用沿用自己的环境配置,`.local/secrets/access.json` 仅供 Agent 操作使用。
- 清理文件前核查代码、测试、脚本、部署及文档引用;不能只凭名称、日期或搜索无结果删除。
## 跨端数据与权限底线
@@ -43,7 +50,7 @@
## 验证与交付
-- 执行与改动范围相符的检查,报告实际结果和未执行项;文档变更检查链接与引用,业务变更检查受影响测试及构建。
+- 按任务测试责任验证,报告实际结果和未执行项:Agent 模式按改动范围检查文档链接或业务测试与构建;用户模式记录用户验收结论,不由 Agent 运行测试和构建。
- CRUD 字段一致性按 [开发指南](docs/dev-field-consistency.md) 检查;根目录命令是 `python backend/scripts/check_field_consistency.py`。该工具仅覆盖注册实体,已知失效路径见该指南。
- 使用源码、引用检索和测试核对影响范围。已移除的 graphify 不再是开发依赖或交付步骤,不安装、运行或恢复其自动 hook。
diff --git a/backend/app/api/collection_protocol.py b/backend/app/api/collection_protocol.py
index c236a87f..c952ecdb 100644
--- a/backend/app/api/collection_protocol.py
+++ b/backend/app/api/collection_protocol.py
@@ -14,6 +14,7 @@ from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import error_response, success_response
from app.core.utils import parse_datetime
from app.crud import collection_protocol as cp_svc
+from app.crud import collection_region as region_svc
from app.models.models import (
BizCollectionProtocol,
BizProtocolPartyCustomer,
@@ -146,6 +147,15 @@ async def list_collection_protocols(
return success_response({"list": items, "total": total, "page": page, "pageSize": pageSize, "totalPages": tp})
+@router.get("/collection-protocols/region-managers")
+async def list_protocol_region_managers(
+ user: dict = Depends(require_admin_or_super_admin),
+ db: Session = Depends(get_db),
+):
+ tenant_no = _get_tenant_no(db, user)
+ return success_response({"list": region_svc.list_active_region_leaders(db, tenant_no=tenant_no)})
+
+
@router.get("/collection-protocols/{protocol_id}")
async def get_collection_protocol_detail(
protocol_id: str,
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/crud/collection_region.py b/backend/app/crud/collection_region.py
index 54d82f1f..9cad665d 100644
--- a/backend/app/crud/collection_region.py
+++ b/backend/app/crud/collection_region.py
@@ -496,6 +496,35 @@ def list_regions_by_staff_user(
return map_staff_region_rows_to_list([(r[0], r[1]) for r in q.all()])
+def list_active_region_leaders(db: Session, *, tenant_no: str) -> List[Dict[str, Any]]:
+ """协议表单候选:租户内有效地区负责人,按用户去重并保留负责地区。"""
+ if not tenant_no:
+ raise ValueError("租户信息缺失")
+ rows = (
+ db.query(SysUser.id, SysUser.nick_name, BizCollectionRegionStaff.region_id)
+ .join(BizCollectionRegionStaff, BizCollectionRegionStaff.user_id == SysUser.id)
+ .join(BizCollectionRegion, BizCollectionRegion.id == BizCollectionRegionStaff.region_id)
+ .filter(
+ BizCollectionRegion.tenant_no == tenant_no,
+ BizCollectionRegion.del_flag == 0,
+ BizCollectionRegionStaff.tenant_no == tenant_no,
+ BizCollectionRegionStaff.del_flag == 0,
+ BizCollectionRegionStaff.is_leader == 1,
+ SysUser.tenant_no == tenant_no,
+ SysUser.del_flag == 0,
+ SysUser.status == "1",
+ )
+ .distinct()
+ .order_by(SysUser.nick_name.asc(), SysUser.id.asc(), BizCollectionRegionStaff.region_id.asc())
+ .all()
+ )
+ leaders: Dict[str, Dict[str, Any]] = {}
+ for user_id, nick_name, region_id in rows:
+ item = leaders.setdefault(user_id, {"id": user_id, "nickName": nick_name or "", "regionIds": []})
+ item["regionIds"].append(region_id)
+ return list(leaders.values())
+
+
def list_staff(
db: Session,
*,
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/market_support.py b/backend/app/services/market_support.py
index db62c93f..7690608e 100644
--- a/backend/app/services/market_support.py
+++ b/backend/app/services/market_support.py
@@ -470,6 +470,24 @@ def get_form_options(
'currentUserRegions': current_user_regions,
}
if normalized_module == 'protocol':
+ # 地区经理独立取全部有效地区负责人,不随医院/地区人员联动缩小。
+ manager_rows = (
+ db.query(SysUser.id, SysUser.nick_name)
+ .join(BizCollectionRegionStaff, BizCollectionRegionStaff.user_id == SysUser.id)
+ .filter(
+ BizCollectionRegionStaff.region_id.in_(active_region_ids),
+ BizCollectionRegionStaff.tenant_no == ctx.tenant_no,
+ BizCollectionRegionStaff.del_flag == 0,
+ BizCollectionRegionStaff.is_leader == 1,
+ SysUser.tenant_no == ctx.tenant_no,
+ SysUser.del_flag == 0,
+ SysUser.status == '1',
+ )
+ .distinct()
+ .order_by(SysUser.nick_name.asc(), SysUser.id.asc())
+ .all()
+ )
+ result['regionManagers'] = [_option(row.id, row.nick_name) for row in manager_rows]
ethnicities = (
db.query(SysEthnic)
.filter(
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_market_support_api.py b/backend/tests/test_market_support_api.py
index f050ec9c..056338c7 100644
--- a/backend/tests/test_market_support_api.py
+++ b/backend/tests/test_market_support_api.py
@@ -131,6 +131,60 @@ def _seed_options(db):
db.commit()
+@pytest.mark.parametrize('region_id', [None, 'ra', 'rb'])
+def test_protocol_manager_options_only_include_active_tenant_region_leaders(region_id):
+ db = _session()
+ _seed_options(db)
+ common = dict(tenant_no='tenant-a', create_dept_id='', del_flag=0)
+ for suffix in ('c', 'd'):
+ db.add(BizCollectionRegion(id=f'r{suffix}', region_name=f'{suffix}区', **common))
+ db.add(SysUser(id=f'leader-{suffix}', user_no=f'l{suffix}', nick_name=f'{suffix}负责人', status='1', **common))
+ db.add(BizCollectionRegionStaff(id=f'leader-binding-{suffix}', region_id=f'r{suffix}', user_id=f'leader-{suffix}', is_leader=1, **common))
+ db.add(BizCollectionRegion(id='deleted-region', region_name='已删区', **{**common, 'del_flag': 1}))
+ db.add(SysUser(id='deleted-user', user_no='deleted', nick_name='已删用户', status='1', **{**common, 'del_flag': 1}))
+ for binding_id, region, user, tenant, deleted in [
+ ('duplicate-leader', 'rb', 'leader-a', 'tenant-a', 0),
+ ('disabled-leader', 'ra', 'disabled', 'tenant-a', 0),
+ ('deleted-user-leader', 'ra', 'deleted-user', 'tenant-a', 0),
+ ('deleted-region-leader', 'deleted-region', 'staff-a', 'tenant-a', 0),
+ ('deleted-binding', 'ra', 'staff-a', 'tenant-a', 1),
+ ('cross-tenant-user', 'ra', 'cross-user', 'tenant-a', 0),
+ ('cross-tenant-binding', 'ra', 'staff-a', 'tenant-b', 0),
+ ('cross-tenant-region', 'rx', 'staff-a', 'tenant-a', 0),
+ ]:
+ db.add(BizCollectionRegionStaff(id=binding_id, region_id=region, user_id=user, is_leader=1,
+ tenant_no=tenant, create_dept_id='', del_flag=deleted))
+ db.commit()
+
+ out = market_support.get_form_options(db, module='protocol', ctx=_ctx(), user_info=_user(), region_id=region_id)
+ assert out['regionManagers'] == [
+ {'value': 'leader-a', 'label': 'A负责人'},
+ {'value': 'user-b', 'label': 'B经理'},
+ {'value': 'leader-c', 'label': 'c负责人'},
+ {'value': 'leader-d', 'label': 'd负责人'},
+ ]
+ from app.crud import collection_region
+ leaders = collection_region.list_active_region_leaders(db, tenant_no='tenant-a')
+ assert [{'value': row['id'], 'label': row['nickName']} for row in leaders] == out['regionManagers']
+ assert leaders[0]['regionIds'] == ['ra', 'rb']
+ assert all(set(row) == {'id', 'nickName', 'regionIds'} for row in leaders)
+ if region_id == 'ra':
+ assert 'staff-a' in {row['value'] for row in out['staff']}
+ assert 'user-b' not in {row['value'] for row in out['staff']}
+ db.close()
+
+
+def test_protocol_manager_options_are_empty_without_leaders():
+ db = _session()
+ _seed_options(db)
+ db.query(BizCollectionRegionStaff).update({'is_leader': 0})
+ db.commit()
+ out = market_support.get_form_options(db, module='protocol', ctx=_ctx(), user_info=_user())
+ assert out['regionManagers'] == []
+ assert out['staff']
+ db.close()
+
+
def test_specialist_form_options_list_all_tenant_regions_like_web(monkeypatch):
"""市场专员采集地区选项应与 Web 一致:本租户全部地区,不依赖人员绑定。"""
from app.services.market_support import get_form_options
@@ -2469,3 +2523,25 @@ def test_payment_transfer_response_carries_server_authorized_capabilities():
'submit-finance',
]
assert pending['allowedActions'] == ['withdraw-transfer']
+
+
+def test_web_protocol_manager_options_derive_tenant_from_authenticated_user():
+ from app.api import collection_protocol
+ from app.core.menu_guard import require_admin_or_super_admin
+ db = _session()
+ _seed_options(db)
+ response = asyncio.run(collection_protocol.list_protocol_region_managers(
+ user={**_user(), 'tenant_no': 'tenant-b'}, db=db))
+ assert response['data']['list'] == [
+ {'id': 'leader-a', 'nickName': 'A负责人', 'regionIds': ['ra']},
+ {'id': 'user-b', 'nickName': 'B经理', 'regionIds': ['rb']},
+ ]
+ route = next(route for route in collection_protocol.router.routes
+ if route.path == '/web/admin/collection-protocols/region-managers')
+ assert any(dep.call == require_admin_or_super_admin for dep in route.dependant.dependencies)
+ paths = [route.path for route in collection_protocol.router.routes]
+ assert paths.index(route.path) < paths.index('/web/admin/collection-protocols/{protocol_id}')
+ db.query(BizCollectionRegionStaff).update({'is_leader': 0})
+ db.commit()
+ assert asyncio.run(collection_protocol.list_protocol_region_managers(user=_user(), db=db))['data']['list'] == []
+ db.close()
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..bddd0b3b 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,89 @@
加载更多…
已经到底了
+
+
+
+
+
+
+
+ 正在加载最新备注…
+
+
+
+ {{ financeRemarkError || '留空并保存,可清空原有备注' }}
+
+
+
+
+
+
+
+