From bd624162638a92e855c076503c7d6a838f9853c1 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Mon, 2 Nov 2026 15:29:35 +0800 Subject: [PATCH 1/5] feat(project): add team attribution and business statistics Add tenant-scoped teams and projects, order attribution across four businesses, conversion inheritance, scoped statistics and audited reassignment. Reuse existing Web and mobile UI patterns; team regions and hospitals remain informational. Include incremental SQL and isolated regression coverage. Actual MySQL and browser/WebView integration validation remains pending; relates to issue #13. --- backend/README.md | 15 + backend/app/api/__init__.py | 3 + backend/app/api/collection_protocol.py | 21 +- backend/app/api/market_mobile.py | 32 ++ backend/app/api/pilot_application.py | 29 +- backend/app/api/presign_order.py | 7 + backend/app/api/project_management.py | 173 ++++++++ backend/app/api/research_donation.py | 15 + backend/app/core/config.py | 3 + backend/app/crud/collection_protocol.py | 17 +- backend/app/crud/pilot_application.py | 11 + backend/app/crud/presign_order.py | 8 + backend/app/crud/research_donation.py | 14 + backend/app/main.py | 4 +- backend/app/models/__init__.py | 7 + backend/app/models/models.py | 3 + backend/app/models/project_management.py | 67 +++ backend/app/models/research_donation.py | 1 + backend/app/schemas/market_mobile.py | 4 + backend/app/services/market_presign.py | 15 + backend/app/services/market_protocol.py | 28 +- backend/app/services/project_assignment.py | 51 +++ backend/app/services/project_management.py | 404 ++++++++++++++++++ .../tests/test_market_project_assignment.py | 172 ++++++++ .../test_project_assignment_integration.py | 148 +++++++ backend/tests/test_project_management.py | 189 ++++++++ .../2026-11-02-project_management.sql | 183 ++++++++ ...026-11-02-project_management_menu_seed.sql | 39 ++ mobile-oa-h5/src/api/market.js | 8 + .../components/ProjectAssignmentField.vue | 77 ++++ mobile-oa-h5/src/market/list-filters.js | 1 + mobile-oa-h5/src/market/list-presenter.js | 4 + .../src/market/list-presenter.test.js | 6 +- .../src/market/mobile-parity-ui.test.js | 2 +- mobile-oa-h5/src/market/modules/presign.js | 2 + mobile-oa-h5/src/market/modules/protocol.js | 4 + .../src/market/presign/presign-form-state.js | 4 + .../market/presign/presign-form-state.test.js | 3 + .../src/market/presign/presign-list-model.js | 5 +- .../market/presign/presign-list-model.test.js | 2 + .../market/project-assignment-state.test.js | 17 + mobile-oa-h5/src/market/project-assignment.js | 18 + .../src/market/project-assignment.test.js | 19 + .../src/market/protocol-mobile-parity.test.js | 2 +- .../src/views/market/MarketModuleForm.vue | 8 + .../src/views/market/MarketModuleList.vue | 3 + .../views/market/presign/PresignOrderForm.vue | 7 + .../views/market/presign/PresignOrderList.vue | 5 + .../project-business-statistics/design.md | 57 +++ .../project-business-statistics/proposal.md | 9 + .../specs/project-management/spec.md | 31 ++ .../project-business-statistics/tasks.md | 24 ++ web-admin/src/api/collection-protocol.ts | 2 + web-admin/src/api/presign-order.ts | 2 + web-admin/src/api/project-management.ts | 66 +++ web-admin/src/api/research-donation.ts | 2 + .../src/components/business/ProjectSelect.vue | 107 +++++ web-admin/src/router/modules/sqxp.ts | 4 + web-admin/src/utils/project-management.ts | 60 +++ .../presign-order/PresignOrderFormDialog.vue | 39 +- .../cell-storage/presign-order/index.vue | 20 +- .../protocol-audit/DonationProtocolTab.vue | 20 +- .../protocol-audit/ProtocolAuditTab.vue | 20 +- .../protocol/ProtocolFormDialog.vue | 37 ++ .../protocol/ProtocolViewDialog.vue | 8 + .../src/views/cell-storage/protocol/index.vue | 20 +- .../ApplicationFormDialog.vue | 38 ++ .../ApplicationViewDialog.vue | 10 + .../pilot-application/PilotApplicationTab.vue | 34 +- .../project-management/ManagementPage.vue | 390 +++++++++++++++++ .../views/project-management/RecordList.vue | 282 ++++++++++++ .../project-management/assignments/index.vue | 8 + .../project-management/projects/index.vue | 4 + .../project-management/statistics/index.vue | 117 +++++ .../views/project-management/teams/index.vue | 4 + .../list/DonationFormDialog.vue | 37 ++ .../list/DonationViewDialog.vue | 8 + .../views/research-donation/list/index.vue | 20 +- web-admin/tests/project-management.test.ts | 75 ++++ 79 files changed, 3390 insertions(+), 25 deletions(-) create mode 100644 backend/app/api/project_management.py create mode 100644 backend/app/models/project_management.py create mode 100644 backend/app/services/project_assignment.py create mode 100644 backend/app/services/project_management.py create mode 100644 backend/tests/test_market_project_assignment.py create mode 100644 backend/tests/test_project_assignment_integration.py create mode 100644 backend/tests/test_project_management.py create mode 100644 docs/sql/migrations/2026-11-02-project_management.sql create mode 100644 docs/sql/migrations/2026-11-02-project_management_menu_seed.sql create mode 100644 mobile-oa-h5/src/market/components/ProjectAssignmentField.vue create mode 100644 mobile-oa-h5/src/market/project-assignment-state.test.js create mode 100644 mobile-oa-h5/src/market/project-assignment.js create mode 100644 mobile-oa-h5/src/market/project-assignment.test.js create mode 100644 openspec/changes/project-business-statistics/design.md create mode 100644 openspec/changes/project-business-statistics/proposal.md create mode 100644 openspec/changes/project-business-statistics/specs/project-management/spec.md create mode 100644 openspec/changes/project-business-statistics/tasks.md create mode 100644 web-admin/src/api/project-management.ts create mode 100644 web-admin/src/components/business/ProjectSelect.vue create mode 100644 web-admin/src/utils/project-management.ts create mode 100644 web-admin/src/views/project-management/ManagementPage.vue create mode 100644 web-admin/src/views/project-management/RecordList.vue create mode 100644 web-admin/src/views/project-management/assignments/index.vue create mode 100644 web-admin/src/views/project-management/projects/index.vue create mode 100644 web-admin/src/views/project-management/statistics/index.vue create mode 100644 web-admin/src/views/project-management/teams/index.vue create mode 100644 web-admin/tests/project-management.test.ts diff --git a/backend/README.md b/backend/README.md index 853d055d..8aadbd61 100644 --- a/backend/README.md +++ b/backend/README.md @@ -49,3 +49,18 @@ SQL 位于 [docs/sql](../docs/sql/)。当前初始化基线不完整,部分历 - 运行受影响测试;测试数量随代码变化,不把旧报告视为当前通过证明。 - CRUD 修改从根目录运行 `python backend/scripts/check_field_consistency.py`,同时人工核对新增或未注册实体;已知覆盖限制见[字段一致性指南](../docs/dev-field-consistency.md)。 - `scripts/` 中的 migration、seed、reset、clear、import 工具可能写库,先读用途和数据范围,不批量执行。 + + +## 项目归属启用 + +项目组的区域、医院仅作资料记录;不参与细胞存储和科研捐赠的业务地点、权限校验。 +切换到项目统计版本前,先在已授权数据库执行增量结构脚本 +[2026-11-02-project_management.sql](../docs/sql/migrations/2026-11-02-project_management.sql), +再执行[菜单种子](../docs/sql/migrations/2026-11-02-project_management_menu_seed.sql)。 +菜单种子不会给角色自动授权,应在现有角色管理分配项目管理权限及祖先菜单。 +脚本不回填历史业务,不代表空库初始化;执行前备份并在隔离 MySQL 环境验证。 + +`PROJECT_ASSIGNMENT_REQUIRED` 默认 `false`,便于先配置项目组和项目、兼容历史客户端。 +人员和启用项目配置齐全后由运维将其设为 `true`;新建预签约及四类正式业务必须选择项目。 +普通编辑保留历史归属,历史补录/调整须通过归属维护并填写原因;受控业务转换继承原归属。 +该开关同时返回给 Web 和移动表单,调整配置后的服务重启由用户执行。 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index e1170073..eaebcd78 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -46,7 +46,10 @@ from app.api.customer_service_dict import router as customer_service_dict_router from app.api.sample_cert_insurance import router as sample_cert_insurance_router from app.api.sample_code_correction import router as sample_code_correction_router +from app.api.project_management import router as project_management_router + __all__ = [ + "project_management_router", "public_router", "menu_router", "admin_router", diff --git a/backend/app/api/collection_protocol.py b/backend/app/api/collection_protocol.py index c952ecdb..32d4748d 100644 --- a/backend/app/api/collection_protocol.py +++ b/backend/app/api/collection_protocol.py @@ -8,6 +8,7 @@ from sqlalchemy.orm import Session from sqlalchemy import and_ as sa_and, func as sa_func +from app.services.project_assignment import create_assignment, guard_project_edit from app.core.database import get_db from app.core.data_scope import get_user_dept_id from app.core.menu_guard import require_admin_or_super_admin @@ -118,6 +119,8 @@ def _parse_datetime_fields(d: dict, camel_keys: list) -> None: @router.get("/collection-protocols") async def list_collection_protocols( + projectId: Optional[str] = Query(None, alias="projectId"), + teamId: Optional[str] = Query(None, alias="teamId"), page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = None, @@ -132,6 +135,8 @@ async def list_collection_protocols( tenant_no = _get_tenant_no(db, user) items, total = cp_svc.list_collection_protocols( db, + project_id=projectId, + team_id=teamId, page=page, page_size=pageSize, keyword=keyword, @@ -172,6 +177,7 @@ async def get_collection_protocol_detail( class ProtocolCreateBody(BaseModel): + project_id: Optional[str] = Field(None, alias="projectId") model_config = ConfigDict(populate_by_name=True) protocol_no: Optional[str] = Field(None, alias="protocolNo") @@ -199,6 +205,7 @@ class ProtocolCreateBody(BaseModel): class ProtocolUpdateBody(BaseModel): + project_id: Optional[str] = Field(None, alias="projectId") model_config = ConfigDict(populate_by_name=True) protocol_no: Optional[str] = Field(None, alias="protocolNo") @@ -377,6 +384,8 @@ async def create_collection_protocol( user_id = _get_current_user_id(user) protocol_id: Optional[str] = None try: + if body.source_presign_order_id: + raise ValueError("请通过预签约转正式入口创建关联协议") protocol_attachments = _resolve_protocol_attachments( body.protocol_attachments, body.attachments, @@ -394,6 +403,7 @@ async def create_collection_protocol( collector = user_id obj = cp_svc.create_collection_protocol( db, + project_id=create_assignment(db, tenant_no, user, body.project_id, "cell_storage"), create_dept_id=get_user_dept_id(db, user_id) or "", protocol_no=body.protocol_no or "", protocol_no_id=body.protocol_no_id, @@ -443,7 +453,7 @@ async def create_collection_protocol( else None ), ) - return success_response(cp_svc._protocol_to_dict(obj), message="创建成功") + return success_response(cp_svc._protocol_to_dict(obj, db), message="创建成功") except ValueError as e: db.rollback() return error_response(400, str(e)) @@ -463,6 +473,13 @@ async def update_collection_protocol( return error_response(404, "协议单不存在或无权操作") try: payload = body.model_dump(exclude_unset=True, by_alias=False) + current = db.query(BizCollectionProtocol).filter( + BizCollectionProtocol.id == protocol_id, BizCollectionProtocol.tenant_no == tenant_no, + BizCollectionProtocol.del_flag == 0).with_for_update().first() + guard_project_edit(current, payload) + if "source_presign_order_id" in payload and (payload["source_presign_order_id"] or None) != current.source_presign_order_id: + raise ValueError("来源预签约不可修改") + payload.pop("project_id", None) sub_keys = { "party_customer", "collection_info", "samples", "payment", "payment_details", "sample_payments", "attachments", @@ -534,7 +551,7 @@ async def update_collection_protocol( else None ), ) - return success_response(cp_svc._protocol_to_dict(obj), message="更新成功") + return success_response(cp_svc._protocol_to_dict(obj, db), message="更新成功") except ValueError as e: db.rollback() return error_response(400, str(e)) diff --git a/backend/app/api/market_mobile.py b/backend/app/api/market_mobile.py index 428ff30a..00efb48d 100644 --- a/backend/app/api/market_mobile.py +++ b/backend/app/api/market_mobile.py @@ -68,6 +68,8 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None: page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = None, + project_id: Optional[str] = Query(None, alias='projectId'), + team_id: Optional[str] = Query(None, alias='teamId'), order_status: Optional[str] = Query(None, alias="orderStatus"), user: dict = Depends(permission_guard), db: Session = Depends(get_db), @@ -80,6 +82,8 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None: page=page, page_size=page_size, keyword=keyword, + project_id=project_id, + team_id=team_id, order_status=order_status, ) items = [ @@ -331,6 +335,8 @@ def _register_protocol_routes(scope_kind: ScopeKind) -> None: page: int = Query(1, ge=1), page_size: int = Query(10, ge=1, le=100, alias='pageSize'), keyword: Optional[str] = None, + project_id: Optional[str] = Query(None, alias='projectId'), + team_id: Optional[str] = Query(None, alias='teamId'), source: Optional[Literal['cell_storage', 'research_donation']] = None, protocol_status: Optional[str] = Query(None, alias='protocolStatus'), create_time_start: Optional[str] = Query(None, alias='createTimeStart'), @@ -352,6 +358,8 @@ def _register_protocol_routes(scope_kind: ScopeKind) -> None: page=page, page_size=page_size, keyword=keyword, + project_id=project_id, + team_id=team_id, source=source, protocol_status=protocol_status, create_time_start=create_time_start, @@ -1093,3 +1101,27 @@ def _register_attachment_routes(scope_kind: ScopeKind) -> None: _register_attachment_routes('specialist') _register_attachment_routes('manager') + + +def _register_project_filter_routes(scope_kind: ScopeKind) -> None: + from app.core.auth import get_current_user + from fastapi import HTTPException + from app.services import project_management + from app.services.market_field_scope import apply_presign_scope, apply_cell_protocol_scope, apply_donation_scope + + @router.get(f'/mini/market/{scope_kind}/project-options') + def project_filter_options(bizType: Literal['presign', 'cell_storage', 'research_donation'], + user: dict = Depends(get_current_user), db: Session = Depends(get_db)): + module = 'presign' if bizType == 'presign' else 'protocol' + if not _has_menu(db, user, f'mini:market:{scope_kind}:{module}'): + raise HTTPException(403, '无移动端菜单权限') + model = project_management.BUSINESS[bizType][0] + apply_scope = {'presign': apply_presign_scope, 'cell_storage': apply_cell_protocol_scope, + 'research_donation': apply_donation_scope}[bizType] + query = apply_scope(db.query(model), db, _context(user, scope_kind), user_info=user) + return success_response(project_management.filter_options( + db, user['tenant_no'], user, bizType, business_scope=query)) + + +_register_project_filter_routes('specialist') +_register_project_filter_routes('manager') diff --git a/backend/app/api/pilot_application.py b/backend/app/api/pilot_application.py index 4f17b777..010a8b31 100644 --- a/backend/app/api/pilot_application.py +++ b/backend/app/api/pilot_application.py @@ -1,15 +1,16 @@ """中试服务申请单管理 API(申请单、患者信息、输注方案、输注计划、出库联动)""" from typing import 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 +from app.services.project_assignment import create_assignment, accessible_order from app.core.database import get_db from app.core.menu_guard import require_admin_or_super_admin from app.core.response import error_response, success_response from app.crud import pilot_application -from app.models.models import SysUser +from app.models.models import SysUser, BizPilotApplication router = APIRouter(prefix="/web/admin", tags=["中试服务申请单管理"]) @@ -42,6 +43,7 @@ class PlanIdBody(BaseModel): class ApplicationCreateBody(BaseModel): + project_id: Optional[str] = Field(None, alias="projectId") model_config = ConfigDict(populate_by_name=True) channel_service_order_id: Optional[str] = Field(None, alias="channelServiceOrderId") @@ -54,6 +56,7 @@ class ApplicationCreateBody(BaseModel): class ApplicationUpdateBody(BaseModel): + project_id: Optional[str] = Field(None, alias="projectId") model_config = ConfigDict(populate_by_name=True) id: str @@ -190,6 +193,8 @@ class InfusionPlanNestedBody(BaseModel): class ApplicationSaveBody(BaseModel): """一揽子保存:申请单 + 患者信息 + 输注方案 + 输注计划(含细胞产品)""" + project_id: Optional[str] = Field(None, alias="projectId") + model_config = ConfigDict(populate_by_name=True) id: Optional[str] = None @@ -221,6 +226,8 @@ class ApplicationSaveBody(BaseModel): @router.get("/pilot-application/list") async def api_list_applications( + projectId: Optional[str] = Query(None, alias="projectId"), + teamId: Optional[str] = Query(None, alias="teamId"), page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = Query(None, alias="keyword"), @@ -235,6 +242,8 @@ async def api_list_applications( result = pilot_application.list_applications( db, tenant_no=tenant_no, + project_id=projectId, + team_id=teamId, page=page, page_size=pageSize, channel_id=channelId, @@ -242,6 +251,7 @@ async def api_list_applications( channel_service_order_id=channelServiceOrderId, keyword=keyword, biz_type=bizType, + user_info=user, ) return success_response(result) @@ -253,6 +263,7 @@ async def api_get_application( db: Session = Depends(get_db), ): tenant_no = _get_tenant_no(db, user) + accessible_order(db, BizPilotApplication, tenant_no, user, id) result = pilot_application.get_application(db, app_id=id, tenant_no=tenant_no) if result is None: return error_response(404, "申请单不存在") @@ -268,6 +279,7 @@ async def api_create_application( try: tenant_no = _get_tenant_no(db, user) data = { + "projectId": create_assignment(db, tenant_no, user, body.project_id, body.biz_type or "pilot_service"), "channelServiceOrderId": body.channel_service_order_id or "", "remark": body.remark, "bizType": body.biz_type or "pilot_service", @@ -296,7 +308,10 @@ async def api_update_application( ): try: tenant_no = _get_tenant_no(db, user) + accessible_order(db, BizPilotApplication, tenant_no, user, body.id, lock=True) data = {} + if "project_id" in body.model_fields_set: + data["projectId"] = body.project_id if body.remark is not None: data["remark"] = body.remark if body.project_title is not None: @@ -334,6 +349,11 @@ async def api_save_application( "bizType": biz_type, "remark": body.remark, } + if is_edit: + if "project_id" in body.model_fields_set: + app_data["projectId"] = body.project_id + else: + app_data["projectId"] = create_assignment(db, tenant_no, user, body.project_id, biz_type) if body.application_name is not None: app_data["applicationName"] = body.application_name if body.project_title is not None: @@ -363,6 +383,8 @@ async def api_save_application( if not is_edit and biz_type == "clinical_application" and not app_data["channelId"]: return error_response(400, "请选择渠道") + if is_edit: + accessible_order(db, BizPilotApplication, tenant_no, user, body.id, lock=True) if is_edit: app_result = pilot_application.update_application( db, @@ -446,6 +468,9 @@ async def api_save_application( return success_response(app_result, message="保存成功") except ValueError as e: return error_response(400, str(e)) + except HTTPException: + db.rollback() + raise except Exception as e: return error_response(500, str(e)) diff --git a/backend/app/api/presign_order.py b/backend/app/api/presign_order.py index 66656374..9230d0b0 100644 --- a/backend/app/api/presign_order.py +++ b/backend/app/api/presign_order.py @@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, ConfigDict, Field, field_validator from sqlalchemy.orm import Session +from app.services.project_assignment import create_assignment from app.core.database import get_db from app.core.data_scope import get_user_dept_id from app.core.menu_guard import require_admin_or_super_admin @@ -37,6 +38,7 @@ def _get_current_user_id(user: dict) -> str: class PresignOrderBody(BaseModel): + project_id: Optional[str] = Field(None, alias="projectId") model_config = ConfigDict(extra="ignore", populate_by_name=True) order_date: Optional[str] = Field(None, alias="orderDate") @@ -92,6 +94,8 @@ class FinanceConfirmBody(BaseModel): @router.get("/presign-orders") async def list_presign_orders( + projectId: Optional[str] = Query(None, alias="projectId"), + teamId: Optional[str] = Query(None, alias="teamId"), page: int = Query(1, ge=1), pageSize: int = Query(10, ge=1, le=100, alias="pageSize"), keyword: Optional[str] = None, @@ -104,6 +108,8 @@ async def list_presign_orders( items, total = po_svc.list_presign_orders( db, tenant_no=tenant_no, + project_id=projectId, + team_id=teamId, page=page, page_size=pageSize, keyword=keyword, @@ -144,6 +150,7 @@ async def create_presign_order( tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) dumped = body.model_dump(by_alias=False) + dumped["project_id"] = create_assignment(db, tenant_no, user, body.project_id, "presign") obj = po_svc.create_presign_order( db, create_dept_id=get_user_dept_id(db, user_id) or "", diff --git a/backend/app/api/project_management.py b/backend/app/api/project_management.py new file mode 100644 index 00000000..cd4a32eb --- /dev/null +++ b/backend/app/api/project_management.py @@ -0,0 +1,173 @@ +"""项目管理;所有管理路由显式校验精确菜单权限。""" +from typing import Optional, List, Literal +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session +from app.core.auth import get_current_user +from app.core.database import get_db +from app.core.menu_guard import require_admin_or_super_admin +from app.core.response import success_response +from app.services import project_management as svc + +router = APIRouter(tags=['项目管理']) +PREFIX = '/web/admin/project-management' + + +class Body(BaseModel): + model_config = ConfigDict(extra='forbid') + + +class Member(Body): + id: Optional[str] = None + userId: str + + +class Region(Body): + id: Optional[str] = None + regionId: str + + +class Hospital(Body): + id: Optional[str] = None + hospitalId: str + + +class TeamBody(Body): + teamName: str = Field(min_length=1, max_length=200) + leaderUserId: str + enabled: bool = True + members: List[Member] = Field(default_factory=list) + regions: List[Region] = Field(default_factory=list) + hospitals: List[Hospital] = Field(default_factory=list) + remark: Optional[str] = Field(None, max_length=512) + + +class ProjectBody(Body): + projectName: str = Field(min_length=1, max_length=200) + teamId: str + leaderUserId: str + bizTypes: List[str] + startDate: Optional[str] = None + endDate: Optional[str] = None + status: Literal['pending', 'active', 'completed', 'disabled'] = 'pending' + remark: Optional[str] = Field(None, max_length=512) + + +class PreviewBody(Body): + bizType: Literal['presign', 'cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'] + id: str + projectId: str + + +class Snapshot(BaseModel): + bizType: str + id: str + projectId: Optional[str] = None + + +class AssignmentBody(PreviewBody): + reason: str = Field(min_length=1, max_length=512) + expectedAssignments: List[Snapshot] + + +def identity(user=Depends(get_current_user)): + tenant = user.get('tenant_no') + if not tenant or not user.get('user_id'): + raise HTTPException(403, '缺少认证租户') + return tenant, user + + +def guard(*codes): + def dependency(ctx=Depends(identity), db: Session=Depends(get_db)): + svc.check_permission(db, ctx[0], ctx[1], *codes) + return ctx + return dependency + + +@router.get('/project-management/options') +def options(bizType: str, usage: Literal['assignment', 'filter']='assignment', keyword: Optional[str]=None, currentProjectId: Optional[str]=None, ctx=Depends(identity), db: Session=Depends(get_db)): + # Arbitrary currentProjectId cannot authorize reading historical project metadata. + if usage == 'filter': + require_admin_or_super_admin(ctx[1]) + method = svc.filter_options if usage == 'filter' else svc.options + return success_response(method(db, *ctx, bizType, keyword)) + + +@router.get(PREFIX + '/references') +def references(ctx=Depends(guard('project:team:list', 'project:team:edit', 'project:project:list', 'project:project:edit')), db: Session=Depends(get_db)): + return success_response(svc.references(db, ctx[0])) + + +@router.get(PREFIX + '/teams') +def teams(page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), keyword: Optional[str]=None, leaderUserId: Optional[str]=None, enabled: Optional[bool]=None, ctx=Depends(guard('project:team:list', 'project:project:list', 'project:project:edit')), db: Session=Depends(get_db)): + return success_response(svc.list_entities(db, ctx[0], svc.ProjectTeam, page, pageSize, keyword=keyword, leaderUserId=leaderUserId, enabled=enabled)) + + +@router.get(PREFIX + '/projects') +def projects(page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), keyword: Optional[str]=None, teamId: Optional[str]=None, status: Optional[str]=None, ctx=Depends(guard('project:project:list')), db: Session=Depends(get_db)): + return success_response(svc.list_entities(db, ctx[0], svc.Project, page, pageSize, keyword=keyword, teamId=teamId, status=status)) + + +@router.post(PREFIX + '/teams') +def create_team(body: TeamBody, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)): + return success_response(svc.save_entity(db, *ctx, svc.ProjectTeam, body.model_dump())) + + +@router.post(PREFIX + '/projects') +def create_project(body: ProjectBody, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)): + return success_response(svc.save_entity(db, *ctx, svc.Project, body.model_dump())) + + +@router.get(PREFIX + '/teams/{row_id}') +def get_team(row_id: str, ctx=Depends(guard('project:team:list')), db: Session=Depends(get_db)): + return success_response(svc.serialize_entity(db, ctx[0], svc.get_row(db, svc.ProjectTeam, ctx[0], row_id))) + + +@router.get(PREFIX + '/projects/{row_id}') +def get_project(row_id: str, ctx=Depends(guard('project:project:list')), db: Session=Depends(get_db)): + return success_response(svc.serialize_entity(db, ctx[0], svc.get_row(db, svc.Project, ctx[0], row_id))) + + +@router.put(PREFIX + '/teams/{row_id}') +def update_team(row_id: str, body: TeamBody, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)): + return success_response(svc.save_entity(db, *ctx, svc.ProjectTeam, body.model_dump(), row_id)) + + +@router.put(PREFIX + '/projects/{row_id}') +def update_project(row_id: str, body: ProjectBody, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)): + return success_response(svc.save_entity(db, *ctx, svc.Project, body.model_dump(), row_id)) + + +@router.delete(PREFIX + '/teams/{row_id}') +def delete_team(row_id: str, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)): + return success_response(svc.delete_entity(db, *ctx, svc.ProjectTeam, row_id)) + + +@router.delete(PREFIX + '/projects/{row_id}') +def delete_project(row_id: str, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)): + return success_response(svc.delete_entity(db, *ctx, svc.Project, row_id)) + + +@router.get(PREFIX + '/statistics') +def statistics(groupBy: str='project', teamId: Optional[str]=None, projectId: Optional[str]=None, startDate: Optional[str]=None, endDate: Optional[str]=None, ctx=Depends(guard('project:statistics:list')), db: Session=Depends(get_db)): + return success_response(svc.statistics(db, *ctx, groupBy=groupBy, teamId=teamId, projectId=projectId, startDate=startDate, endDate=endDate)) + + +@router.get(PREFIX + '/records') +def records(bizType: str, page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), teamId: Optional[str]=None, projectId: Optional[str]=None, unassigned: bool=False, status: Optional[str]=None, keyword: Optional[str]=None, startDate: Optional[str]=None, endDate: Optional[str]=None, ctx=Depends(guard('project:statistics:list', 'project:assignment:edit')), db: Session=Depends(get_db)): + return success_response(svc.records(db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId, unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate)) + + +@router.post(PREFIX + '/assignments/preview') +def preview(body: PreviewBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)): + return success_response(svc.preview_assignment(db, *ctx, body.bizType, body.id, body.projectId)) + + +@router.post(PREFIX + '/assignments') +def assign(body: AssignmentBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)): + return success_response(svc.assign(db, *ctx, body.bizType, body.id, body.projectId, body.reason, [r.model_dump() for r in body.expectedAssignments])) + + +@router.get(PREFIX + '/assignments/history') +def history(bizType: str, id: str, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)): + return success_response(svc.assignment_history(db, *ctx, bizType, id)) diff --git a/backend/app/api/research_donation.py b/backend/app/api/research_donation.py index 393209cb..016f79a5 100644 --- a/backend/app/api/research_donation.py +++ b/backend/app/api/research_donation.py @@ -5,6 +5,9 @@ from fastapi import APIRouter, Depends, Query from pydantic import BaseModel, ConfigDict, Field from sqlalchemy.orm import Session +from app.models.models import BizCollectionProtocol +from app.models.research_donation import BizResearchDonation +from app.services.project_assignment import create_assignment, accessible_order from app.core.database import get_db from app.core.menu_guard import require_admin_or_super_admin from app.core.response import error_response, success_response @@ -44,6 +47,7 @@ def _get_current_user_id(user: dict) -> str: class DonationCreateBody(BaseModel): + project_id: Optional[str] = Field(None, alias="projectId") model_config = ConfigDict(populate_by_name=True) donation_no: Optional[str] = Field(None, alias="donationNo") @@ -71,6 +75,7 @@ class DonationCreateBody(BaseModel): class DonationUpdateBody(BaseModel): + project_id: Optional[str] = Field(None, alias="projectId") model_config = ConfigDict(populate_by_name=True) donation_no: Optional[str] = Field(None, alias="donationNo") @@ -143,6 +148,8 @@ def assert_donation_sensitive_fields( @router.get("/research-donations") async def list_research_donations( + projectId: Optional[str] = Query(None, alias="projectId"), + teamId: Optional[str] = Query(None, alias="teamId"), page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200, alias="pageSize"), keyword: Optional[str] = Query(None, alias="keyword"), @@ -158,6 +165,8 @@ async def list_research_donations( result = rd_svc.list_donations( db, tenant_no=tenant_no, + project_id=projectId, + team_id=teamId, page=page, page_size=pageSize, keyword=keyword, @@ -250,7 +259,10 @@ async def create_research_donation( tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: + if body.source_protocol_id or body.source_type not in (None, "", "direct"): + raise ValueError("请通过存储转科研入口创建关联捐赠") data = body.model_dump(exclude_none=False, by_alias=True) + data["projectId"] = create_assignment(db, tenant_no, user, body.project_id, "research_donation") collector = (data.get("collectorUserId") or data.get("collector_user_id") or "").strip() if not collector: data["collectorUserId"] = user_id @@ -295,6 +307,7 @@ async def update_research_donation( tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: + accessible_order(db, BizResearchDonation, tenant_no, user, donation_id, lock=True) data = body.model_dump(exclude_unset=True, by_alias=True) before_codes = sample_codes_from_orm_rows( rd_svc.list_samples(db, donation_id, tenant_no) @@ -482,6 +495,7 @@ async def convert_from_storage( tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: + accessible_order(db, BizCollectionProtocol, tenant_no, user, body.protocol_id, lock=True) obj = rd_svc.convert_from_storage( db, body.protocol_id, @@ -510,6 +524,7 @@ async def convert_to_storage( tenant_no = _get_tenant_no(db, user) user_id = _get_current_user_id(user) try: + accessible_order(db, BizResearchDonation, tenant_no, user, donation_id, lock=True) obj = rd_svc.convert_to_storage( db, donation_id, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6bc46d50..3561bff3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -22,6 +22,9 @@ _MIN_JWT_SECRET_KEY_LEN = 32 class Settings(BaseSettings): """应用配置""" + # 完成项目配置后启用新建单据必填;历史编辑不受影响。 + PROJECT_ASSIGNMENT_REQUIRED: bool = False + # 应用基础配置 APP_NAME: str = "zkxw 管理骨架" DEBUG: bool = True diff --git a/backend/app/crud/collection_protocol.py b/backend/app/crud/collection_protocol.py index 1bc92a07..fbf035db 100644 --- a/backend/app/crud/collection_protocol.py +++ b/backend/app/crud/collection_protocol.py @@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session +from app.services.project_assignment import assignment_brief, filter_assignment, guard_project_edit from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date from app.crud.child_row_sync import sync_child_rows from app.crud.base import camel_to_snake @@ -435,9 +436,10 @@ def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]: return {u.id: u.nick_name or "" for u in rows} -def _protocol_to_dict(p: BizCollectionProtocol) -> Dict[str, Any]: +def _protocol_to_dict(p: BizCollectionProtocol, db=None) -> Dict[str, Any]: return { "id": p.id, + **assignment_brief(db, p.tenant_no, p.project_id), "protocolNoId": p.protocol_no_id or "", "protocolNo": p.protocol_no or "", "protocolStatus": p.protocol_status or "", @@ -1085,6 +1087,8 @@ def _storage_in_date_to_yyyymmdd(value: str) -> str: def list_collection_protocols( db: Session, *, + project_id: Optional[str] = None, + team_id: Optional[str] = None, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, @@ -1100,6 +1104,7 @@ def list_collection_protocols( q0 = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.del_flag == 0) if tenant_no: q0 = q0.filter(BizCollectionProtocol.tenant_no == tenant_no) + q0 = filter_assignment(q0, BizCollectionProtocol, db, tenant_no, project_id, team_id) if protocol_status: q0 = q0.filter(BizCollectionProtocol.protocol_status == protocol_status.strip()) if create_time_start: @@ -1294,7 +1299,7 @@ def list_collection_protocols( # 合并主表 + 子表数据 result = [] for r in rows: - d = _protocol_to_dict(r) + d = _protocol_to_dict(r, db) pc = pc_map.get(r.id) ci = ci_map.get(r.id) d["collectorUserName"] = user_name_map.get(d.get("collectorUserId", ""), "") @@ -1443,7 +1448,7 @@ def get_protocol_detail_bundle( ci = get_collection_info(db, protocol_id, tenant_no) s_rows = list_samples(db, protocol_id, tenant_no) sc = get_storage_cert(db, protocol_id, tenant_no) - protocol_dict = _protocol_to_dict(p) + protocol_dict = _protocol_to_dict(p, db) ci_dict = _collection_info_to_dict(ci) if ci else None user_ids: List[str] = [] if protocol_dict.get("collectorUserId"): @@ -2540,6 +2545,7 @@ def list_installment_warning( def create_collection_protocol( db: Session, *, + project_id: Optional[str] = None, protocol_no_id: Optional[str] = None, protocol_no: str = "", protocol_status: str = "dsx", @@ -2577,6 +2583,7 @@ def create_collection_protocol( if protocol_total_amount not in (None, ""): total_amt = Decimal(str(protocol_total_amount)) obj = BizCollectionProtocol( + project_id=project_id, id=generate_uuid(), protocol_no_id=(protocol_no_id or "").strip() or None, protocol_no=pn, @@ -2722,8 +2729,8 @@ def update_collection_protocol( obj.protocol_total_amount = None else: obj.protocol_total_amount = Decimal(str(protocol_total_amount)) - if source_presign_order_id is not None: - obj.source_presign_order_id = (source_presign_order_id or "").strip() or None + if source_presign_order_id is not None and (source_presign_order_id.strip() or None) != obj.source_presign_order_id: + raise ValueError("来源预签约不可修改,请使用受控转换入口") if remark is not None: obj.remark = remark.strip() or None obj.update_by = user_id diff --git a/backend/app/crud/pilot_application.py b/backend/app/crud/pilot_application.py index c78d0810..3067ebc0 100644 --- a/backend/app/crud/pilot_application.py +++ b/backend/app/crud/pilot_application.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session +from app.services.project_assignment import assignment_brief, filter_assignment, guard_project_edit from app.models.models import ( BizChannel, BizChannelServiceOrder, @@ -117,6 +118,7 @@ def _row_to_dict_application(row, db=None, tenant_no=None) -> Dict[str, Any]: tn = tenant_no or row.tenant_no d: Dict[str, Any] = { "id": row.id, + **assignment_brief(db, tn, row.project_id), "applicationNo": row.application_no or "", "applicationName": row.application_name or "", "projectTitle": row.project_title or "", @@ -319,6 +321,9 @@ def _row_to_dict_infusion_schedule(row, cell_products: Optional[List[Dict[str, A def list_applications( db: Session, tenant_no: str, + user_info=None, + project_id: Optional[str] = None, + team_id: Optional[str] = None, page: int = 1, page_size: int = 10, channel_id: Optional[str] = None, @@ -331,6 +336,10 @@ def list_applications( BizPilotApplication.tenant_no == tenant_no, BizPilotApplication.del_flag == 0, ) + q = filter_assignment(q, BizPilotApplication, db, tenant_no, project_id, team_id) + if user_info is not None: + from app.core.data_scope import apply_data_scope + q = apply_data_scope(q, BizPilotApplication, user_info, db, tenant_no) if channel_id: q = q.filter(BizPilotApplication.channel_id == channel_id) if application_status: @@ -510,6 +519,7 @@ def create_application( application_no = _generate_application_no(db, tenant_no, biz_type=biz_type) row = BizPilotApplication( + project_id=data.get("projectId", data.get("project_id")) or None, id=str(uuid.uuid4()), application_no=application_no, application_name=data.get("applicationName") or "", @@ -558,6 +568,7 @@ def update_application(db: Session, app_id: str, data: dict, tenant_no: str) -> ) if not row: raise ValueError("申请单不存在") + guard_project_edit(row, data) if row.application_status not in ("dks", "jxz"): raise ValueError("当前状态不允许编辑") diff --git a/backend/app/crud/presign_order.py b/backend/app/crud/presign_order.py index 08214af5..98d8a119 100644 --- a/backend/app/crud/presign_order.py +++ b/backend/app/crud/presign_order.py @@ -10,6 +10,7 @@ from sqlalchemy import and_, func, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from app.services.project_assignment import assignment_brief, filter_assignment, guard_project_edit from app.core.utils import generate_uuid, parse_datetime from app.crud.child_row_sync import sync_child_rows from app.crud.biz_no_generator import allocate_next_biz_no @@ -414,6 +415,7 @@ def order_to_dict( ) data: Dict[str, Any] = { "id": o.id, + **assignment_brief(db, tenant_no, o.project_id), "orderDate": o.order_date or "", "protocolNo": o.protocol_no or "", "motherName": o.mother_name or "", @@ -463,6 +465,8 @@ def list_presign_orders( db: Session, *, tenant_no: str, + project_id: Optional[str] = None, + team_id: Optional[str] = None, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, @@ -473,6 +477,7 @@ def list_presign_orders( BizPresignOrder.tenant_no == tenant_no, BizPresignOrder.del_flag == 0, ) + q = filter_assignment(q, BizPresignOrder, db, tenant_no, project_id, team_id) st = (order_status or "").strip() if st: q = q.filter(BizPresignOrder.order_status == st) @@ -548,6 +553,7 @@ def create_presign_order( db, tenant_no=tenant_no, kind="collection", user_id=user_id ) obj = BizPresignOrder( + project_id=fields.get("project_id", fields.get("projectId")) or None, id=generate_uuid(), order_date=(fields.get("order_date") or fields.get("orderDate") or "").strip() or None, @@ -633,6 +639,7 @@ def update_presign_order( commit: bool = True, ) -> BizPresignOrder: obj = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no) + guard_project_edit(obj, fields) if (obj.order_status or "") == "converted": raise ValueError("已转正式的预签约订单不可修改") mapping = { @@ -1095,6 +1102,7 @@ def convert_to_protocol( register_date=order.order_date, collector_user_id=staff["collector_user_id"], source_presign_order_id=order.id, + project_id=order.project_id, payment_model_version=2, protocol_total_amount=None, remark=order.remark, diff --git a/backend/app/crud/research_donation.py b/backend/app/crud/research_donation.py index c8e400f8..167d37e4 100644 --- a/backend/app/crud/research_donation.py +++ b/backend/app/crud/research_donation.py @@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session +from app.services.project_assignment import assignment_brief, filter_assignment, guard_project_edit from app.core.protocol_status_display import assert_protocol_status_transition from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date from app.crud.child_row_sync import sync_child_rows @@ -410,6 +411,7 @@ def _donation_to_dict( tn = tenant_no or d.tenant_no return { "id": d.id, + **assignment_brief(db, tn, d.project_id), "donationNo": d.donation_no or "", "monthlyVerifyNo": d.monthly_verify_no or "", "donationStatus": d.donation_status or "", @@ -649,6 +651,8 @@ def list_donations( db: Session, *, tenant_no: str, + project_id: Optional[str] = None, + team_id: Optional[str] = None, page: int = 1, page_size: int = 10, keyword: Optional[str] = None, @@ -666,6 +670,7 @@ def list_donations( ) if tenant_no: q = q.filter(BizResearchDonation.tenant_no == tenant_no) + q = filter_assignment(q, BizResearchDonation, db, tenant_no, project_id, team_id) if donation_status: q = q.filter(BizResearchDonation.donation_status == donation_status.strip()) if create_time_start: @@ -1159,6 +1164,7 @@ def create_donation( db, tenant_no=tenant_no, create_time=now ) obj = BizResearchDonation( + project_id=raw.get("project_id") or None, id=generate_uuid(), donation_no=donation_no, monthly_verify_no=monthly_verify_no, @@ -1269,7 +1275,11 @@ def update_donation( if not obj: raise ValueError("捐赠单不存在或无权操作") + guard_project_edit(obj, data) raw = _normalize_fields(data) + for source_field in ("source_type", "source_protocol_id"): + if source_field in raw and (raw[source_field] or None) != (getattr(obj, source_field) or None): + raise ValueError("转换来源不可修改,请使用受控转换入口") _resolve_donation_payload(raw) now = datetime.now() @@ -1704,6 +1714,7 @@ def convert_from_storage( BizCollectionProtocol.del_flag == 0, ) ) + .populate_existing().with_for_update() .first() ) if not proto: @@ -1772,6 +1783,7 @@ def convert_from_storage( # 创建主记录 donation = BizResearchDonation( + project_id=proto.project_id, id=generate_uuid(), donation_no=donation_no, monthly_verify_no=monthly_verify_no, @@ -1975,6 +1987,7 @@ def convert_to_storage( BizResearchDonation.del_flag == 0, ) ) + .populate_existing().with_for_update() .first() ) if not donation: @@ -2033,6 +2046,7 @@ def convert_to_storage( # 创建协议单 proto = BizCollectionProtocol( + project_id=donation.project_id, id=generate_uuid(), protocol_no_id=protocol_no_id, protocol_no=protocol_no, diff --git a/backend/app/main.py b/backend/app/main.py index 910a49b8..8c2484b9 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from app.core.database import SessionLocal from app.core.api_request_log_middleware import ApiRequestLogMiddleware from app.core.api_crypto_middleware import ApiCryptoMiddleware from app.models import models # noqa: F401 -from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router +from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router, project_management_router @asynccontextmanager @@ -157,6 +157,8 @@ app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX) app.include_router(customer_service_dict_router, prefix=settings.API_V1_PREFIX) app.include_router(sample_cert_insurance_router, prefix=settings.API_V1_PREFIX) app.include_router(sample_code_correction_router, prefix=settings.API_V1_PREFIX) +app.include_router(project_management_router, prefix=settings.API_V1_PREFIX) + def _mask_secret(value: str) -> str: diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 53a0135f..8f03be43 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -73,7 +73,14 @@ from app.models.research_donation import ( # noqa: F401 User = SysUser ApiRequestLog = SysApiRequestLog +from app.models.project_management import ( + Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion, + ProjectTeamHospital, ProjectAssignmentAudit, +) + __all__ = [ + "Project", "ProjectTeam", "ProjectTeamMember", "ProjectTeamRegion", + "ProjectTeamHospital", "ProjectAssignmentAudit", "BaseModel", "SoftDeleteModel", "SysUser", diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 60bf1632..b38898bb 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1071,6 +1071,7 @@ class BizCollectionProtocol(Base): ) id = Column(String(64), primary_key=True, comment="主键ID") + project_id = Column(String(64), nullable=True, index=True, comment="所属统计项目ID") protocol_no_id = Column(String(64), nullable=True, comment="采集协议号表ID") protocol_no = Column(String(32), nullable=False, comment="采集协议号展示") protocol_status = Column(String(16), nullable=False, default="dsx", comment="协议状态") @@ -1361,6 +1362,7 @@ class BizPresignOrder(Base): __tablename__ = "biz_presign_order" id = Column(String(64), primary_key=True, comment="主键ID") + project_id = Column(String(64), nullable=True, index=True, comment="所属统计项目ID") order_date = Column(CHAR(8), nullable=True, comment="业务日期YYYYMMDD") protocol_no = Column(String(64), nullable=True, comment="协议编号") mother_name = Column(String(64), nullable=True, comment="母亲姓名") @@ -4036,6 +4038,7 @@ class BizPilotApplication(Base): __tablename__ = "biz_pilot_application" id = Column(String(64), primary_key=True, comment="主键ID") + project_id = Column(String(64), nullable=True, index=True, comment="所属统计项目ID") application_no = Column(String(64), nullable=True, comment="申请单号(自动生成,前缀ZSSQ)") application_name = Column(String(200), nullable=True, comment="申请单名称") project_title = Column(String(200), nullable=True, comment="课题名称(基础实验)") diff --git a/backend/app/models/project_management.py b/backend/app/models/project_management.py new file mode 100644 index 00000000..1c3446c5 --- /dev/null +++ b/backend/app/models/project_management.py @@ -0,0 +1,67 @@ +"""租户项目及归属审计;区域医院关联只记录组织资料。""" +from datetime import datetime +from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, UniqueConstraint +from app.models.base import Base + + +class ProjectFields: + id = Column(String(64), primary_key=True) + tenant_no = Column(String(64), nullable=False, index=True) + order_num = Column(Integer) + remark = Column(String(512)) + create_by = Column(String(64)) + create_dept_id = Column(String(64), nullable=False, default='') + create_time = Column(DateTime, nullable=False, default=datetime.now) + update_by = Column(String(64)) + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + del_flag = Column(SmallInteger, nullable=False, default=0) + + +class ProjectTeam(ProjectFields, Base): + __tablename__ = 'biz_project_team' + team_name = Column(String(200), nullable=False) + leader_user_id = Column(String(64), nullable=False) + enabled = Column(SmallInteger, nullable=False, default=1) + + +class ProjectTeamMember(ProjectFields, Base): + __tablename__ = 'biz_project_team_member' + team_id = Column(String(64), nullable=False, index=True) + user_id = Column(String(64), nullable=False) + + +class ProjectTeamRegion(ProjectFields, Base): + __tablename__ = 'biz_project_team_region' + team_id = Column(String(64), nullable=False, index=True) + region_id = Column(String(64), nullable=False) + + +class ProjectTeamHospital(ProjectFields, Base): + __tablename__ = 'biz_project_team_hospital' + team_id = Column(String(64), nullable=False, index=True) + hospital_id = Column(String(64), nullable=False) + + +class Project(ProjectFields, Base): + __tablename__ = 'biz_project' + __table_args__ = (UniqueConstraint('tenant_no', 'project_no', name='uk_project_tenant_no'),) + project_no = Column(String(64), nullable=False) + project_name = Column(String(200), nullable=False) + team_id = Column(String(64), nullable=False, index=True) + leader_user_id = Column(String(64), nullable=False) + biz_types = Column(String(255), nullable=False) + start_date = Column(Date) + end_date = Column(Date) + status = Column(String(16), nullable=False, default='pending') + + +class ProjectAssignmentAudit(ProjectFields, Base): + __tablename__ = 'biz_project_assignment_audit' + biz_type = Column(String(32), nullable=False) + biz_id = Column(String(64), nullable=False, index=True) + old_project_id = Column(String(64)) + new_project_id = Column(String(64)) + old_project_name = Column(String(200)) + new_project_name = Column(String(200)) + reason = Column(String(512), nullable=False) + operator_name = Column(String(100)) diff --git a/backend/app/models/research_donation.py b/backend/app/models/research_donation.py index 2c5cf95c..90b3dfc4 100644 --- a/backend/app/models/research_donation.py +++ b/backend/app/models/research_donation.py @@ -12,6 +12,7 @@ class BizResearchDonation(Base): __tablename__ = "biz_research_donation" id = Column(String(64), primary_key=True, comment="主键ID") + project_id = Column(String(64), nullable=True, index=True, comment="所属统计项目ID") donation_no = Column(String(64), nullable=False, comment="捐赠协议编号") monthly_verify_no = Column(String(16), nullable=True, comment="当月核量号 YYYYMM+3位序号") donation_status = Column(String(16), nullable=False, default="dsx", comment="状态 dsx/sxz/ygq/yzf/sdfq/zckc") diff --git a/backend/app/schemas/market_mobile.py b/backend/app/schemas/market_mobile.py index a4ccfc6f..c04de555 100644 --- a/backend/app/schemas/market_mobile.py +++ b/backend/app/schemas/market_mobile.py @@ -116,6 +116,8 @@ class MarketPresignOrderBody(BaseModel): model_config = ConfigDict(extra='forbid', populate_by_name=True) + project_id: Optional[str] = Field(None, max_length=64, alias='projectId') + order_date: Optional[str] = Field(None, alias="orderDate") protocol_no: Optional[str] = Field( None, max_length=64, alias="protocolNo" @@ -441,6 +443,8 @@ class MarketProtocolBody(BaseModel): model_config = ConfigDict(extra='forbid', populate_by_name=True) + project_id: Optional[str] = Field(None, max_length=64, alias='projectId') + source: Literal['cell_storage', 'research_donation'] protocol_status: Optional[ Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zky'] diff --git a/backend/app/services/market_presign.py b/backend/app/services/market_presign.py index 7213cf57..288ace7e 100644 --- a/backend/app/services/market_presign.py +++ b/backend/app/services/market_presign.py @@ -9,6 +9,11 @@ from fastapi import HTTPException from sqlalchemy import func, or_ from sqlalchemy.orm import Session +from app.services.project_assignment import ( + create_assignment, + guard_project_edit, + filter_assignment, +) from app.crud import presign_order as presign_crud from app.models.models import ( BizCellSampleType, @@ -327,10 +332,15 @@ def list_presign_orders( page_size: int = 10, keyword: Optional[str] = None, order_status: Optional[str] = None, + project_id: Optional[str] = None, + team_id: Optional[str] = None, ) -> Tuple[List[dict], int]: query = apply_presign_scope( db.query(BizPresignOrder), db, ctx, user_info=user_info ) + query = filter_assignment( + query, BizPresignOrder, db, ctx.tenant_no, project_id, team_id + ) status = (order_status or "").strip() if status: query = query.filter(BizPresignOrder.order_status == status) @@ -417,6 +427,9 @@ def create_presign_order( user_info: dict, ) -> dict: fields = _dump_body(body) + fields["project_id"] = create_assignment( + db, ctx.tenant_no, user_info, body.project_id, "presign" + ) _reject_direct_conversion(fields) sample_type_ids = fields.pop("sample_type_ids", None) transfers = fields.pop("transfers", None) @@ -513,6 +526,8 @@ def update_presign_order( if (current.order_status or '').strip() == 'converted': raise ValueError('当前状态的预签约订单不可修改') fields = _dump_body(body) + guard_project_edit(current, fields) + fields.pop("project_id", None) _reject_direct_conversion(fields) sample_type_ids = fields.pop("sample_type_ids", None) fields.pop("transfers", None) diff --git a/backend/app/services/market_protocol.py b/backend/app/services/market_protocol.py index 2b2d75f6..3b1503cf 100644 --- a/backend/app/services/market_protocol.py +++ b/backend/app/services/market_protocol.py @@ -9,6 +9,12 @@ from fastapi import HTTPException from sqlalchemy import func, or_ from sqlalchemy.orm import Session +from app.services.project_assignment import ( + create_assignment, + guard_project_edit, + assignment_brief, + filter_assignment, +) from app.core.utils import parse_datetime from app.core.protocol_status_display import ( assert_protocol_status_transition, @@ -853,6 +859,8 @@ def list_protocols( create_time_end: Optional[str] = None, sample_collect_time_start: Optional[str] = None, sample_collect_time_end: Optional[str] = None, + project_id: Optional[str] = None, + team_id: Optional[str] = None, ) -> Tuple[List[dict], int]: if source and source not in SOURCES: raise ValueError('不支持的协议来源') @@ -880,6 +888,9 @@ def list_protocols( query = apply_cell_protocol_scope( db.query(BizCollectionProtocol), db, ctx, user_info=user_info ) + query = filter_assignment( + query, BizCollectionProtocol, db, ctx.tenant_no, project_id, team_id + ) if status: query = query.filter(BizCollectionProtocol.protocol_status == status) if created_from: @@ -952,6 +963,7 @@ def list_protocols( rows.extend( { 'id': row.id, + **assignment_brief(db, ctx.tenant_no, row.project_id), 'source': SOURCE_CELL, 'code': row.protocol_no or '', 'protocolNo': row.protocol_no or '', @@ -981,6 +993,10 @@ def list_protocols( query = apply_donation_scope( db.query(BizResearchDonation), db, ctx, user_info=user_info ) + if query is not None: + query = filter_assignment( + query, BizResearchDonation, db, ctx.tenant_no, project_id, team_id + ) if query is None: donation_rows = [] else: @@ -1036,6 +1052,7 @@ def list_protocols( rows.extend( { 'id': row.id, + **assignment_brief(db, ctx.tenant_no, row.project_id), 'source': SOURCE_DONATION, 'code': row.donation_no or '', 'protocolNo': row.donation_no or '', @@ -1458,7 +1475,7 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic collection_protocol_id=row.id, tenant_no=tenant_no, ) - basic_dict = cell_crud._protocol_to_dict(row) + basic_dict = cell_crud._protocol_to_dict(row, db) basic_dict['protocolTotalAmount'] = _fixed_money(row.protocol_total_amount) payment_dict = cell_crud._payment_to_dict(payment) if payment else None if payment_dict is not None: @@ -1532,7 +1549,7 @@ def _donation_detail( from app.crud import qc_task as _qc_task_svc # noqa: F401 web_bundle = donation_crud.get_donation_detail(db, row.id, tenant_no) or {} - basic = dict(web_bundle.get('donation') or donation_crud._donation_to_dict(row)) + basic = dict(web_bundle.get('donation') or donation_crud._donation_to_dict(row, db, tenant_no)) basic['convertTime'] = basic.get('convertTime') or basic.get('toStorageTime') or '' basic['convertDesc'] = basic.get('convertDesc') or basic.get('toStorageDesc') or '' audits = donation_crud.list_audits( @@ -1852,6 +1869,9 @@ def create_protocol( or body.payment_details is not None ): raise ValueError('科研捐赠不支持缴费方案') + project_id = create_assignment( + db, ctx.tenant_no, user_info, body.project_id, body.source + ) customer, collection, samples, attachments = _normalize_write( db, body=body, ctx=ctx ) @@ -1868,6 +1888,7 @@ def create_protocol( ) row = cell_crud.create_collection_protocol( db, + project_id=project_id, protocol_no=body.protocol_no or '', # 对齐 Web 新增弹窗:未传时默认生效中 protocol_status=(body.protocol_status or '').strip() or 'sxz', @@ -1949,6 +1970,7 @@ def create_protocol( if body.sample_payments: raise ValueError('科研捐赠不支持样本缴费方案') data = { + 'project_id': project_id, 'donation_no': body.donation_no or '', # 对齐 Web 新增弹窗:未传时默认生效中 'donation_status': (body.donation_status or '').strip() or 'sxz', @@ -2027,6 +2049,7 @@ def update_protocol( row = _lock_cell_protocol_access( db, protocol_id, ctx, user_info=user_info ) + guard_project_edit(row, _dump(body)) current_collection = cell_crud.get_collection_info( db, row.id, ctx.tenant_no ) @@ -2171,6 +2194,7 @@ def update_protocol( row = _lock_donation_access( db, protocol_id, ctx, user_info=user_info ) + guard_project_edit(row, _dump(body)) current_collection = donation_crud.get_collection( db, row.id, ctx.tenant_no ) diff --git a/backend/app/services/project_assignment.py b/backend/app/services/project_assignment.py new file mode 100644 index 00000000..01ea685d --- /dev/null +++ b/backend/app/services/project_assignment.py @@ -0,0 +1,51 @@ +"""Shared attribution boundary for existing business write and read paths.""" +from fastapi import HTTPException +from app.core.config import settings + + +def create_assignment(db, tenant_no, user, project_id, biz_type): + from app.services.project_management import validate_assignment + if biz_type == 'clinical_application': + if project_id: + raise HTTPException(400, '临床应用不支持项目归属') + return None + return validate_assignment(db, tenant_no, user, project_id, biz_type, + required=settings.PROJECT_ASSIGNMENT_REQUIRED) + + +def guard_project_edit(row, payload): + """Omission preserves attribution; explicit changes require audited maintenance.""" + key = 'projectId' if 'projectId' in payload else 'project_id' + if key in payload and (payload[key] or None) != (row.project_id or None): + raise HTTPException(400, '请通过项目归属维护调整') + + +def assignment_brief(db, tenant_no, project_id): + if not db or not project_id: + return dict(projectId=project_id, projectName=None, teamId=None, teamName=None) + from app.services.project_management import project_brief + return project_brief(db, tenant_no, project_id) + + +def filter_assignment(query, model, db, tenant_no, project_id=None, team_id=None): + if project_id: + query = query.filter(model.project_id == project_id) + if team_id: + from app.models.project_management import Project + projects = db.query(Project.id).filter(Project.tenant_no == tenant_no, + Project.team_id == team_id, Project.del_flag == 0) + query = query.filter(model.project_id.in_(projects)) + return query + + +def accessible_order(db, model, tenant_no, user, record_id, *, lock=False): + from app.core.data_scope import apply_data_scope + query = db.query(model).filter(model.id == record_id, model.tenant_no == tenant_no, + model.del_flag == 0) + query = apply_data_scope(query, model, user, db, tenant_no) + if lock: + query = query.populate_existing().with_for_update() + row = query.first() + if row is None: + raise HTTPException(404, '单据不存在或无权访问') + return row diff --git a/backend/app/services/project_management.py b/backend/app/services/project_management.py new file mode 100644 index 00000000..600c524e --- /dev/null +++ b/backend/app/services/project_management.py @@ -0,0 +1,404 @@ +"""项目资料、权限内统计和关联归属事务。""" +from datetime import date, datetime, timedelta +from fastapi import HTTPException +from sqlalchemy import or_, and_, func +from app.core.data_scope import apply_data_scope +from app.core.utils import generate_uuid +from app.crud.child_row_sync import sync_child_rows +from app.models.project_management import (Project, ProjectTeam, ProjectTeamMember, + ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit) +from app.models.models import (SysUser, BizCollectionRegion, BizCollectionHospital, + BizPresignOrder, BizCollectionProtocol, BizPilotApplication) +from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert +from app.services.web_auth_roles import iter_user_menu_perm_codes + +BUSINESS = { + 'presign': (BizPresignOrder, 'protocol_no', 'order_status'), + 'cell_storage': (BizCollectionProtocol, 'protocol_no', 'protocol_status'), + 'research_donation': (BizResearchDonation, 'donation_no', 'donation_status'), + 'pilot_service': (BizPilotApplication, 'application_no', 'application_status'), + 'basic_experiment': (BizPilotApplication, 'application_no', 'application_status'), +} +COUNT_TYPES = tuple(k for k in BUSINESS if k != 'presign') + + +def scoped(db, model, tenant): + return db.query(model).filter(model.tenant_no == tenant, model.del_flag == 0) + + +def get_row(db, model, tenant, row_id, lock=False): + query = scoped(db, model, tenant).filter(model.id == row_id) + row = (query.populate_existing().with_for_update() if lock else query).first() + if not row: + raise HTTPException(404, '记录不存在或无权访问') + return row + + +def has_permission(db, tenant, user, code): + return user.get('role') == 'super_admin' or code in iter_user_menu_perm_codes(db, user.get('user_id', ''), tenant_no=tenant) + + +def check_permission(db, tenant, user, *codes): + if not any(has_permission(db, tenant, user, code) for code in codes): + raise HTTPException(403, '无对应菜单权限') + + +def project_brief(db, tenant_no, project_id): + result = dict(projectId=project_id, projectName=None, teamId=None, teamName=None) + if project_id: + row = scoped(db, Project, tenant_no).filter(Project.id == project_id).first() + if row: + team = scoped(db, ProjectTeam, tenant_no).filter(ProjectTeam.id == row.team_id).first() + result.update(projectName=row.project_name, teamId=row.team_id, teamName=team.team_name if team else None) + return result + + +def validate_assignment(db, tenant_no, user, project_id, biz_type, *, current_project_id=None, required=False): + project_id = project_id or None + if current_project_id: + if project_id != current_project_id: + raise HTTPException(400, '请通过项目归属维护调整') + return current_project_id + if not project_id: + if required: + raise HTTPException(400, '请选择项目') + return None + row = get_row(db, Project, tenant_no, project_id, lock=True) + team = get_row(db, ProjectTeam, tenant_no, row.team_id, lock=True) + if row.status != 'active' or not team.enabled: + raise HTTPException(400, '项目或项目组已停用或尚未启用') + effective_type = 'cell_storage' if biz_type == 'presign' else biz_type + if effective_type not in row.biz_types.split(','): + raise HTTPException(400, '项目不支持该业务类型') + uid = user.get('user_id') + member = uid in (row.leader_user_id, team.leader_user_id) or scoped(db, ProjectTeamMember, tenant_no).filter(ProjectTeamMember.team_id == team.id, ProjectTeamMember.user_id == uid).with_for_update().first() + if not member and not has_permission(db, tenant_no, user, 'project:assignment:edit'): + raise HTTPException(403, '仅项目负责人或成员可选择此项目') + return project_id + + +def business_query(db, tenant, user, kind): + if kind not in BUSINESS: + raise HTTPException(400, '业务类型不支持') + model = BUSINESS[kind][0] + query = scoped(db, model, tenant) + if model is BizPilotApplication: + query = query.filter(model.biz_type == kind) + return apply_data_scope(query, model, user, db, tenant) + + +def filtered_query(db, tenant, user, kind, filters): + model, number, status = BUSINESS[kind] + q = business_query(db, tenant, user, kind) + if filters.get('projectId'): + q = q.filter(model.project_id == filters['projectId']) + if filters.get('teamId'): + q = q.filter(model.project_id.in_(scoped(db, Project, tenant).filter(Project.team_id == filters['teamId']).with_entities(Project.id))) + if filters.get('unassigned'): + q = q.filter(model.project_id.is_(None)) + if filters.get('keyword'): + q = q.filter(getattr(model, number).contains(filters['keyword'])) + for key, op in [('startDate', 'start'), ('endDate', 'end')]: + if filters.get(key): + try: + day = date.fromisoformat(str(filters[key])) + except ValueError: + raise HTTPException(400, '日期格式应为 YYYY-MM-DD') + q = q.filter(model.create_time >= datetime.combine(day, datetime.min.time())) if op == 'start' else q.filter(model.create_time < datetime.combine(day + timedelta(days=1), datetime.min.time())) + return q + + +def record_dict(db, tenant, kind, row): + return dict(id=row.id, bizType=kind, bizNo=getattr(row, BUSINESS[kind][1]), status=getattr(row, BUSINESS[kind][2]), createTime=row.create_time, **project_brief(db, tenant, row.project_id)) + + +def records(db, tenant, user, biz_type, page=1, pageSize=20, **filters): + if biz_type not in BUSINESS: + raise HTTPException(400, '业务类型不支持') + model, _, status = BUSINESS[biz_type] + q = filtered_query(db, tenant, user, biz_type, filters) + status_column = getattr(model, status) + status_counts = {str(value or ''): count for value, count in + q.with_entities(status_column, func.count(model.id)).group_by(status_column).all()} + if filters.get('status'): + q = q.filter(getattr(model, status) == filters['status']) + total = q.count() + rows = q.order_by(model.create_time.desc(), model.id).offset((page-1)*pageSize).limit(pageSize).all() + return dict(list=[record_dict(db, tenant, biz_type, r) for r in rows], total=total, statusCounts=status_counts) + + +def statistics(db, tenant, user, groupBy='project', **filters): + if groupBy not in ('project', 'team'): + raise HTTPException(400, '分组方式不支持') + totals = dict.fromkeys(COUNT_TYPES, 0) + unassigned = dict.fromkeys(COUNT_TYPES, 0) + counts = {} + for kind in COUNT_TYPES: + model = BUSINESS[kind][0] + counts[kind] = filtered_query(db, tenant, user, kind, filters).with_entities( + model.project_id, func.count(model.id)).group_by(model.project_id).all() + project_ids = {pid for values in counts.values() for pid, _ in values if pid} + metadata = {} + if project_ids: + rows = scoped(db, Project, tenant).outerjoin(ProjectTeam, and_( + ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant, + ProjectTeam.del_flag == 0)).filter(Project.id.in_(project_ids)).with_entities( + Project.id, Project.project_name, Project.team_id, ProjectTeam.team_name).all() + metadata = {r.id: r for r in rows} + items = {} + for kind, values in counts.items(): + for project_id, count in values: + totals[kind] += count + if not project_id: + unassigned[kind] += count + continue + info = metadata.get(project_id) + team_id = info.team_id if info else None + team_name = info.team_name if info else None + key = team_id if groupBy == 'team' else project_id + item = items.setdefault(key, dict(id=key, + name=team_name if groupBy == 'team' else (info.project_name if info else None), + teamName=team_name, **dict.fromkeys(COUNT_TYPES, 0))) + item[kind] += count + return dict(items=list(items.values()), totals=totals, unassignedTotals=unassigned, + presignTotal=filtered_query(db, tenant, user, 'presign', filters).count()) + + +def linked_records(db, tenant, user, kind, row_id, lock=False, current_read=False): + """构建双向来源链;无权关联记录导致整次操作拒绝。""" + def graph_query(model): + query = scoped(db, model, tenant).populate_existing() + return query.with_for_update() if current_read else query + + seen, pending, rows = set(), [(kind, row_id)], [] + while pending: + current, rid = pending.pop() + if (current, rid) in seen: + continue + seen.add((current, rid)) + model = BUSINESS[current][0] + row = get_row(db, model, tenant, rid, lock=current_read) + if not business_query(db, tenant, user, current).filter(model.id == rid).first(): + raise HTTPException(403, '关联记录不存在或无权访问') + rows.append((current, row)) + if current == 'presign': + pending += [('cell_storage', r.id) for r in graph_query(BizCollectionProtocol).filter(BizCollectionProtocol.source_presign_order_id == rid).all()] + if row.converted_protocol_id: + pending.append(('cell_storage', row.converted_protocol_id)) + if current == 'cell_storage': + if row.source_presign_order_id: + pending.append(('presign', row.source_presign_order_id)) + pending += [('presign', r.id) for r in graph_query(BizPresignOrder).filter(BizPresignOrder.converted_protocol_id == rid).all()] + pending += [('research_donation', r.id) for r in graph_query(BizResearchDonation).filter(BizResearchDonation.source_protocol_id == rid).all()] + if current == 'research_donation' and row.source_protocol_id: + pending.append(('cell_storage', row.source_protocol_id)) + if current in ('cell_storage', 'research_donation'): + col = BizProtocolDonationConvert.protocol_id if current == 'cell_storage' else BizProtocolDonationConvert.research_donation_id + for link in graph_query(BizProtocolDonationConvert).filter(col == rid).all(): + pending.extend([('cell_storage', link.protocol_id), ('research_donation', link.research_donation_id)]) + rows.sort(key=lambda pair: (pair[0], pair[1].id)) + if lock: + rows = [(k, get_row(db, BUSINESS[k][0], tenant, r.id, lock=True)) for k, r in rows] + return rows + + +def preview_assignment(db, tenant, user, kind, row_id, project_id): + check_permission(db, tenant, user, 'project:assignment:edit') + rows = linked_records(db, tenant, user, kind, row_id) + for k, row in rows: + validate_assignment(db, tenant, user, project_id, k, required=True) + return {'records': [record_dict(db, tenant, k, r) for k, r in rows]} + + +def assign(db, tenant, user, kind, row_id, project_id, reason, expected): + check_permission(db, tenant, user, 'project:assignment:edit') + if not reason or not reason.strip(): + raise HTTPException(400, '请填写调整原因') + rows = linked_records(db, tenant, user, kind, row_id, lock=True) + actual = {(k, r.id, r.project_id) for k, r in rows} + refreshed = linked_records(db, tenant, user, kind, row_id, current_read=True) + snapshot = {(r['bizType'], r['id'], r.get('projectId')) for r in expected} + if actual != snapshot or actual != {(k, r.id, r.project_id) for k, r in refreshed} or len(expected) != len(actual): + raise HTTPException(409, '关联记录或归属已变更,请重新预览') + for k, row in rows: + validate_assignment(db, tenant, user, project_id, k, required=True) + new_name = project_brief(db, tenant, project_id)['projectName'] + operator = scoped(db, SysUser, tenant).filter(SysUser.id == user.get('user_id')).first() + for k, row in rows: + db.add(ProjectAssignmentAudit(id=generate_uuid(), tenant_no=tenant, biz_type=k, biz_id=row.id, old_project_id=row.project_id, new_project_id=project_id, old_project_name=project_brief(db, tenant, row.project_id)['projectName'], new_project_name=new_name, reason=reason.strip(), operator_name=operator.nick_name if operator else user.get('user_id'), create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or '')) + row.project_id = project_id + row.update_by = user.get('user_id') + row.update_time = datetime.now() + db.commit() + return {'updated': len(rows)} + + +def assignment_history(db, tenant, user, kind, row_id): + model = BUSINESS.get(kind, (None,))[0] + if model is None or not business_query(db, tenant, user, kind).filter(model.id == row_id).first(): + raise HTTPException(404, '记录不存在或无权访问') + rows = scoped(db, ProjectAssignmentAudit, tenant).filter(ProjectAssignmentAudit.biz_type == kind, ProjectAssignmentAudit.biz_id == row_id).order_by(ProjectAssignmentAudit.create_time.desc()).all() + return {'list': [dict(id=r.id, oldProjectName=r.old_project_name, newProjectName=r.new_project_name, reason=r.reason, operatorName=r.operator_name, createTime=r.create_time) for r in rows]} + + +CHILDREN = ( + ('members', ProjectTeamMember, 'userId', 'user_id', SysUser, 'nick_name'), + ('regions', ProjectTeamRegion, 'regionId', 'region_id', BizCollectionRegion, 'region_name'), + ('hospitals', ProjectTeamHospital, 'hospitalId', 'hospital_id', BizCollectionHospital, 'hospital_name'), +) + + +def serialize_entity(db, tenant, row): + leader = scoped(db, SysUser, tenant).filter(SysUser.id == row.leader_user_id).first() + result = dict(id=row.id, leaderUserId=row.leader_user_id, leaderName=leader.nick_name if leader else '', remark=row.remark, createTime=row.create_time) + if isinstance(row, ProjectTeam): + result.update(teamName=row.team_name, enabled=bool(row.enabled)) + for key, model, public, attr, ref_model, name in CHILDREN: + children = scoped(db, model, tenant).filter(model.team_id == row.id).order_by(model.order_num).all() + result[key] = [] + for child in children: + ref = scoped(db, ref_model, tenant).filter(ref_model.id == getattr(child, attr)).first() + result[key].append(dict(id=child.id, **{public: getattr(child, attr)}, name=getattr(ref, name) if ref else '')) + else: + result.update(projectNo=row.project_no, projectName=row.project_name, teamId=row.team_id, teamName=project_brief(db, tenant, row.id)['teamName'], bizTypes=row.biz_types.split(','), startDate=row.start_date, endDate=row.end_date, status=row.status) + return result + + +def list_entities(db, tenant, model, page=1, pageSize=20, **filters): + q = scoped(db, model, tenant) + name = model.team_name if model is ProjectTeam else model.project_name + if filters.get('keyword'): + q = q.filter(name.contains(filters['keyword'])) + for public, attr in [('leaderUserId', 'leader_user_id'), ('teamId', 'team_id'), ('status', 'status'), ('enabled', 'enabled')]: + if filters.get(public) not in (None, '') and hasattr(model, attr): + q = q.filter(getattr(model, attr) == filters[public]) + return dict(total=q.count(), list=[serialize_entity(db, tenant, r) for r in q.order_by(model.create_time.desc(), model.id).offset((page-1)*pageSize).limit(pageSize).all()]) + + +def references(db, tenant): + return dict(users=[dict(id=r.id, name=r.nick_name) for r in scoped(db, SysUser, tenant).filter(SysUser.status == '1').all()], regions=[dict(id=r.id, name=r.region_name) for r in scoped(db, BizCollectionRegion, tenant).all()], hospitals=[dict(id=r.id, name=r.hospital_name, regionId=r.region_id) for r in scoped(db, BizCollectionHospital, tenant).all()]) + + +def has_business(db, tenant, project_id): + # Deleted business is still a historical reference and prevents structural changes. + return any(db.query(model.id).filter(model.tenant_no == tenant, model.project_id == project_id).with_for_update().first() for model in {v[0] for v in BUSINESS.values()}) + + +def save_entity(db, tenant, user, model, body, row_id=None): + row = get_row(db, model, tenant, row_id, lock=True) if row_id else model(id=generate_uuid(), tenant_no=tenant, create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or '') + name_key = 'teamName' if model is ProjectTeam else 'projectName' + if not str(body.get(name_key) or '').strip(): + raise HTTPException(400, '名称不能为空') + leader = get_row(db, SysUser, tenant, body.get('leaderUserId')) + if leader.status != '1': + raise HTTPException(400, '负责人已停用') + row.leader_user_id = leader.id + row.remark = body.get('remark') + row.update_by = user.get('user_id') + row.update_time = datetime.now() + if model is ProjectTeam: + row.team_name = body[name_key].strip() + row.enabled = int(bool(body.get('enabled', True))) + db.add(row) + for key, child_model, public, attr, ref_model, _ in CHILDREN: + children = body.get(key, []) + seen = set() + for child in children: + ref_id = child.get(public) + if ref_id in seen: + raise HTTPException(400, '关联资料不能重复') + seen.add(ref_id) + get_row(db, ref_model, tenant, ref_id) + if child.get('id'): + existing = db.query(child_model).filter(child_model.id == child['id']).first() + if existing and (existing.tenant_no != tenant or existing.team_id != row.id or existing.del_flag): + raise HTTPException(400, '子行不属于当前项目组') + try: + sync_child_rows(db, child_model, parent_attr='team_id', parent_id=row.id, tenant_no=tenant, user_id=user.get('user_id'), items=children, + build_row=lambda rid, cm=child_model: cm(id=rid, tenant_no=tenant, team_id=row.id, create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or ''), + apply_fields=lambda child, item, index, now, a=attr, p=public: (setattr(child, a, item[p]), setattr(child, 'order_num', index)), + natural_key=lambda item, p=public: item[p], row_natural_key=lambda child, a=attr: getattr(child, a)) + except ValueError as exc: + raise HTTPException(400, str(exc)) + else: + team = get_row(db, ProjectTeam, tenant, body.get('teamId'), lock=True) + if row_id and row.team_id != team.id and has_business(db, tenant, row.id): + raise HTTPException(400, '项目已有业务,不可更换项目组') + kinds = body.get('bizTypes') or [] + if not kinds or any(k not in COUNT_TYPES for k in kinds): + raise HTTPException(400, '请选择有效业务类型') + status = body.get('status', 'pending') + if status not in ('pending', 'active', 'completed', 'disabled'): + raise HTTPException(400, '项目状态无效') + row.project_name, row.team_id, row.biz_types, row.status = body[name_key].strip(), team.id, ','.join(dict.fromkeys(kinds)), status + if not row_id: + row.project_no = 'XM' + datetime.now().strftime('%Y%m%d') + generate_uuid().replace('-', '')[:16].upper() + try: + row.start_date = date.fromisoformat(body['startDate']) if body.get('startDate') else None + row.end_date = date.fromisoformat(body['endDate']) if body.get('endDate') else None + except ValueError: + raise HTTPException(400, '日期格式应为 YYYY-MM-DD') + if row.start_date and row.end_date and row.start_date > row.end_date: + raise HTTPException(400, '结束日期不能早于开始日期') + db.add(row) + db.commit() + return serialize_entity(db, tenant, row) + + +def delete_entity(db, tenant, user, model, row_id): + row = get_row(db, model, tenant, row_id, lock=True) + if model is Project and has_business(db, tenant, row.id): + raise HTTPException(400, '项目已被业务引用,请停用') + if model is ProjectTeam and db.query(Project.id).filter(Project.tenant_no == tenant, Project.team_id == row.id).with_for_update().first(): + raise HTTPException(400, '项目组已被项目引用,请停用') + row.del_flag, row.update_by = 1, user.get('user_id') + if model is ProjectTeam: + for _, child_model, *_ in CHILDREN: + for child in scoped(db, child_model, tenant).filter(child_model.team_id == row.id).all(): + child.del_flag, child.update_by = 1, user.get('user_id') + db.commit() + return {'id': row.id} + + +def options(db, tenant, user, biz_type, keyword=None): + if biz_type not in BUSINESS: + raise HTTPException(400, '业务类型不支持') + effective_type = 'cell_storage' if biz_type == 'presign' else biz_type + q = scoped(db, Project, tenant).join(ProjectTeam, and_( + ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant, + ProjectTeam.del_flag == 0, ProjectTeam.enabled == 1)).filter(Project.status == 'active') + if not has_permission(db, tenant, user, 'project:assignment:edit'): + memberships = scoped(db, ProjectTeamMember, tenant).filter( + ProjectTeamMember.user_id == user.get('user_id')).with_entities(ProjectTeamMember.team_id) + q = q.filter(or_(Project.leader_user_id == user.get('user_id'), + ProjectTeam.leader_user_id == user.get('user_id'), + Project.team_id.in_(memberships))) + if keyword: + q = q.filter(or_(Project.project_name.contains(keyword), Project.project_no.contains(keyword))) + rows = q.with_entities(Project.id, Project.project_name, Project.project_no, + Project.team_id, Project.biz_types, ProjectTeam.team_name).order_by( + Project.create_time.desc(), Project.id).all() + result = [dict(id=r.id, projectName=r.project_name, projectNo=r.project_no, + teamId=r.team_id, teamName=r.team_name, disabled=False) + for r in rows if effective_type in r.biz_types.split(',')] + from app.core.config import settings + return {'list': result, 'required': settings.PROJECT_ASSIGNMENT_REQUIRED} + + +def filter_options(db, tenant, user, biz_type, keyword=None, *, business_scope=None): + """Read filters reflect accessible business history, independent of membership/status.""" + if biz_type not in BUSINESS: + raise HTTPException(400, '业务类型不支持') + model = BUSINESS[biz_type][0] + visible = business_scope if business_scope is not None else business_query(db, tenant, user, biz_type) + ids = visible.with_entities(model.project_id).filter(model.project_id.isnot(None)).distinct() + q = scoped(db, Project, tenant).outerjoin(ProjectTeam, and_( + ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant, + ProjectTeam.del_flag == 0)).filter(Project.id.in_(ids)) + if keyword: + q = q.filter(or_(Project.project_name.contains(keyword), Project.project_no.contains(keyword))) + rows = q.with_entities(Project.id, Project.project_name, Project.project_no, + Project.team_id, ProjectTeam.team_name).order_by(Project.create_time.desc(), Project.id).all() + return {'list': [dict(id=r.id, projectName=r.project_name, projectNo=r.project_no, + teamId=r.team_id, teamName=r.team_name, disabled=False) for r in rows], + 'required': False} diff --git a/backend/tests/test_market_project_assignment.py b/backend/tests/test_market_project_assignment.py new file mode 100644 index 00000000..de1673c4 --- /dev/null +++ b/backend/tests/test_market_project_assignment.py @@ -0,0 +1,172 @@ +"""Mobile attribution writes and scoped reads against isolated SQLite records.""" +import pytest +from fastapi import HTTPException +from app.core.config import settings +from app.models.project_management import Project, ProjectTeam +from app.models.models import BizPresignOrder, BizCollectionProtocol +from app.models.research_donation import BizResearchDonation +from app.schemas.market_mobile import MarketPresignOrderBody, MarketProtocolBody +from app.services import market_presign, market_protocol +from test_market_protocol_api import _session, _masters, _ctx, _user, _body, _cell, _donation + + +@pytest.fixture +def db(): + session = _session() + _masters(session) + session.add_all([ + ProjectTeam(id='team', tenant_no='tenant-a', team_name='团队', leader_user_id='u1'), + Project(id='project', tenant_no='tenant-a', project_no='P', project_name='项目', team_id='team', leader_user_id='u1', status='active', biz_types='presign,cell_storage,research_donation'), + ]) + session.commit() + yield session + session.close() + + +@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation']) +def test_mobile_create_persists_and_returns_project(db, kind): + if kind == 'presign': + result = market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info=_user()) + basic = result + else: + result = market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user()) + basic = result['basic'] + model = {'presign': BizPresignOrder, 'cell_storage': BizCollectionProtocol, 'research_donation': BizResearchDonation}[kind] + db.expire_all() + assert db.get(model, basic['id']).project_id == 'project' + assert basic['projectId'] == 'project' + assert basic['projectName'] == '项目' + assert basic['teamId'] == 'team' + assert basic['teamName'] == '团队' + + +@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation']) +def test_mobile_creation_obeys_required_configuration(db, monkeypatch, kind): + monkeypatch.setattr(settings, 'PROJECT_ASSIGNMENT_REQUIRED', True) + with pytest.raises(HTTPException): + if kind == 'presign': + market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲'), ctx=_ctx('specialist'), user_info=_user()) + else: + market_protocol.create_protocol(db, body=_body(kind, samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user()) + + +@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation']) +def test_scoped_lists_and_immutable_edits(db, kind): + if kind == 'presign': + own = BizPresignOrder(id='own', tenant_no='tenant-a', sales_user_id='u1', order_status='active', project_id='project') + hidden = BizPresignOrder(id='hidden', tenant_no='tenant-a', sales_user_id='u2', order_status='active', project_id='project') + db.add_all([own, hidden]) + else: + factory = _cell if kind == 'cell_storage' else _donation + own = factory(db, 'own') + hidden = factory(db, 'hidden', sales='u2') + own.project_id = hidden.project_id = 'project' + db.commit() + service = market_presign if kind == 'presign' else market_protocol + listing = service.list_presign_orders if kind == 'presign' else service.list_protocols + params = {} if kind == 'presign' else {'source': kind} + rows, total = listing(db, ctx=_ctx('specialist'), user_info=_user(), project_id='project', team_id='team', **params) + assert total == 1 + assert rows[0]['id'] == 'own' + assert rows[0]['projectName'] == '项目' + assert listing(db, ctx=_ctx('specialist'), user_info=_user(), project_id='missing', **params)[1] == 0 + db.get(Project, 'project').status = 'disabled' + db.commit() + kwargs = {'order_id': 'own'} if kind == 'presign' else {'protocol_id': 'own', 'source': kind} + update = service.update_presign_order if kind == 'presign' else service.update_protocol + body = MarketPresignOrderBody(remark='保留历史') if kind == 'presign' else MarketProtocolBody(source=kind, remark='保留历史') + update(db, ctx=_ctx('specialist'), user_info=_user(), body=body, **kwargs) + db.refresh(own) + assert own.project_id == 'project' + body = MarketPresignOrderBody(projectId=None) if kind == 'presign' else MarketProtocolBody(source=kind, projectId=None) + with pytest.raises(HTTPException, match='项目归属维护'): + update(db, ctx=_ctx('specialist'), user_info=_user(), body=body, **kwargs) + + +@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation']) +def test_mobile_rejects_foreign_project_and_legacy_backfill(db, kind): + db.get(Project, 'project').tenant_no = 'tenant-other' + db.commit() + with pytest.raises(HTTPException): + if kind == 'presign': + market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info=_user()) + else: + market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user()) + if kind == 'presign': + row = BizPresignOrder(id='legacy', tenant_no='tenant-a', sales_user_id='u1', order_status='active') + db.add(row) + else: + row = (_cell if kind == 'cell_storage' else _donation)(db, 'legacy') + db.commit() + with pytest.raises(HTTPException, match='项目归属维护'): + if kind == 'presign': + market_presign.update_presign_order(db, order_id='legacy', body=MarketPresignOrderBody(projectId='project'), ctx=_ctx('specialist'), user_info=_user()) + else: + market_protocol.update_protocol(db, source=kind, protocol_id='legacy', body=MarketProtocolBody(source=kind, projectId='project'), ctx=_ctx('specialist'), user_info=_user()) + db.refresh(row) + assert row.project_id is None + + +@pytest.mark.parametrize('role', ['specialist', 'manager']) +@pytest.mark.parametrize('resource', ['protocols', 'presign-orders']) +def test_mobile_http_list_binds_project_and_team_aliases(db, role, resource): + import asyncio + import httpx + from fastapi import FastAPI + from app.api.market_mobile import router + from app.core.database import get_db + + row = _cell(db, 'visible') + row.project_id = 'project' + hidden = _cell(db, 'hidden', sales='u2', manager='manager-other') + hidden.project_id = 'project' + db.add_all([ + BizPresignOrder(id='presign-own', tenant_no='tenant-a', sales_user_id='u1', region_manager_user_id='manager-a', order_status='active', project_id='project'), + BizPresignOrder(id='presign-hidden', tenant_no='tenant-a', sales_user_id='u2', region_manager_user_id='manager-other', order_status='active', project_id='project'), + ]) + db.commit() + app = FastAPI() + app.include_router(router) + + async def database(): + yield db + + async def user(): + return _user('manager-a' if role == 'manager' else 'u1') + + app.dependency_overrides[get_db] = database + for route in router.routes: + for dependency in route.dependant.dependencies: + if dependency.name == 'user': + app.dependency_overrides[dependency.call] = user + + async def check(): + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client: + url = f'/mini/market/{role}/{resource}' + response = await client.get(url, params={'source': 'cell_storage', 'projectId': 'project', 'teamId': 'team'}) + assert response.status_code == 200 + data = response.json()['data'] + assert data['total'] == 1 + assert data['list'][0]['projectName'] == '项目' + response = await client.get(url, params={'source': 'cell_storage', 'projectId': 'missing'}) + assert response.json()['data']['total'] == 0 + response = await client.get(url, params={'source': 'cell_storage', 'teamId': 'missing'}) + assert response.json()['data']['total'] == 0 + asyncio.run(check()) + + +@pytest.mark.parametrize('role', ['specialist', 'manager']) +def test_mobile_history_filter_options_keep_field_scope(db, role): + from app.services.project_management import filter_options + from app.services.market_field_scope import apply_presign_scope + db.get(Project, 'project').status = 'disabled' + db.add(Project(id='hidden-project', tenant_no='tenant-a', project_no='H', project_name='隐藏', team_id='team', leader_user_id='u2', status='active', biz_types='cell_storage')) + db.add_all([ + BizPresignOrder(id='own', tenant_no='tenant-a', sales_user_id='u1', region_manager_user_id='manager-a', project_id='project'), + BizPresignOrder(id='hidden', tenant_no='tenant-a', sales_user_id='u2', region_manager_user_id='manager-other', project_id='hidden-project'), + ]) + db.commit() + user = _user('manager-a' if role == 'manager' else 'u1') + query = apply_presign_scope(db.query(BizPresignOrder), db, _ctx(role, user['user_id']), user_info=user) + result = filter_options(db, 'tenant-a', user, 'presign', business_scope=query) + assert [row['id'] for row in result['list']] == ['project'] diff --git a/backend/tests/test_project_assignment_integration.py b/backend/tests/test_project_assignment_integration.py new file mode 100644 index 00000000..0f176d45 --- /dev/null +++ b/backend/tests/test_project_assignment_integration.py @@ -0,0 +1,148 @@ +"""Existing order write paths retain project attribution without changing business fields.""" +import unittest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from app.models.base import Base +from app.models.models import BizCollectionProtocol, BizPresignOrder +from app.crud import collection_protocol, presign_order + + +class ProjectAssignmentIntegrationTest(unittest.TestCase): + def setUp(self): + self.engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(self.engine) + self.db = Session(self.engine) + + def tearDown(self): + self.db.close() + self.engine.dispose() + + def test_protocol_creation_persists_assignment(self): + row = collection_protocol.create_collection_protocol(self.db, protocol_no='CP1', tenant_no='t', project_id='p') + self.assertEqual(self.db.get(BizCollectionProtocol, row.id).project_id, 'p') + + def test_presign_creation_persists_assignment_and_region(self): + row = presign_order.create_presign_order(self.db, tenant_no='t', user_id='u', fields={'protocol_no':'PS1', 'project_id':'p', 'region_id':'region'}) + self.assertEqual(self.db.get(BizPresignOrder, row.id).project_id, 'p') + self.assertEqual(row.region_id, 'region') + + def test_edit_rejects_assigning_legacy_order_and_preserves_omitted(self): + from app.services.project_assignment import guard_project_edit + from fastapi import HTTPException + old = BizPresignOrder(project_id=None) + guard_project_edit(old, {}) + with self.assertRaises(HTTPException): + guard_project_edit(old, {'projectId':'p'}) + old.project_id = 'p' + guard_project_edit(old, {'project_id':'p'}) + with self.assertRaises(HTTPException): + guard_project_edit(old, {'project_id':None}) + + def test_conversions_inherit_existing_assignment(self): + from app.crud import research_donation + from app.models.research_donation import BizResearchDonation + row = collection_protocol.create_collection_protocol(self.db, protocol_no='CONVERT1', tenant_no='t', project_id='p', protocol_status='sxz') + donation = research_donation.convert_from_storage(self.db, row.id, tenant_no='t') + self.assertEqual(donation.project_id, 'p') + # A direct donation may convert into a new storage protocol. + direct = BizResearchDonation(id='direct', tenant_no='t', donation_no='DIRECT1', donation_status='sxz', project_id='p') + self.db.add(direct) + self.db.commit() + research_donation.convert_to_storage(self.db, direct.id, tenant_no='t') + converted = self.db.query(BizCollectionProtocol).filter_by(protocol_no='DIRECT1').one() + self.assertEqual(converted.project_id, 'p') + + def test_basic_experiment_creation_and_read_preserve_project_title(self): + from app.crud import pilot_application + data = dict(bizType='basic_experiment', projectId='p', projectTitle='课题', customerName='客户', customerContact='联系人', customerPhone='13800000000') + result = pilot_application.create_application(self.db, data=data, tenant_no='t', user_id='u', dept_id='') + self.assertEqual(result['projectId'], 'p') + self.assertEqual(result['projectTitle'], '课题') + + def test_web_schema_keeps_explicit_null_for_edit_guard(self): + from app.api.presign_order import PresignOrderBody + from app.api.collection_protocol import ProtocolUpdateBody + from app.api.research_donation import DonationUpdateBody + from app.api.pilot_application import ApplicationSaveBody + for cls in (PresignOrderBody, ProtocolUpdateBody, DonationUpdateBody, ApplicationSaveBody): + self.assertNotIn('project_id', cls().model_dump(exclude_unset=True)) + self.assertIn('project_id', cls(projectId=None).model_dump(exclude_unset=True)) + + def test_statistics_aggregates_before_loading_project_metadata(self): + from sqlalchemy import event + from app.models.project_management import Project, ProjectTeam + from app.services.project_management import statistics + self.db.add(ProjectTeam(id='g', tenant_no='t', team_name='组', leader_user_id='u')) + self.db.add(Project(id='p', tenant_no='t', project_no='P', project_name='项目', team_id='g', leader_user_id='u', biz_types='cell_storage', status='active')) + self.db.add_all([BizCollectionProtocol(id=str(i), tenant_no='t', protocol_no=str(i), project_id='p') for i in range(30)]) + self.db.commit() + selects = [] + def count(conn, cursor, statement, params, context, many): + if statement.lstrip().upper().startswith('SELECT'): + selects.append(statement) + event.listen(self.engine, 'before_cursor_execute', count) + result = statistics(self.db, 't', {'user_id':'u', 'role':'super_admin'}) + self.assertEqual(result['totals']['cell_storage'], 30) + self.assertLessEqual(len(selects), 10) + + def test_ordinary_edit_cannot_rewire_conversion_source(self): + from app.crud import research_donation + from app.models.research_donation import BizResearchDonation + row = collection_protocol.create_collection_protocol(self.db, protocol_no='LINK1', tenant_no='t', source_presign_order_id='original') + with self.assertRaises(ValueError): + collection_protocol.update_collection_protocol(self.db, row.id, tenant_no='t', source_presign_order_id='other') + self.db.rollback() + donation = BizResearchDonation(id='rd', tenant_no='t', donation_no='LINK2', source_type='from_storage', source_protocol_id='original') + self.db.add(donation) + self.db.commit() + with self.assertRaises(ValueError): + research_donation.update_donation(self.db, donation.id, tenant_no='t', data={'sourceProtocolId':'other'}) + self.db.rollback() + with self.assertRaises(ValueError): + research_donation.update_donation(self.db, donation.id, tenant_no='t', data={'sourceType':'direct'}) + + def test_management_initial_empty_filters_do_not_hide_existing_rows(self): + from app.models.project_management import ProjectTeam + from app.services.project_management import list_entities + self.db.add(ProjectTeam(id='g', tenant_no='t', team_name='组', leader_user_id='u')) + self.db.commit() + self.assertEqual(list_entities(self.db, 't', ProjectTeam, keyword='', leaderUserId='', enabled=None)['total'], 1) + self.assertEqual(list_entities(self.db, 't', ProjectTeam, enabled=False)['total'], 0) + + def test_history_filters_follow_business_access_not_membership(self): + from app.models.project_management import Project, ProjectTeam + from app.models.models import SysRole, SysUserRole + from app.services.project_management import filter_options, options + self.db.add_all([ + ProjectTeam(id='g', tenant_no='t', team_name='停用组', leader_user_id='other', enabled=0), + Project(id='p', tenant_no='t', project_no='P', project_name='历史项目', team_id='g', leader_user_id='other', biz_types='cell_storage', status='disabled'), + Project(id='hidden', tenant_no='t', project_no='H', project_name='不可见项目', team_id='g', leader_user_id='other', biz_types='cell_storage', status='active'), + SysRole(id='role', role_name='member', tenant_no='t', data_scope='4'), + SysUserRole(id='ur', user_id='u', role_id='role', tenant_no='t'), + BizCollectionProtocol(id='mine', protocol_no='M', tenant_no='t', create_by='u', project_id='p'), + BizCollectionProtocol(id='other', protocol_no='O', tenant_no='t', create_by='other', project_id='hidden'), + ]) + self.db.commit() + user = {'user_id':'u', 'role':'admin'} + self.assertEqual(options(self.db, 't', user, 'cell_storage')['list'], []) + self.assertEqual([r['id'] for r in filter_options(self.db, 't', user, 'cell_storage')['list']], ['p']) + + def test_generic_history_options_require_existing_web_role_guard(self): + from app.api.project_management import options + from fastapi import HTTPException + with self.assertRaises(HTTPException) as caught: + options(bizType='cell_storage', usage='filter', keyword=None, ctx=('t', {'user_id':'u', 'role':'user'}), db=self.db) + self.assertEqual(caught.exception.status_code, 403) + + def test_presign_conversion_inherits_assignment_after_project_stops(self): + from app.models.project_management import Project, ProjectTeam + self.db.add_all([ + ProjectTeam(id='g', tenant_no='t', team_name='组', leader_user_id='other', enabled=0), + Project(id='p', tenant_no='t', project_no='P', project_name='历史项目', team_id='g', leader_user_id='other', biz_types='cell_storage', status='disabled'), + BizPresignOrder(id='ps', tenant_no='t', protocol_no='PSCONVERT', project_id='p', order_status='active'), + ]) + self.db.commit() + result = presign_order.convert_to_protocol(self.db, order_id='ps', tenant_no='t', user_id=None) + protocol = self.db.get(BizCollectionProtocol, result['protocolId']) + self.assertEqual(protocol.project_id, 'p') + self.assertEqual(protocol.source_presign_order_id, 'ps') diff --git a/backend/tests/test_project_management.py b/backend/tests/test_project_management.py new file mode 100644 index 00000000..007ff91d --- /dev/null +++ b/backend/tests/test_project_management.py @@ -0,0 +1,189 @@ +"""Project attribution isolation and history contract, using an in-memory database.""" +import importlib.util +import unittest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from fastapi import HTTPException + + +class ProjectManagementTest(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(importlib.util.find_spec('app.services.project_management'), 'project service missing') + from app.services import project_management as svc + from app.models.project_management import Project, ProjectTeam, ProjectTeamMember + from app.models.base import Base + self.svc, self.Project, self.Team = svc, Project, ProjectTeam + self.engine = create_engine('sqlite:///:memory:') + Base.metadata.create_all(self.engine) + self.db = Session(self.engine) + self.user = {'user_id': 'u', 'tenant_no': 't', 'role': 'super_admin'} + self.db.add_all([ProjectTeam(id='g', tenant_no='t', team_name='Team', leader_user_id='u'), + Project(id='p', tenant_no='t', project_no='P1', project_name='Project', team_id='g', leader_user_id='u', biz_types='cell_storage,presign', status='active')]) + self.db.commit() + + def tearDown(self): + if hasattr(self, 'db'): + self.db.close() + self.engine.dispose() + + def test_assignment_and_tenant_boundary(self): + self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage'), 'p') + with self.assertRaises(HTTPException): + self.svc.validate_assignment(self.db, 'other', self.user, 'p', 'cell_storage') + with self.assertRaises(HTTPException): + self.svc.validate_assignment(self.db, 't', self.user, 'p', 'basic_experiment') + + def test_legacy_edit_keeps_disabled_project(self): + self.db.get(self.Project, 'p').status = 'disabled' + self.db.commit() + self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage', current_project_id='p'), 'p') + with self.assertRaises(HTTPException): + self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', current_project_id='p') + with self.assertRaises(HTTPException): + self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', required=True) + + def test_statistics_excludes_foreign_and_deleted(self): + from app.models.models import BizCollectionProtocol + self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p'), + BizCollectionProtocol(id='b', protocol_no='B', tenant_no='other', project_id='p'), + BizCollectionProtocol(id='c', protocol_no='C', tenant_no='t', project_id='p', del_flag=1), + BizCollectionProtocol(id='d', protocol_no='D', tenant_no='t')]) + self.db.commit() + result = self.svc.statistics(self.db, 't', self.user) + self.assertEqual(result['totals']['cell_storage'], 2) + self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage')['total'], 2) + self.assertEqual(result['unassignedTotals']['cell_storage'], 1) + self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage', projectId='p')['total'], 1) + + def test_assignment_preview_and_atomic_snapshot(self): + from app.models.models import BizCollectionProtocol, BizPresignOrder + self.db.add_all([BizPresignOrder(id='s', tenant_no='t', project_id=None, converted_protocol_id='a'), + BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')]) + self.db.commit() + result = self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p') + self.assertEqual(len(result['records']), 2) + with self.assertRaises(HTTPException): + self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', []) + self.db.rollback() + self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id) + self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', result['records']) + self.assertEqual(self.db.get(BizPresignOrder, 's').project_id, 'p') + self.assertEqual(len(self.svc.assignment_history(self.db, 't', self.user, 'cell_storage', 'a')['list']), 1) + + def test_team_child_ids_survive_edits(self): + from app.models.models import SysUser + self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User')) + self.db.commit() + body = dict(teamName='Team', leaderUserId='u', members=[{'userId': 'u'}]) + first = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g') + second = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g') + self.assertEqual(first['members'][0]['id'], second['members'][0]['id']) + with self.assertRaises(HTTPException): + self.svc.delete_entity(self.db, 't', self.user, self.Team, 'g') + + def test_non_member_admin_cannot_choose_project(self): + # An ordinary admin with no enabled-role permissions is not a substitute recorder. + with self.assertRaises(HTTPException) as error: + self.svc.validate_assignment(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'p', 'cell_storage') + self.assertEqual(error.exception.status_code, 403) + + def test_non_member_data_scope_does_not_expand(self): + from app.models.models import BizCollectionProtocol + self.db.add(BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p')) + self.db.commit() + user = {'user_id': 'u', 'role': 'admin'} + self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 0) + self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 0) + + def test_empty_date_input_is_optional(self): + from app.models.models import SysUser + self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User')) + self.db.commit() + result = self.svc.save_entity(self.db, 't', self.user, self.Project, + dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate='')) + self.assertTrue(result['projectNo'].startswith('XM')) + self.assertIsNone(result['startDate']) + + def test_conversion_table_reverse_graph_and_audit(self): + from app.models.models import BizCollectionProtocol + from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert + self.db.get(self.Project, 'p').biz_types = 'cell_storage,research_donation' + self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t'), + BizResearchDonation(id='d', donation_no='D', tenant_no='t'), + BizProtocolDonationConvert(id='c', protocol_id='a', research_donation_id='d', biz_no='A', convert_type='research_to_storage', tenant_no='t')]) + self.db.commit() + preview = self.svc.preview_assignment(self.db, 't', self.user, 'research_donation', 'd', 'p') + self.assertEqual({r['bizType'] for r in preview['records']}, {'cell_storage', 'research_donation'}) + self.svc.assign(self.db, 't', self.user, 'research_donation', 'd', 'p', 'reason', preview['records']) + self.assertEqual(self.db.get(BizCollectionProtocol, 'a').project_id, 'p') + self.assertEqual(self.db.get(BizResearchDonation, 'd').project_id, 'p') + + def test_preview_rejects_inaccessible_link_without_writes(self): + from app.models.models import BizCollectionProtocol, BizPresignOrder + self.db.add_all([BizPresignOrder(id='s', tenant_no='other', converted_protocol_id='a'), + BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')]) + self.db.commit() + with self.assertRaises(HTTPException): + self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p') + self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id) + + def test_date_end_inclusive_and_business_types_separate(self): + from datetime import datetime + from app.models.models import BizPilotApplication + self.db.add_all([BizPilotApplication(id='a', tenant_no='t', project_id='p', biz_type='basic_experiment', channel_service_order_id='', create_time=datetime(2026, 1, 2, 23, 59)), + BizPilotApplication(id='b', tenant_no='t', project_id='p', biz_type='pilot_service', channel_service_order_id='', create_time=datetime(2026, 1, 3)), + BizPilotApplication(id='c', tenant_no='t', project_id='p', biz_type='clinical_application', channel_service_order_id='', create_time=datetime(2026, 1, 2))]) + self.db.commit() + result = self.svc.statistics(self.db, 't', self.user, startDate='2026-01-02', endDate='2026-01-02') + self.assertEqual(result['totals']['basic_experiment'], 1) + self.assertEqual(result['totals']['pilot_service'], 0) + self.assertEqual(sum(result['totals'].values()), 1) + + def test_routes_exist_and_protect_management(self): + from app.api.project_management import router + paths = {r.path for r in router.routes} + self.assertIn('/project-management/options', paths) + self.assertIn('/web/admin/project-management/assignments/preview', paths) + with self.assertRaises(HTTPException) as error: + self.svc.check_permission(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'project:team:list') + self.assertEqual(error.exception.status_code, 403) + + def test_enabled_role_union_authorizes_substitute_and_keeps_data_scope(self): + from app.models.models import SysRole, SysUserRole, SysRoleMenu, SysMenu, BizCollectionProtocol + self.db.add_all([SysRole(id='role1', role_name='reader', tenant_no='t', data_scope='4'), + SysRole(id='role2', role_name='assignment', tenant_no='t', data_scope='4'), + SysUserRole(id='ur1', user_id='outsider', role_id='role1', tenant_no='t'), + SysUserRole(id='ur2', user_id='outsider', role_id='role2', tenant_no='t'), + SysMenu(id='menu1', tenant_no='t', menu_name='归属', perms='project:assignment:edit'), + SysRoleMenu(id='rm1', tenant_no='t', role_id='role2', menu_id='menu1'), + BizCollectionProtocol(id='mine', protocol_no='Mine', tenant_no='t', create_by='outsider', project_id='p'), + BizCollectionProtocol(id='foreign', protocol_no='Other', tenant_no='t', create_by='u', project_id='p')]) + self.db.commit() + user = dict(user_id='outsider', role='admin') + self.assertEqual(self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage'), 'p') + self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 1) + self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 1) + self.db.get(SysRole, 'role2').status = '0' + self.db.commit() + with self.assertRaises(HTTPException): + self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage') + + def test_mysql_migration_has_repeat_guards_and_menu_parent_structure(self): + from pathlib import Path + root = Path(__file__).resolve().parents[2] + migration = (root / 'docs/sql/migrations/2026-11-02-project_management.sql').read_text() + self.assertEqual(migration.count('CREATE TABLE IF NOT EXISTS'), 6) + self.assertEqual(migration.count('information_schema.columns'), 4) + self.assertEqual(migration.count('information_schema.statistics'), 4) + self.assertNotIn(' BEFORE ', migration) + self.assertNotIn('JSON_TABLE', migration) + menus = (root / 'docs/sql/migrations/2026-11-02-project_management_menu_seed.sql').read_text() + self.assertEqual(menus.count('AND NOT EXISTS'), 6) + self.assertEqual(menus.count("parent.perms='menu:market'"), 4) + self.assertIn("parent.perms='project:team:list'", menus) + self.assertIn("parent.perms='project:project:list'", menus) + self.assertNotIn('INSERT INTO sys_role_menu', menus) + + +if __name__ == '__main__': + unittest.main() diff --git a/docs/sql/migrations/2026-11-02-project_management.sql b/docs/sql/migrations/2026-11-02-project_management.sql new file mode 100644 index 00000000..600f604a --- /dev/null +++ b/docs/sql/migrations/2026-11-02-project_management.sql @@ -0,0 +1,183 @@ +-- Issue #13: MySQL 5.7 增量迁移。未在真实数据库执行;先备份并在隔离副本验证。 +-- DDL 隐式提交;该脚本不作为自动应用启动步骤。 + +CREATE TABLE IF NOT EXISTS biz_project_team ( + id VARCHAR(64) NOT NULL, + team_name VARCHAR(200) NOT NULL, + leader_user_id VARCHAR(64) NOT NULL, + enabled SMALLINT NOT NULL DEFAULT 1, + tenant_no VARCHAR(64) NOT NULL, + order_num INT NULL, + remark VARCHAR(512) NULL, + create_by VARCHAR(64) NULL, + create_dept_id VARCHAR(64) NOT NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) NULL, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_tenant_active (tenant_no, del_flag) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS biz_project_team_member ( + id VARCHAR(64) NOT NULL, + team_id VARCHAR(64) NOT NULL, + user_id VARCHAR(64) NOT NULL, + tenant_no VARCHAR(64) NOT NULL, + order_num INT NULL, + remark VARCHAR(512) NULL, + create_by VARCHAR(64) NULL, + create_dept_id VARCHAR(64) NOT NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) NULL, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_tenant_active (tenant_no, del_flag), + KEY ix_team (tenant_no, team_id, del_flag) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS biz_project_team_region ( + id VARCHAR(64) NOT NULL, + team_id VARCHAR(64) NOT NULL, + region_id VARCHAR(64) NOT NULL, + tenant_no VARCHAR(64) NOT NULL, + order_num INT NULL, + remark VARCHAR(512) NULL, + create_by VARCHAR(64) NULL, + create_dept_id VARCHAR(64) NOT NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) NULL, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_tenant_active (tenant_no, del_flag), + KEY ix_team (tenant_no, team_id, del_flag) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS biz_project_team_hospital ( + id VARCHAR(64) NOT NULL, + team_id VARCHAR(64) NOT NULL, + hospital_id VARCHAR(64) NOT NULL, + tenant_no VARCHAR(64) NOT NULL, + order_num INT NULL, + remark VARCHAR(512) NULL, + create_by VARCHAR(64) NULL, + create_dept_id VARCHAR(64) NOT NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) NULL, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_tenant_active (tenant_no, del_flag), + KEY ix_team (tenant_no, team_id, del_flag) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS biz_project ( + id VARCHAR(64) NOT NULL, + project_no VARCHAR(64) NOT NULL, + project_name VARCHAR(200) NOT NULL, + team_id VARCHAR(64) NOT NULL, + leader_user_id VARCHAR(64) NOT NULL, + biz_types VARCHAR(255) NOT NULL, + start_date DATE NULL, + end_date DATE NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + tenant_no VARCHAR(64) NOT NULL, + order_num INT NULL, + remark VARCHAR(512) NULL, + create_by VARCHAR(64) NULL, + create_dept_id VARCHAR(64) NOT NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) NULL, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_tenant_active (tenant_no, del_flag), + KEY ix_team (tenant_no, team_id, del_flag), + UNIQUE KEY uk_project_tenant_no (tenant_no, project_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS biz_project_assignment_audit ( + id VARCHAR(64) NOT NULL, + biz_type VARCHAR(32) NOT NULL, + biz_id VARCHAR(64) NOT NULL, + old_project_id VARCHAR(64) NULL, + new_project_id VARCHAR(64) NULL, + old_project_name VARCHAR(200) NULL, + new_project_name VARCHAR(200) NULL, + reason VARCHAR(512) NOT NULL, + operator_name VARCHAR(100) NULL, + tenant_no VARCHAR(64) NOT NULL, + order_num INT NULL, + remark VARCHAR(512) NULL, + create_by VARCHAR(64) NULL, + create_dept_id VARCHAR(64) NOT NULL DEFAULT '', + create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_by VARCHAR(64) NULL, + update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + del_flag SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + KEY ix_tenant_active (tenant_no, del_flag), + KEY ix_business (tenant_no, biz_type, biz_id, create_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @project_ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_presign_order' AND column_name='project_id')=0, + 'ALTER TABLE biz_presign_order ADD COLUMN project_id VARCHAR(64) NULL', + 'SELECT 1'); +PREPARE project_stmt FROM @project_ddl; +EXECUTE project_stmt; +DEALLOCATE PREPARE project_stmt; +SET @project_ddl = IF( + (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='biz_presign_order' AND index_name='ix_project_scope')=0, + 'ALTER TABLE biz_presign_order ADD INDEX ix_project_scope (tenant_no, project_id, del_flag, create_time)', + 'SELECT 1'); +PREPARE project_stmt FROM @project_ddl; +EXECUTE project_stmt; +DEALLOCATE PREPARE project_stmt; + +SET @project_ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_collection_protocol' AND column_name='project_id')=0, + 'ALTER TABLE biz_collection_protocol ADD COLUMN project_id VARCHAR(64) NULL', + 'SELECT 1'); +PREPARE project_stmt FROM @project_ddl; +EXECUTE project_stmt; +DEALLOCATE PREPARE project_stmt; +SET @project_ddl = IF( + (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='biz_collection_protocol' AND index_name='ix_project_scope')=0, + 'ALTER TABLE biz_collection_protocol ADD INDEX ix_project_scope (tenant_no, project_id, del_flag, create_time)', + 'SELECT 1'); +PREPARE project_stmt FROM @project_ddl; +EXECUTE project_stmt; +DEALLOCATE PREPARE project_stmt; + +SET @project_ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_research_donation' AND column_name='project_id')=0, + 'ALTER TABLE biz_research_donation ADD COLUMN project_id VARCHAR(64) NULL', + 'SELECT 1'); +PREPARE project_stmt FROM @project_ddl; +EXECUTE project_stmt; +DEALLOCATE PREPARE project_stmt; +SET @project_ddl = IF( + (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='biz_research_donation' AND index_name='ix_project_scope')=0, + 'ALTER TABLE biz_research_donation ADD INDEX ix_project_scope (tenant_no, project_id, del_flag, create_time)', + 'SELECT 1'); +PREPARE project_stmt FROM @project_ddl; +EXECUTE project_stmt; +DEALLOCATE PREPARE project_stmt; + +SET @project_ddl = IF( + (SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_pilot_application' AND column_name='project_id')=0, + 'ALTER TABLE biz_pilot_application ADD COLUMN project_id VARCHAR(64) NULL', + 'SELECT 1'); +PREPARE project_stmt FROM @project_ddl; +EXECUTE project_stmt; +DEALLOCATE PREPARE project_stmt; +SET @project_ddl = IF( + (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='biz_pilot_application' AND index_name='ix_project_scope')=0, + 'ALTER TABLE biz_pilot_application ADD INDEX ix_project_scope (tenant_no, project_id, del_flag, create_time)', + 'SELECT 1'); +PREPARE project_stmt FROM @project_ddl; +EXECUTE project_stmt; +DEALLOCATE PREPARE project_stmt; diff --git a/docs/sql/migrations/2026-11-02-project_management_menu_seed.sql b/docs/sql/migrations/2026-11-02-project_management_menu_seed.sql new file mode 100644 index 00000000..aef212a1 --- /dev/null +++ b/docs/sql/migrations/2026-11-02-project_management_menu_seed.sql @@ -0,0 +1,39 @@ +-- Issue #13 菜单种子;仅附加到各租户现有 Web 市场节点,不创建新的虚拟根。 +-- 不给现有角色自动授权。管理员通过现有角色菜单维护分配六个权限及祖先菜单。 +-- 使用租户+权限散列稳定ID,允许重复执行。未执行真实数据库。 + +INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag) +SELECT MD5(CONCAT(parent.tenant_no, ':project:team:list')), '项目组管理', parent.id, 'C', 'project-management/teams', '/project-management/teams/index', 'project:team:list', 1, '1', '1', parent.tenant_no, 10, '', NOW(), NOW(), 0 +FROM sys_menu parent +WHERE parent.perms='menu:market' AND parent.del_flag=0 AND parent.status='1' +AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:team:list' AND existing.del_flag=0); + +INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag) +SELECT MD5(CONCAT(parent.tenant_no, ':project:project:list')), '项目管理', parent.id, 'C', 'project-management/projects', '/project-management/projects/index', 'project:project:list', 1, '1', '1', parent.tenant_no, 11, '', NOW(), NOW(), 0 +FROM sys_menu parent +WHERE parent.perms='menu:market' AND parent.del_flag=0 AND parent.status='1' +AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:project:list' AND existing.del_flag=0); + +INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag) +SELECT MD5(CONCAT(parent.tenant_no, ':project:statistics:list')), '业务统计', parent.id, 'C', 'project-management/statistics', '/project-management/statistics/index', 'project:statistics:list', 1, '1', '1', parent.tenant_no, 12, '', NOW(), NOW(), 0 +FROM sys_menu parent +WHERE parent.perms='menu:market' AND parent.del_flag=0 AND parent.status='1' +AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:statistics:list' AND existing.del_flag=0); + +INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag) +SELECT MD5(CONCAT(parent.tenant_no, ':project:assignment:edit')), '项目归属维护', parent.id, 'C', 'project-management/assignments', '/project-management/assignments/index', 'project:assignment:edit', 1, '1', '1', parent.tenant_no, 13, '', NOW(), NOW(), 0 +FROM sys_menu parent +WHERE parent.perms='menu:market' AND parent.del_flag=0 AND parent.status='1' +AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:assignment:edit' AND existing.del_flag=0); + +INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag) +SELECT MD5(CONCAT(parent.tenant_no, ':project:team:edit')), '项目组编辑', parent.id, 'F', '', '', 'project:team:edit', 1, '0', '1', parent.tenant_no, 1, '', NOW(), NOW(), 0 +FROM sys_menu parent +WHERE parent.perms='project:team:list' AND parent.del_flag=0 AND parent.status='1' +AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:team:edit' AND existing.del_flag=0); + +INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag) +SELECT MD5(CONCAT(parent.tenant_no, ':project:project:edit')), '项目编辑', parent.id, 'F', '', '', 'project:project:edit', 1, '0', '1', parent.tenant_no, 1, '', NOW(), NOW(), 0 +FROM sys_menu parent +WHERE parent.perms='project:project:list' AND parent.del_flag=0 AND parent.status='1' +AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:project:edit' AND existing.del_flag=0); diff --git a/mobile-oa-h5/src/api/market.js b/mobile-oa-h5/src/api/market.js index 761939b3..d0623d7b 100644 --- a/mobile-oa-h5/src/api/market.js +++ b/mobile-oa-h5/src/api/market.js @@ -217,3 +217,11 @@ export function marketModuleContract(context) { const normalizedContext = assertContext(context) return getMarketModule(normalizedContext.module) } + +export async function fetchProjectOptions(bizType, { signal, filter = false, context } = {}) { + const { request } = await import('../utils/http.js') + const url = filter + ? `/mini/market/${assertContext(context).marketRole}/project-options` + : '/project-management/options' + return request({ url, method: 'GET', data: { bizType }, signal }) +} diff --git a/mobile-oa-h5/src/market/components/ProjectAssignmentField.vue b/mobile-oa-h5/src/market/components/ProjectAssignmentField.vue new file mode 100644 index 00000000..395bb5b2 --- /dev/null +++ b/mobile-oa-h5/src/market/components/ProjectAssignmentField.vue @@ -0,0 +1,77 @@ + + + diff --git a/mobile-oa-h5/src/market/list-filters.js b/mobile-oa-h5/src/market/list-filters.js index c2d6286a..a8959e95 100644 --- a/mobile-oa-h5/src/market/list-filters.js +++ b/mobile-oa-h5/src/market/list-filters.js @@ -48,6 +48,7 @@ export function createListFilters(module, source = '') { export function buildListParams(module, filters = {}) { const result = {} + if (['presign', 'protocol'].includes(module)) addText(result, 'projectId', filters.projectId) if (module === 'presign') { addText(result, 'orderStatus', filters.orderStatus) } else if (module === 'protocol') { diff --git a/mobile-oa-h5/src/market/list-presenter.js b/mobile-oa-h5/src/market/list-presenter.js index 73cb9b4e..3567c93b 100644 --- a/mobile-oa-h5/src/market/list-presenter.js +++ b/mobile-oa-h5/src/market/list-presenter.js @@ -131,6 +131,8 @@ function protocolCard(item) { status, statusTone: statusTag.tagType, fields: [ + field('所属项目', item.projectName || item.basic?.projectName || ((item.projectId || item.basic?.projectId) ? '历史项目' : '未归属')), + field('项目组', item.teamName || item.basic?.teamName), field('样本编码', item.sampleCode || item.labStorageCode), field('采集日期', collectDate || item.donationDate), field('当月核量号', item.monthlyVerifyNo), @@ -167,6 +169,8 @@ function protocolCard(item) { status, statusTone: statusTag.tagType, fields: [ + field('所属项目', item.projectName || item.basic?.projectName || ((item.projectId || item.basic?.projectId) ? '历史项目' : '未归属')), + field('项目组', item.teamName || item.basic?.teamName), field('样本编码', item.sampleCode), field('登记日期', item.registerDate || item.donationDate), field('母亲电话', item.motherPhone), diff --git a/mobile-oa-h5/src/market/list-presenter.test.js b/mobile-oa-h5/src/market/list-presenter.test.js index f3d48337..91d9d52b 100644 --- a/mobile-oa-h5/src/market/list-presenter.test.js +++ b/mobile-oa-h5/src/market/list-presenter.test.js @@ -41,8 +41,8 @@ test('protocol card exposes web parity fields without truncating to three rows', }) assert.equal(card.title, '李安然') assert.equal(card.subtitle, '细胞存储 · S-001') - assert.equal(card.fields[0].label, '样本编码') - assert.equal(card.fields[0].value, 'S-001') + assert.equal(card.fields[2].label, '样本编码') + assert.equal(card.fields[2].value, 'S-001') assert.ok(!card.fields.some((item) => item.label === '协议编号')) assert.ok(!card.fields.some((item) => item.label === '状态')) assert.ok(card.fields.every((item) => !item.wide)) @@ -110,7 +110,7 @@ test('research donation protocol card matches excel-parity field set', () => { allowedActions: ['edit'] }) const labels = card.fields.map((f) => f.label) - assert.equal(labels[0], '样本编码') + assert.equal(labels[2], '样本编码') for (const label of [ '样本编码', '当月核量号', '实验室流转编号', '废弃原因', '样本类型', '采集地区', '采集医院', '分娩时间', '采集时间', '市场专员', '地区经理', diff --git a/mobile-oa-h5/src/market/mobile-parity-ui.test.js b/mobile-oa-h5/src/market/mobile-parity-ui.test.js index c8825f82..18da6eae 100644 --- a/mobile-oa-h5/src/market/mobile-parity-ui.test.js +++ b/mobile-oa-h5/src/market/mobile-parity-ui.test.js @@ -570,7 +570,7 @@ test('protocol cards expose the same business fields as the web list', async () }) assert.deepEqual(card.fields.map((field) => field.label), [ - '样本编码', '登记日期', '母亲电话', '母亲身份证号', '父亲姓名', + '所属项目', '项目组', '样本编码', '登记日期', '母亲电话', '母亲身份证号', '父亲姓名', '新生儿性别', '分娩方式', '新生儿体重(克)', '采集地区', '采集医院', '市场专员', '样本采集时间', '创建时间' ]) diff --git a/mobile-oa-h5/src/market/modules/presign.js b/mobile-oa-h5/src/market/modules/presign.js index a897f786..3eb6f5d5 100644 --- a/mobile-oa-h5/src/market/modules/presign.js +++ b/mobile-oa-h5/src/market/modules/presign.js @@ -100,6 +100,8 @@ export default { key: 'base', title: '预签信息', fields: [ + { label: '所属项目', value: detail?.projectName || (detail?.projectId ? '历史项目' : '未归属') }, + { label: '项目组', value: detail?.teamName || '—' }, { label: '业务日期', value: displayValue(formatPresignDate(detail?.orderDate)) }, { label: '母亲姓名', value: detail?.motherName || '—' }, { label: '预产期', value: displayValue(formatPresignDate(detail?.expectedDeliveryDate)) }, diff --git a/mobile-oa-h5/src/market/modules/protocol.js b/mobile-oa-h5/src/market/modules/protocol.js index e9b21bf5..a0ebbaf7 100644 --- a/mobile-oa-h5/src/market/modules/protocol.js +++ b/mobile-oa-h5/src/market/modules/protocol.js @@ -452,6 +452,8 @@ export default { ? [ { label: '状态', value: bizStatus.label, tag: true, tagType: bizStatus.tagType }, { label: '母亲姓名', value: value(detail?.customer, 'motherName') }, + { label: '所属项目', value: basic.projectName || (basic.projectId ? '历史项目' : '未归属') }, + { label: '项目组', value: basic.teamName || '—' }, { label: '捐赠日期', value: value(basic, dateKey) }, { label: '创建时间', value: value(basic, 'createTime') }, { label: '备注', value: value(basic, 'remark') } @@ -459,6 +461,8 @@ export default { : [ { label: '协议状态', value: bizStatus.label, tag: true, tagType: bizStatus.tagType }, { label: '母亲姓名', value: value(detail?.customer, 'motherName') }, + { label: '所属项目', value: basic.projectName || (basic.projectId ? '历史项目' : '未归属') }, + { label: '项目组', value: basic.teamName || '—' }, { label: '登记日期', value: value(basic, dateKey) }, { label: '协议总金额', value: value(basic, 'protocolTotalAmount') }, { label: '备注', value: value(basic, 'remark') }, diff --git a/mobile-oa-h5/src/market/presign/presign-form-state.js b/mobile-oa-h5/src/market/presign/presign-form-state.js index 7ae709fe..bf6ac410 100644 --- a/mobile-oa-h5/src/market/presign/presign-form-state.js +++ b/mobile-oa-h5/src/market/presign/presign-form-state.js @@ -47,6 +47,9 @@ export function createPresignForm(value = {}, options = {}) { const defaultCollector = stringValue(options.currentUserId) return { id: stringValue(source.id), + projectId: stringValue(source.projectId), + projectName: stringValue(source.projectName), + teamName: stringValue(source.teamName), orderDate: stringValue(source.orderDate, stringValue(options.today, todayYmd())), protocolNo: stringValue(source.protocolNo), motherName: stringValue(source.motherName), @@ -153,6 +156,7 @@ export function buildPresignOrderPayload(form = {}, { includeTransfers = true } if (Object.prototype.hasOwnProperty.call(payload, 'contractAmount')) { payload.contractAmount = normalizedAmount(payload.contractAmount) } + if (!source.id && source.projectId) payload.projectId = source.projectId if (includeTransfers && Array.isArray(source.transfers)) { payload.transfers = source.transfers.map(buildPresignTransferPayload) } diff --git a/mobile-oa-h5/src/market/presign/presign-form-state.test.js b/mobile-oa-h5/src/market/presign/presign-form-state.test.js index 0852149b..67d82a3a 100644 --- a/mobile-oa-h5/src/market/presign/presign-form-state.test.js +++ b/mobile-oa-h5/src/market/presign/presign-form-state.test.js @@ -18,6 +18,9 @@ import { test('creates every Web-writable field with mobile defaults', () => { assert.deepEqual(createPresignForm({}, { today: '20261011', currentUserId: 'user-1' }), { id: '', + projectId: '', + projectName: '', + teamName: '', orderDate: '20261011', protocolNo: '', motherName: '', diff --git a/mobile-oa-h5/src/market/presign/presign-list-model.js b/mobile-oa-h5/src/market/presign/presign-list-model.js index 55478965..865296d5 100644 --- a/mobile-oa-h5/src/market/presign/presign-list-model.js +++ b/mobile-oa-h5/src/market/presign/presign-list-model.js @@ -25,8 +25,9 @@ function payModeLabel(value) { return resolveStatusTag(payMode, key).label } -export function buildPresignListParams({ keyword = '', orderStatus = '', page = 1, pageSize = 10 } = {}) { +export function buildPresignListParams({ keyword = '', orderStatus = '', projectId = '', page = 1, pageSize = 10 } = {}) { return { + ...(projectId ? { projectId: String(projectId).trim() } : {}), keyword: String(keyword || '').trim(), orderStatus: String(orderStatus || '').trim(), page: Math.max(1, Number(page) || 1), @@ -50,6 +51,8 @@ export function buildPresignCardModel(item = {}, index = 0, role = 'specialist', ), title: displayText(item.motherName), fields: [ + { key: 'projectName', label: '所属项目', value: item.projectName || (item.projectId ? '历史项目' : '未归属') }, + { key: 'teamName', label: '项目组', value: displayText(item.teamName) }, { key: 'orderDate', label: '业务日期', value: formatPresignDate(item.orderDate) }, { key: 'motherName', label: '母亲姓名', value: displayText(item.motherName) }, { diff --git a/mobile-oa-h5/src/market/presign/presign-list-model.test.js b/mobile-oa-h5/src/market/presign/presign-list-model.test.js index 27d3eb63..05737033 100644 --- a/mobile-oa-h5/src/market/presign/presign-list-model.test.js +++ b/mobile-oa-h5/src/market/presign/presign-list-model.test.js @@ -49,6 +49,8 @@ test('builds every Web list field with Task 3 formatters and authorized actions' assert.equal(card.index, '13') assert.equal(card.title, '张三') assert.deepEqual(card.fields, [ + { key: 'projectName', label: '所属项目', value: '未归属' }, + { key: 'teamName', label: '项目组', value: '-' }, { key: 'orderDate', label: '业务日期', value: '2026-10-11' }, { key: 'motherName', label: '母亲姓名', value: '张三' }, { key: 'expectedDeliveryDate', label: '预产期', value: '2026-12-31' }, diff --git a/mobile-oa-h5/src/market/project-assignment-state.test.js b/mobile-oa-h5/src/market/project-assignment-state.test.js new file mode 100644 index 00000000..415db442 --- /dev/null +++ b/mobile-oa-h5/src/market/project-assignment-state.test.js @@ -0,0 +1,17 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { projectOptions, projectValidation, projectLabel } from './project-assignment.js' + +test('only enabled options may be selected, preserving project and team labels', () => { + assert.deepEqual(projectOptions({ list: [{ id: 'p', projectName: '项目', teamName: '组' }, { id: 'old', disabled: true }] }), [{ value: 'p', text: '项目 · 组' }]) + assert.equal(projectLabel({ projectId: 'old', projectName: '历史停用项目' }), '历史停用项目') + assert.equal(projectLabel({}), '未归属') +}) +test('new assignments fail closed during loading or failure and enforce required, edits do not revalidate history', () => { + assert.match(projectValidation({ loading: true }), /加载/) + assert.match(projectValidation({ error: '失败' }), /重试/) + assert.match(projectValidation({ required: true }), /选择/) + assert.equal(projectValidation({ required: false }), '') + assert.equal(projectValidation({ editing: true, error: '失败', required: true }), '') + assert.equal(projectValidation({ required: true, projectId: 'p' }), '') +}) diff --git a/mobile-oa-h5/src/market/project-assignment.js b/mobile-oa-h5/src/market/project-assignment.js new file mode 100644 index 00000000..4baa6756 --- /dev/null +++ b/mobile-oa-h5/src/market/project-assignment.js @@ -0,0 +1,18 @@ +export function projectOptions(data) { + return (data?.list || []).filter((row) => !row.disabled).map((row) => ({ + value: String(row.id), + text: [row.projectName, row.teamName].filter(Boolean).join(' · ') + })) +} + +export function projectLabel(record) { + return record?.projectName || (record?.projectId ? '历史项目' : '未归属') +} + +export function projectValidation({ editing = false, loading = false, error = '', required = false, projectId = '' } = {}) { + if (editing) return '' + if (loading) return '项目选项加载中,请稍后保存' + if (error) return '项目选项加载失败,请重试后保存' + if (required && !projectId) return '请选择所属项目' + return '' +} diff --git a/mobile-oa-h5/src/market/project-assignment.test.js b/mobile-oa-h5/src/market/project-assignment.test.js new file mode 100644 index 00000000..fe636af4 --- /dev/null +++ b/mobile-oa-h5/src/market/project-assignment.test.js @@ -0,0 +1,19 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { createPresignForm, buildPresignOrderPayload } from './presign/presign-form-state.js' +import { buildListParams } from './list-filters.js' +import { buildPresignListParams } from './presign/presign-list-model.js' + +test('new presign submits project while editing retains historical display without rewriting assignment', () => { + const fresh = createPresignForm({ projectId: 'p1', projectName: '项目一', teamName: '项目组' }) + assert.equal(buildPresignOrderPayload(fresh).projectId, 'p1') + const historical = createPresignForm({ ...fresh, id: 'record' }) + assert.equal(historical.projectName, '项目一') + assert.equal(historical.teamName, '项目组') + assert.equal(Object.hasOwn(buildPresignOrderPayload(historical), 'projectId'), false) +}) + +test('project filters reach both mobile list requests', () => { + assert.equal(buildListParams('protocol', { source: 'research_donation', projectId: 'p1' }).projectId, 'p1') + assert.equal(buildPresignListParams({ projectId: 'p1' }).projectId, 'p1') +}) diff --git a/mobile-oa-h5/src/market/protocol-mobile-parity.test.js b/mobile-oa-h5/src/market/protocol-mobile-parity.test.js index 3026b30a..3dd15b56 100644 --- a/mobile-oa-h5/src/market/protocol-mobile-parity.test.js +++ b/mobile-oa-h5/src/market/protocol-mobile-parity.test.js @@ -127,7 +127,7 @@ test('donation detail follows the web view and omits price and payment', () => { ]) assert.equal(sections[0].title, '基本信息') assert.deepEqual(sections[0].fields.map((field) => field.label), [ - '状态', '母亲姓名', '捐赠日期', '创建时间', '备注' + '状态', '母亲姓名', '所属项目', '项目组', '捐赠日期', '创建时间', '备注' ]) const sampleLabels = sections.find((section) => section.key === 'samples').cards[0].fields.map((field) => field.label) for (const hidden of ['优惠价', '原价', '存储年限']) { diff --git a/mobile-oa-h5/src/views/market/MarketModuleForm.vue b/mobile-oa-h5/src/views/market/MarketModuleForm.vue index 8bc60274..e374cbf0 100644 --- a/mobile-oa-h5/src/views/market/MarketModuleForm.vue +++ b/mobile-oa-h5/src/views/market/MarketModuleForm.vue @@ -12,6 +12,9 @@ diff --git a/web-admin/src/views/project-management/RecordList.vue b/web-admin/src/views/project-management/RecordList.vue new file mode 100644 index 00000000..5e37724a --- /dev/null +++ b/web-admin/src/views/project-management/RecordList.vue @@ -0,0 +1,282 @@ + + diff --git a/web-admin/src/views/project-management/assignments/index.vue b/web-admin/src/views/project-management/assignments/index.vue new file mode 100644 index 00000000..858fd5fe --- /dev/null +++ b/web-admin/src/views/project-management/assignments/index.vue @@ -0,0 +1,8 @@ + + diff --git a/web-admin/src/views/project-management/projects/index.vue b/web-admin/src/views/project-management/projects/index.vue new file mode 100644 index 00000000..75c387be --- /dev/null +++ b/web-admin/src/views/project-management/projects/index.vue @@ -0,0 +1,4 @@ + + diff --git a/web-admin/src/views/project-management/statistics/index.vue b/web-admin/src/views/project-management/statistics/index.vue new file mode 100644 index 00000000..6ed4ceb0 --- /dev/null +++ b/web-admin/src/views/project-management/statistics/index.vue @@ -0,0 +1,117 @@ + + diff --git a/web-admin/src/views/project-management/teams/index.vue b/web-admin/src/views/project-management/teams/index.vue new file mode 100644 index 00000000..9f500e15 --- /dev/null +++ b/web-admin/src/views/project-management/teams/index.vue @@ -0,0 +1,4 @@ + + diff --git a/web-admin/src/views/research-donation/list/DonationFormDialog.vue b/web-admin/src/views/research-donation/list/DonationFormDialog.vue index 940049d4..6a545321 100644 --- a/web-admin/src/views/research-donation/list/DonationFormDialog.vue +++ b/web-admin/src/views/research-donation/list/DonationFormDialog.vue @@ -41,6 +41,22 @@

基本信息

+ +
+ {{ formData.teamName || '未归属项目组' }} · 归属调整请使用项目归属维护 +