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 }} + + 财务备注:{{ row.financeRemark || '—' }} + + {{ avatarText(row) }} {{ submitterText(row) }} - + {{ statusText(row.instanceStatus) }} @@ -82,12 +77,89 @@ 加载更多… 已经到底了 + + + + + + 财务备注 + 同一审批单在各端共享 + + + + + + 备注内容 + {{ financeRemarkText.length }} / 512 + + + 正在加载最新备注… + +