Compare commits

...
5 Commits
Author SHA1 Message Date
zlwu 0bddf38e52 feat(project): enforce team-scoped selection with explicit all-project permission
Separate cross-team selection from attribution maintenance and apply the same scope to options, history filters and writes. Add an opt-in tenant permission without granting existing roles.

Protect authorization mutation paths against self-grants, validate tenant targets and links, and include menu ancestors atomically. Cover role union, revocation, mobile writes and administration boundaries with regression tests.
2026-11-02 17:09:20 +08:00
zlwu b510d18439 fix(project): add list row numbers and align search actions
Keep project management lists and related detail, preview and audit dialogs consistent with existing table and filter styles. Number paginated rows continuously and align statistics and assignment searches to the right.
2026-11-02 16:31:02 +08:00
zlwu f41e6f60d6 feat(projects): move management pages into a dedicated top-level menu
Add the Project navigation group alongside Market, Production and Quality. Preserve existing leaf IDs and role grants while migrating the menu hierarchy, keep legacy URLs redirecting, and align frontend/backend menus.
2026-11-02 16:22:18 +08:00
zlwu a4c4028144 fix(projects): resolve runtime permissions and migration compatibility
Use existing menu permissions for management actions, preserve shared list styles in teleported detail dialogs, and align project attribution column collations with the project primary key. Record isolated MySQL and browser validation while retaining the real-device acceptance gate.
2026-11-02 16:04:31 +08:00
zlwu bd62416263 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.
2026-11-02 15:29:43 +08:00
91 changed files with 4124 additions and 57 deletions

No files matched your search

+29
View File
@@ -49,3 +49,32 @@ 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)与
[项目一级菜单迁移](../docs/sql/migrations/2026-11-02-project_top_menu.sql),
最后执行[全部项目权限种子](../docs/sql/migrations/2026-11-02-project_all_permission.sql)。
“项目”与“市场、生产、质检”平级,包含项目组管理、项目管理、业务统计、历史归属维护。
菜单种子不会给角色新增业务权限,应在现有角色管理分配项目管理权限及祖先菜单;
一级菜单迁移保留已有叶子 ID/授权,仅给已授权项目子菜单的角色补齐新祖先。
“所属项目”默认只列出本人担任负责人或成员的项目组内项目(多组取并集)。
需要跨组选择时,在“系统管理 → 角色管理 → 菜单权限 → 项目 → 全部项目”显式授予
`project:project:all`;多角色与职位继承权限取有效角色并集。已有归属维护权限、普通管理员身份不会自动获得该权限。
超级管理员沿用系统全部权限语义,但所有候选仍限当前租户。新建仅列启用且支持该业务的项目;
历史筛选叠加原订单数据权限,可保留组内停用项目。全部项目权限不授予订单查看、项目资料编辑或归属维护权限。
授权配置入口同时加固:用户、角色、职位和菜单变更分别需要已有的 `system:user:list`、
`system:role:list`、`system:post:list`、`system:menu:list` 权限(超级管理员保留全权限),
避免普通业务管理员自行绑定角色或修改权限码。质检金额专用角色授权的三个写入口也校验
`biz:qcMaterialAmountAuth:list`,防止经专用角色绕过上述限制。配置保存校验当前租户,角色菜单自动补齐祖先,
不自动勾选其他功能。升级前应核对实际配置人员已获对应系统管理权限。
脚本不回填历史业务,不代表空库初始化;执行前备份并在隔离 MySQL 环境验证。
`PROJECT_ASSIGNMENT_REQUIRED` 默认 `false`,便于先配置项目组和项目、兼容历史客户端。
人员和启用项目配置齐全后由运维将其设为 `true`;新建预签约及四类正式业务必须选择项目。
普通编辑保留历史归属,历史补录/调整须通过归属维护并填写原因;受控业务转换继承原归属。
该开关同时返回给 Web 和移动表单,调整配置后的服务重启由用户执行。
+3
View File
@@ -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",
+44 -24
View File
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.data_scope import apply_data_scope, _get_child_dept_ids
from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code, require_strict_menu_code
from app.core.response import success_response, error_response
from app.core.storage import storage
from app.crud import tenant as tenant_svc
@@ -27,7 +27,8 @@ from app.crud import menu as menu_svc
from app.crud import role as role_svc
from app.crud import user as user_svc
from app.crud.collection_protocol import _lookup_attachment_by_url
from app.models.models import BizAttachment, SysDept, SysRole, SysUser
from app.models.models import BizAttachment, SysDept, SysRole, SysUser, SysMenu, SysPost
from app.services.permission_administration import validate_tenant_references
from app.services.web_auth_roles import (
_map_role_name_to_web,
list_user_ids_with_effective_role_name,
@@ -238,12 +239,12 @@ def _get_tenant_no(db: Session, user: dict) -> str:
def _assert_user_tenant_access(db: Session, user: dict, target_user_id: str):
"""同租户或 super_admin 才可写操作用户;返回目标用户行。"""
"""用户管理始终在当前认证租户内操作;返回目标用户行。"""
row = user_svc.get_user(db, target_user_id)
if not row:
raise ValueError("用户不存在")
tenant_no = _get_tenant_no(db, user)
if row.tenant_no != tenant_no and user.get("role") != "super_admin":
if row.tenant_no != tenant_no:
raise ValueError("无权操作其他租户的用户")
return row
@@ -383,11 +384,13 @@ async def get_user_by_id(
@router.post("/users")
async def create_user(
body: UserCreateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:user:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
validate_tenant_references(db, SysRole, body.role_ids, tenant_no)
validate_tenant_references(db, SysPost, body.post_ids, tenant_no)
if user_svc.user_no_exists(db, body.user_no, tenant_no):
return error_response(400, "用户账号已存在")
obj = user_svc.create_user(
@@ -421,11 +424,14 @@ async def create_user(
async def update_user(
user_id: str,
body: UserUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:user:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
_assert_user_tenant_access(db, user, user_id)
tenant_no = _get_tenant_no(db, user)
validate_tenant_references(db, SysRole, body.role_ids, tenant_no)
validate_tenant_references(db, SysPost, body.post_ids, tenant_no)
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
update_kwargs: dict[str, Any] = {
"nick_name": payload.get("nick_name"),
@@ -457,7 +463,7 @@ async def update_user(
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:user:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -471,7 +477,7 @@ async def delete_user(
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: str,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:user:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -971,7 +977,7 @@ async def list_posts(
@router.post("/posts")
async def create_post(
body: PostCreateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -995,10 +1001,11 @@ async def create_post(
async def update_post(
post_id: str,
body: PostUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysPost, [post_id], _get_tenant_no(db, user))
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
obj = post_svc.update_post(
db,
@@ -1018,10 +1025,11 @@ async def update_post(
@router.delete("/posts/{post_id}")
async def delete_post(
post_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysPost, [post_id], _get_tenant_no(db, _user))
post_svc.soft_delete_post(db, post_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -1052,7 +1060,7 @@ async def list_post_bound_users(
async def update_post_bound_users(
post_id: str,
body: PostUsersUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
@@ -1092,7 +1100,7 @@ async def list_post_bound_roles(
async def update_post_bound_roles(
post_id: str,
body: PostRolesUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
@@ -1353,11 +1361,13 @@ async def list_menus(
@router.post("/menus")
async def create_menu(
body: MenuCreateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:menu:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
if body.parent_id and body.parent_id != "0":
validate_tenant_references(db, SysMenu, [body.parent_id], _get_tenant_no(db, user))
obj = menu_svc.create_menu(
db,
menu_name=body.menu_name,
@@ -1383,11 +1393,14 @@ async def create_menu(
async def update_menu(
menu_id: str,
body: MenuUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:menu:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysMenu, [menu_id], _get_tenant_no(db, user))
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
if body.parent_id and body.parent_id != "0":
validate_tenant_references(db, SysMenu, [body.parent_id], _get_tenant_no(db, user))
obj = menu_svc.update_menu(
db,
menu_id,
@@ -1412,10 +1425,11 @@ async def update_menu(
@router.delete("/menus/{menu_id}")
async def delete_menu(
menu_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:menu:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysMenu, [menu_id], _get_tenant_no(db, _user))
menu_svc.soft_delete_menu(db, menu_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -1475,7 +1489,7 @@ async def list_role_management(
@router.post("/role-management")
async def create_role_management(
body: RoleCreateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -1499,10 +1513,11 @@ async def create_role_management(
async def update_role_management(
role_id: str,
body: RoleUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysRole, [role_id], _get_tenant_no(db, user))
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
obj = role_svc.update_role(
db,
@@ -1522,10 +1537,11 @@ async def update_role_management(
@router.delete("/role-management/{role_id}")
async def delete_role_management(
role_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysRole, [role_id], _get_tenant_no(db, _user))
role_svc.soft_delete_role(db, role_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -1535,9 +1551,12 @@ async def delete_role_management(
@router.get("/role-management/{role_id}/menus")
async def get_role_menus(
role_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
role = role_svc.get_role(db, role_id)
if not role or role.tenant_no != _get_tenant_no(db, _user):
return error_response(404, "角色不存在")
menu_ids = role_svc.get_role_menu_ids(db, role_id)
return success_response({"menuIds": menu_ids})
@@ -1546,7 +1565,7 @@ async def get_role_menus(
async def update_role_menus(
role_id: str,
body: RoleMenuBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -1560,11 +1579,11 @@ async def update_role_menus(
@router.get("/role-management/{role_id}/data-scope")
async def get_role_data_scope(
role_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
role = role_svc.get_role(db, role_id)
if not role:
if not role or role.tenant_no != _get_tenant_no(db, _user):
return error_response(404, "角色不存在")
details = role_svc.get_role_data_scope(db, role_id)
return success_response({"dataScope": role.data_scope or "1", "scopeDetails": details})
@@ -1574,10 +1593,11 @@ async def get_role_data_scope(
async def update_role_data_scope(
role_id: str,
body: RoleDataScopeBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysRole, [role_id], _get_tenant_no(db, user))
tenant_no = _get_tenant_no(db, user)
role_svc.update_role_data_scope(
db,
+19 -2
View File
@@ -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))
+32
View File
@@ -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')
+22 -2
View File
@@ -1,4 +1,4 @@
"""运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)"""
"""运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐)"""
from fastapi import APIRouter, Depends
from app.core.menu_guard import require_admin_or_super_admin
@@ -6,9 +6,29 @@ from app.core.response import success_response
router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
# 与 web-admin/src/router/modules/sqxp.ts 一致:5 个一级菜单分组
# 与 web-admin/src/router/modules/sqxp.ts 一致:6 个一级菜单分组
# 市场二级顺序:存储服务、科研捐赠、市场基础信息、中试服务、基础实验
STATIC_MENU_TREE = [
{
"id": "TOP00000000000000000000000000006",
"name": "Project",
"path": "/project",
"component": "/index/index",
"meta": {
"title": "项目", "icon": "ri:folder-chart-line", "keepAlive": False,
"isTopMenu": True, "menuCode": "menu:project", "roles": ["admin"],
},
"children": [
{"name": "ProjectManagementTeams", "path": "teams", "component": "/project-management/teams/index",
"meta": {"title": "项目组管理", "keepAlive": True, "menuCode": "project:team:list"}},
{"name": "ProjectManagementProjects", "path": "projects", "component": "/project-management/projects/index",
"meta": {"title": "项目管理", "keepAlive": True, "menuCode": "project:project:list"}},
{"name": "ProjectManagementStatistics", "path": "statistics", "component": "/project-management/statistics/index",
"meta": {"title": "业务统计", "keepAlive": True, "menuCode": "project:statistics:list"}},
{"name": "ProjectManagementAssignments", "path": "assignments", "component": "/project-management/assignments/index",
"meta": {"title": "历史归属维护", "keepAlive": True, "menuCode": "project:assignment:edit"}},
],
},
# ────────────────────────────────────────────
# 1. 市场 (menu:market)
# ────────────────────────────────────────────
+27 -2
View File
@@ -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))
+7
View File
@@ -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 "",
+173
View File
@@ -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))
+4 -3
View File
@@ -13,6 +13,7 @@ from app.core.menu_guard import (
require_admin_or_super_admin,
require_any_menu_code,
require_menu_code,
require_strict_menu_code,
strip_row_fields,
user_has_menu_perm,
)
@@ -2650,7 +2651,7 @@ async def list_qc_material_amount_auth_users(
async def grant_qc_material_amount_auth(
body: QcMaterialAmountAuthGrantBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
user: dict = Depends(require_strict_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, allow_super_admin=True)),
):
"""为用户绑定「质检物料金额查看」专用角色。"""
try:
@@ -2674,7 +2675,7 @@ async def grant_qc_material_amount_auth(
async def revoke_qc_material_amount_auth(
user_id: str,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
user: dict = Depends(require_strict_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, allow_super_admin=True)),
):
"""解除用户的「质检物料金额查看」专用角色。"""
try:
@@ -2699,7 +2700,7 @@ async def revoke_qc_material_amount_auth(
async def batch_revoke_qc_material_amount_auth(
body: QcMaterialAmountAuthBatchBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
user: dict = Depends(require_strict_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, allow_super_admin=True)),
):
"""批量解除「质检物料金额查看」授权。"""
try:
+15
View File
@@ -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,
+3
View File
@@ -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
+3 -1
View File
@@ -113,7 +113,7 @@ def assert_strict_menu_code(
raise HTTPException(status_code=403, detail="无对应菜单权限")
def require_strict_menu_code(menu_code: str) -> Callable:
def require_strict_menu_code(menu_code: str, *, allow_super_admin: bool = False) -> Callable:
"""租户化的精确菜单权限依赖;与历史宽松守卫并存。"""
required = str(menu_code or "").strip()
@@ -121,6 +121,8 @@ def require_strict_menu_code(menu_code: str) -> Callable:
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
if allow_super_admin and user.get('role') == SUPER_ADMIN_ROLE:
return user
assert_strict_menu_code(db, user, required)
return user
+12 -5
View File
@@ -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
+11
View File
@@ -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("当前状态不允许编辑")
+8
View File
@@ -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,
+14
View File
@@ -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,
+3
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows, sync_link_rows
from app.services.permission_administration import menu_ids_with_ancestors, validate_tenant_references
from app.models.models import (
SysPost,
SysPostRole,
@@ -378,6 +379,8 @@ def update_role_menus(
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
validate_tenant_references(db, SysRole, [role_id], tenant_no)
menu_ids = menu_ids_with_ancestors(db, menu_ids, tenant_no)
sync_link_rows(
db,
SysRoleMenu,
+3 -1
View File
@@ -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:
+7
View File
@@ -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",
+3
View File
@@ -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="课题名称(基础实验)")
+67
View File
@@ -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))
+1
View File
@@ -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")
+4
View File
@@ -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']
+15
View File
@@ -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)
+26 -2
View File
@@ -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
)
@@ -0,0 +1,35 @@
"""Validate authorization targets before any role/menu relationship is changed."""
from app.models.models import SysMenu
def validate_tenant_references(db, model, ids, tenant_no):
"""Reject deleted, foreign or missing targets without writing partial grants."""
ids = list(dict.fromkeys(ids or []))
if not ids:
return
found = {row.id for row in db.query(model.id).filter(
model.id.in_(ids), model.tenant_no == tenant_no, model.del_flag == 0)}
if found != set(ids):
raise ValueError('关联记录不存在或不属于当前租户')
def menu_ids_with_ancestors(db, menu_ids, tenant_no):
"""Include only each selected node's same-tenant ancestors, up to its own root."""
menus = {row.id: row for row in db.query(SysMenu).filter(
SysMenu.tenant_no == tenant_no, SysMenu.del_flag == 0)}
result, included = [], set()
for selected in menu_ids:
current, path = selected, set()
while current and current != '0':
if current in path:
raise ValueError('菜单存在循环父级,不能授权')
if current not in menus:
raise ValueError('菜单或祖先不存在或不属于当前租户')
path.add(current)
if current not in included:
result.append(current)
included.add(current)
current = menus[current].parent_id
if not path:
raise ValueError('菜单不存在或不属于当前租户')
return result
@@ -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
+415
View File
@@ -0,0 +1,415 @@
"""项目资料、权限内统计和关联归属事务。"""
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')
ALL_PROJECTS_PERMISSION = 'project:project:all'
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 == 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, ALL_PROJECTS_PERMISSION):
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 apply_project_choice_scope(query, db, tenant, user):
"""Apply team membership or the explicit tenant-wide project-choice permission.
The caller joins the tenant-scoped, non-deleted ProjectTeam table. Business
data scope and project availability remain separate caller constraints.
"""
if has_permission(db, tenant, user, ALL_PROJECTS_PERMISSION):
return query
memberships = scoped(db, ProjectTeamMember, tenant).filter(
ProjectTeamMember.user_id == user.get('user_id')).with_entities(ProjectTeamMember.team_id)
return query.filter(or_(ProjectTeam.leader_user_id == user.get('user_id'),
Project.team_id.in_(memberships)))
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')
q = apply_project_choice_scope(q, db, tenant, user)
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):
"""Intersect project-choice scope with visible history; include stopped projects."""
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).join(ProjectTeam, and_(
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant,
ProjectTeam.del_flag == 0)).filter(Project.id.in_(ids))
q = apply_project_choice_scope(q, db, tenant, user)
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}
+1 -1
View File
@@ -24,7 +24,7 @@ from app.models.models import (
NO_ROLE_WEB_ROLE = "guest"
# 一级菜单权限码(用于顶部菜单权限控制)
TOP_MENU_PERMS = frozenset({"menu:market", "menu:production", "menu:quality", "menu:operations", "menu:system-manage"})
TOP_MENU_PERMS = frozenset({"menu:project", "menu:market", "menu:production", "menu:quality", "menu:operations", "menu:system-manage"})
def _map_role_name_to_web(role_name: str) -> str:
@@ -0,0 +1,201 @@
"""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']
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_cross_team_assignment_requires_explicit_all_permission(db, kind):
from app.models.models import SysMenu, SysRole, SysRoleMenu, SysUserRole
# Becoming a project leader does not make the user a member of its team.
db.get(ProjectTeam, 'team').leader_user_id = 'u2'
db.add_all([
SysRole(id='assignment-role', tenant_no='tenant-a', role_name='归属维护', data_scope='4'),
SysUserRole(id='assignment-user-role', tenant_no='tenant-a', user_id='u1', role_id='assignment-role'),
SysMenu(id='assignment-menu', tenant_no='tenant-a', menu_name='归属维护', perms='project:assignment:edit'),
SysRoleMenu(id='assignment-role-menu', tenant_no='tenant-a', role_id='assignment-role', menu_id='assignment-menu'),
])
db.commit()
def create():
if kind == 'presign':
return market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info={**_user(), 'role': 'admin'})
return market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info={**_user(), 'role': 'admin'})
with pytest.raises(HTTPException) as caught:
create()
assert caught.value.status_code == 403
db.add_all([
SysMenu(id='all-menu', tenant_no='tenant-a', menu_name='全部项目', menu_type='F', visible='0', perms='project:project:all'),
SysRoleMenu(id='all-role-menu', tenant_no='tenant-a', role_id='assignment-role', menu_id='all-menu'),
])
db.commit()
result = create()
assert (result if kind == 'presign' else result['basic'])['projectId'] == 'project'
@@ -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_keep_stopped_projects_within_membership_and_business_access(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='u', 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')
+192
View File
@@ -0,0 +1,192 @@
"""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:project:all'),
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'), 9)
self.assertEqual(migration.count('information_schema.statistics'), 4)
self.assertNotIn(' BEFORE ', migration)
self.assertNotIn('JSON_TABLE', migration)
self.assertIn('INTO @project_charset, @project_collation', migration)
self.assertEqual(migration.count('ADD COLUMN project_id VARCHAR(64) CHARACTER SET'), 4)
self.assertEqual(migration.count('MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET'), 4)
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()
@@ -0,0 +1,180 @@
"""Users cannot self-grant project access through the permission administration APIs."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin import router
from app.core.auth import get_current_user
from app.core.database import get_db
from app.models.base import Base
from app.models.models import SysMenu, SysPost, SysRole, SysRoleMenu, SysUser, SysUserRole
@pytest.fixture
def env():
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
db = Session(engine)
user = dict(user_id='u', role='admin', tenant_no='t')
db.add_all([
SysUser(id='u', tenant_no='t', user_no='member', nick_name='Member'),
SysUser(id='foreign-user', tenant_no='foreign', user_no='foreign', nick_name='Foreign'),
SysRole(id='role', tenant_no='t', role_name='Member', data_scope='4'),
SysRole(id='foreign-role', tenant_no='foreign', role_name='Foreign'),
SysUserRole(id='ur', tenant_no='t', user_id='u', role_id='role'),
SysPost(id='post', tenant_no='t', post_name='Post', post_code='POST'),
SysPost(id='foreign-post', tenant_no='foreign', post_name='Foreign', post_code='FOREIGN'),
SysMenu(id='root', tenant_no='t', parent_id='0', menu_name='Web'),
SysMenu(id='project', tenant_no='t', parent_id='root', menu_name='项目', perms='menu:project'),
SysMenu(id='all', tenant_no='t', parent_id='project', menu_name='全部项目', menu_type='F', perms='project:project:all'),
SysMenu(id='foreign-menu', tenant_no='foreign', parent_id='0', menu_name='Foreign'),
])
db.commit()
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[get_db] = lambda: db
with TestClient(app) as client:
yield db, user, client
db.close()
engine.dispose()
def grant(db, code):
db.add_all([SysMenu(id=code, tenant_no='t', menu_name=code, perms=code),
SysRoleMenu(id=code, tenant_no='t', role_id='role', menu_id=code)])
db.commit()
@pytest.mark.parametrize('method,path,body', [
('PUT', '/role-management/role/menus', {'menuIds': ['all']}),
('POST', '/role-management', {'roleName': '超级管理员'}),
('PUT', '/role-management/role', {'roleName': '超级管理员'}),
('DELETE', '/role-management/role', None),
('PUT', '/role-management/role/data-scope', {'dataScope': '1'}),
('POST', '/users', {'userNo': 'attacker', 'nickName': 'Attacker', 'password': 'TestOnly123!', 'roleIds': ['role']}),
('PUT', '/users/u', {'roleIds': ['foreign-role']}),
('PUT', '/users/u', {'postIds': ['post']}),
('POST', '/users/u/reset-password', None),
('DELETE', '/users/u', None),
('PUT', '/posts/post/roles', {'roleIds': ['role']}),
('PUT', '/posts/post/users', {'userIds': ['u']}),
('PUT', '/posts/post', {'status': '1'}),
('POST', '/menus', {'menuName': 'Grant', 'perms': 'project:project:all'}),
('PUT', '/menus/project', {'perms': 'project:project:all'}),
('DELETE', '/menus/all', None),
])
def test_business_admin_cannot_mutate_permission_graph(env, method, path, body):
_, _, client = env
result = client.request(method, '/web/admin' + path, json=body)
assert result.status_code == 403
def test_role_manager_grants_leaf_and_ancestors_only(env):
db, _, client = env
grant(db, 'system:role:list')
result = client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'system:role:list']})
assert result.json()['code'] == 200
rows = db.query(SysRoleMenu).filter_by(role_id='role', tenant_no='t', del_flag=0).all()
assert {r.menu_id for r in rows} == {'all', 'project', 'root', 'system:role:list'}
# Same save is idempotent and does not create duplicate active grants.
assert client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'system:role:list']}).json()['code'] == 200
assert db.query(SysRoleMenu).filter_by(role_id='role', tenant_no='t', del_flag=0).count() == 4
@pytest.mark.parametrize('super_admin', [False, True])
@pytest.mark.parametrize('path,body', [
('/role-management/foreign-role/menus', {'menuIds': ['all']}),
('/role-management/role/menus', {'menuIds': ['all', 'foreign-menu']}),
('/role-management/foreign-role', {'roleName': 'Changed'}),
('/role-management/foreign-role/data-scope', {'dataScope': '1'}),
('/menus/foreign-menu', {'perms': 'project:project:all'}),
('/menus/project', {'parentId': 'foreign-menu'}),
('/posts/foreign-post', {'status': '1'}),
('/users/foreign-user', {'roleIds': ['role']}),
('/users/u', {'roleIds': ['foreign-role']}),
('/users/u', {'postIds': ['foreign-post']}),
])
def test_permission_admin_cannot_write_foreign_targets_or_links(env, path, body, super_admin):
db, user, client = env
for code in ('system:role:list', 'system:menu:list', 'system:post:list', 'system:user:list'):
grant(db, code)
if super_admin:
user['role'] = 'super_admin'
before = {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)}
result = client.put('/web/admin' + path, json=body)
assert result.json().get('code', result.status_code) in (400, 403, 404)
db.expire_all()
assert {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)} == before
assert db.get(SysRole, 'foreign-role').role_name == 'Foreign'
assert db.get(SysUser, 'foreign-user').tenant_no == 'foreign'
@pytest.mark.parametrize('parent', ['missing', 'all'])
def test_role_grant_rejects_broken_or_cyclic_ancestry_atomically(env, parent):
db, user, client = env
user['role'] = 'super_admin'
db.get(SysMenu, 'project').parent_id = parent
db.commit()
result = client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all']})
assert result.json().get('code', result.status_code) == 400
assert db.query(SysRoleMenu).count() == 0
@pytest.mark.parametrize('code,path,body', [
('system:role:list', '/role-management/role', {'remark': 'authorized'}),
('system:role:list', '/role-management/role/data-scope', {'dataScope': '4'}),
('system:user:list', '/users/u', {'roleIds': ['role'], 'postIds': ['post']}),
('system:post:list', '/posts/post/roles', {'roleIds': ['role']}),
('system:post:list', '/posts/post/users', {'userIds': ['u']}),
('system:menu:list', '/menus/all', {'menuName': '全部项目'}),
])
def test_authorized_tenant_administrators_can_manage_their_module(env, code, path, body):
db, _, client = env
grant(db, code)
assert client.put('/web/admin' + path, json=body).json()['code'] == 200
def test_web_and_mobile_ancestor_roots_remain_separate(env):
db, user, client = env
user['role'] = 'super_admin'
db.add_all([
SysMenu(id='mobile-root', tenant_no='t', parent_id='0', menu_name='Mobile'),
SysMenu(id='mobile-leaf', tenant_no='t', parent_id='mobile-root', menu_name='Mobile business'),
])
db.commit()
assert client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'mobile-leaf']}).json()['code'] == 200
assert {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)} == {'all', 'project', 'root', 'mobile-leaf', 'mobile-root'}
@pytest.mark.parametrize('method,path,body', [
('POST', '/qc-material-amount-auth/users', {'userId': 'u'}),
('DELETE', '/qc-material-amount-auth/users/u', None),
('POST', '/qc-material-amount-auth/users/batch-revoke', {'userIds': ['u']}),
])
def test_business_admin_cannot_self_bind_specialized_authorization_roles(env, method, path, body):
from app.api.qc import router as qc_router
_, _, client = env
client.app.include_router(qc_router)
assert client.request(method, '/web/admin' + path, json=body).status_code == 403
def test_specialized_authorization_manager_can_grant_and_revoke(env):
from app.api.qc import router as qc_router
from app.core.qc_material_amount_auth_constants import (
QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, QC_MATERIAL_AMOUNT_VIEW_ROLE_ID,
QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME, QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME,
)
from app.models.models import SysDept
db, _, client = env
client.app.include_router(qc_router)
grant(db, QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)
db.add_all([
SysDept(id='qc-dept', tenant_no='t', dept_name=QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME),
SysRole(id=QC_MATERIAL_AMOUNT_VIEW_ROLE_ID, tenant_no='t', role_name=QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME),
])
db.get(SysUser, 'u').dept_id = 'qc-dept'
db.commit()
assert client.post('/web/admin/qc-material-amount-auth/users', json={'userId': 'u'}).json()['code'] == 200
assert client.delete('/web/admin/qc-material-amount-auth/users/u').json()['code'] == 200
@@ -0,0 +1,176 @@
"""Project choices and writes share explicit team/all-project permission boundaries."""
import unittest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models.base import Base
from app.models.models import (BizCollectionProtocol, SysMenu, SysRole, SysRoleMenu,
SysUserRole, SysPost, SysPostRole, SysUserPost)
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember
from app.services import project_management as svc
from app.services.project_assignment import create_assignment
class ProjectSelectionPermissionsTest(unittest.TestCase):
def setUp(self):
self.engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.user = dict(user_id='member', role='admin', tenant_no='t')
kinds = ','.join(svc.COUNT_TYPES)
self.db.add_all([
ProjectTeam(id='own', tenant_no='t', team_name='Own', leader_user_id='leader'),
ProjectTeam(id='other', tenant_no='t', team_name='Other', leader_user_id='other'),
ProjectTeam(id='foreign', tenant_no='foreign', team_name='Foreign', leader_user_id='member'),
ProjectTeamMember(id='member', tenant_no='t', team_id='own', user_id='member'),
Project(id='own-p', tenant_no='t', project_no='OWN', project_name='Own', team_id='own', leader_user_id='other', status='active', biz_types=kinds),
Project(id='other-p', tenant_no='t', project_no='OTHER', project_name='Other', team_id='other', leader_user_id='other', status='active', biz_types=kinds),
Project(id='foreign-p', tenant_no='foreign', project_no='FOREIGN', project_name='Foreign', team_id='foreign', leader_user_id='member', status='active', biz_types=kinds),
])
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def grant(self, code, suffix='all', *, inherited=False):
self.db.add_all([
SysRole(id=suffix, tenant_no='t', role_name=suffix, data_scope='4'),
SysMenu(id=suffix, tenant_no='t', menu_name=suffix, menu_type='F', visible='0', perms=code),
SysRoleMenu(id=suffix, tenant_no='t', role_id=suffix, menu_id=suffix),
])
if inherited:
self.db.add_all([
SysPost(id=suffix, tenant_no='t', post_name=suffix, post_code=suffix),
SysPostRole(id=suffix, tenant_no='t', post_id=suffix, role_id=suffix),
SysUserPost(id=suffix, tenant_no='t', user_id='member', post_id=suffix),
])
else:
self.db.add(SysUserRole(id=suffix, tenant_no='t', user_id='member', role_id=suffix))
self.db.commit()
def choices(self, user=None, kind='cell_storage'):
return {r['id'] for r in svc.options(self.db, 't', user or self.user, kind)['list']}
def assert_forbidden(self, project_id='other-p', kind='cell_storage'):
with self.assertRaises(HTTPException) as caught:
create_assignment(self.db, 't', self.user, project_id, kind)
self.assertEqual(caught.exception.status_code, 403)
def test_project_leader_alone_does_not_bypass_team_membership(self):
self.db.get(Project, 'other-p').leader_user_id = 'member'
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
def test_team_leader_and_membership_union(self):
self.assertEqual(self.choices(dict(user_id='leader', role='admin')), {'own-p'})
self.db.add(ProjectTeamMember(id='second', tenant_no='t', team_id='other', user_id='member'))
self.db.commit()
self.assertEqual(self.choices(), {'own-p', 'other-p'})
self.db.get(ProjectTeamMember, 'second').del_flag = 1
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
def test_admin_with_no_team_has_no_choices(self):
self.assertEqual(self.choices(dict(user_id='outsider', role='admin')), set())
def test_assignment_edit_does_not_grant_all_projects(self):
self.grant('project:assignment:edit', 'assignment')
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
def test_all_permission_in_role_union_allows_all_business_types(self):
self.grant('project:assignment:edit', 'assignment')
self.grant('project:project:all')
for kind in svc.BUSINESS:
with self.subTest(kind=kind):
self.assertEqual(self.choices(kind=kind), {'own-p', 'other-p'})
self.assertEqual(create_assignment(self.db, 't', self.user, 'other-p', kind), 'other-p')
with self.assertRaises(HTTPException) as caught:
create_assignment(self.db, 't', self.user, 'foreign-p', 'cell_storage')
self.assertEqual(caught.exception.status_code, 404)
def test_all_permission_does_not_bypass_project_state_or_business_type(self):
self.grant('project:project:all')
self.db.get(Project, 'other-p').status = 'disabled'
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
with self.assertRaises(HTTPException):
create_assignment(self.db, 't', self.user, 'other-p', 'cell_storage')
self.db.get(Project, 'other-p').status = 'active'
self.db.get(Project, 'other-p').biz_types = 'basic_experiment'
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
with self.assertRaises(HTTPException):
create_assignment(self.db, 't', self.user, 'other-p', 'cell_storage')
def test_permission_revocation_is_effective_on_next_request(self):
self.grant('project:project:all')
self.assertEqual(self.choices(), {'own-p', 'other-p'})
for model, field, disabled, enabled in [
(SysRole, 'status', '0', '1'), (SysRoleMenu, 'del_flag', 1, 0),
(SysMenu, 'status', '0', '1'), (SysUserRole, 'del_flag', 1, 0),
]:
with self.subTest(model=model.__name__):
setattr(self.db.get(model, 'all'), field, disabled)
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
setattr(self.db.get(model, 'all'), field, enabled)
self.db.commit()
def test_post_inherited_all_permission_and_disabled_post(self):
self.grant('project:project:all', inherited=True)
self.assertEqual(self.choices(), {'own-p', 'other-p'})
self.assertEqual(create_assignment(self.db, 't', self.user, 'other-p', 'presign'), 'other-p')
self.db.get(SysPost, 'all').status = '0'
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
def test_filter_options_require_team_scope_and_preserve_business_scope(self):
self.grant('project:assignment:edit', 'assignment')
self.db.add_all([
BizCollectionProtocol(id='own-record', protocol_no='OWN', tenant_no='t', create_by='member', project_id='own-p'),
BizCollectionProtocol(id='other-record', protocol_no='OTHER', tenant_no='t', create_by='member', project_id='other-p'),
BizCollectionProtocol(id='hidden-record', protocol_no='HIDDEN', tenant_no='t', create_by='other', project_id='own-p'),
])
self.db.commit()
def filters(**kwargs):
return {r['id'] for r in svc.filter_options(self.db, 't', self.user, 'cell_storage', **kwargs)['list']}
self.assertEqual(filters(), {'own-p'})
self.grant('project:project:all')
self.assertEqual(filters(), {'own-p', 'other-p'})
self.assertEqual(svc.records(self.db, 't', self.user, 'cell_storage')['total'], 2)
# Mobile passes its stricter business query to the same options service.
mobile_scope = self.db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == 'own-record')
self.assertEqual(filters(business_scope=mobile_scope), {'own-p'})
def test_cross_group_reassignment_needs_both_permissions(self):
self.grant('project:assignment:edit', 'assignment')
self.db.add(BizCollectionProtocol(id='record', protocol_no='R', tenant_no='t', create_by='member', project_id='own-p'))
self.db.commit()
with self.assertRaises(HTTPException) as caught:
svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'record', 'other-p')
self.assertEqual(caught.exception.status_code, 403)
self.grant('project:project:all')
preview = svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'record', 'other-p')
self.db.get(SysRoleMenu, 'all').del_flag = 1
self.db.commit()
with self.assertRaises(HTTPException) as caught:
svc.assign(self.db, 't', self.user, 'cell_storage', 'record', 'other-p', 'reason', preview['records'])
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(self.db.get(BizCollectionProtocol, 'record').project_id, 'own-p')
def test_all_permission_does_not_grant_maintenance_or_management_actions(self):
self.grant('project:project:all')
for code in ('project:assignment:edit', 'project:project:edit', 'project:team:edit'):
with self.subTest(code=code), self.assertRaises(HTTPException):
svc.check_permission(self.db, 't', self.user, code)
def test_super_admin_retains_tenant_boundary(self):
self.assertEqual(self.choices(dict(user_id='root', role='super_admin')), {'own-p', 'other-p'})
@@ -0,0 +1,11 @@
-- Issue #13:在 project_top_menu 之后执行,可重复执行。
-- 独立功能权限,挂在“项目”一级节点下,授权时无需同时授予项目资料管理权限。
-- 不从归属维护或管理员角色继承授权,不写 sys_role_menu;由角色管理显式勾选。
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:all')), '全部项目', parent.id,
'F', '', '', 'project:project:all', 1, '0', '1', parent.tenant_no, 20, '', NOW(), NOW(), 0
FROM sys_menu parent
WHERE parent.perms='menu:project' 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:all');
@@ -0,0 +1,197 @@
-- Issue #13: MySQL 5.7 增量迁移。已在隔离 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;
-- 原业务表可能使用不同的默认排序规则;关联列必须与实际项目主键一致。
-- 同时修复已执行旧版迁移的列,不更改原表其他业务列或整库排序规则。
SELECT character_set_name, collation_name INTO @project_charset, @project_collation
FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='biz_project' AND column_name='id';
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,
CONCAT('ALTER TABLE biz_presign_order ADD COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
IF((SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_presign_order' AND column_name='project_id') <> @project_collation,
CONCAT('ALTER TABLE biz_presign_order MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' 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,
CONCAT('ALTER TABLE biz_collection_protocol ADD COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
IF((SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_collection_protocol' AND column_name='project_id') <> @project_collation,
CONCAT('ALTER TABLE biz_collection_protocol MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' 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,
CONCAT('ALTER TABLE biz_research_donation ADD COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
IF((SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_research_donation' AND column_name='project_id') <> @project_collation,
CONCAT('ALTER TABLE biz_research_donation MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' 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,
CONCAT('ALTER TABLE biz_pilot_application ADD COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
IF((SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_pilot_application' AND column_name='project_id') <> @project_collation,
CONCAT('ALTER TABLE biz_pilot_application MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' 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;
@@ -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);
@@ -0,0 +1,51 @@
-- Issue #13:在项目管理菜单种子之后执行,可重复执行。
-- 项目与市场使用同一 Web 虚拟根;不创建/改动移动根或移动菜单。
-- 保留四个叶子菜单及按钮 ID,仅迁移父级和路由;已有角色只补新祖先权限。
START TRANSACTION;
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(market.tenant_no, ':menu:project')), '项目', market.parent_id, 'M',
'/project', '/index/index', 'menu:project', 1, '1', '1', market.tenant_no,
COALESCE(market.order_num, 1) - 1, '', NOW(), NOW(), 0
FROM sys_menu market
WHERE market.perms='menu:market' AND market.del_flag=0
AND NOT EXISTS (SELECT 1 FROM sys_menu existing
WHERE existing.tenant_no=market.tenant_no AND existing.perms='menu:project');
UPDATE sys_menu leaf
JOIN sys_menu top_menu ON top_menu.tenant_no=leaf.tenant_no
AND top_menu.perms='menu:project' AND top_menu.del_flag=0
SET leaf.parent_id=top_menu.id,
leaf.path=CASE leaf.perms
WHEN 'project:team:list' THEN 'teams'
WHEN 'project:project:list' THEN 'projects'
WHEN 'project:statistics:list' THEN 'statistics'
WHEN 'project:assignment:edit' THEN 'assignments' END,
leaf.menu_name=CASE WHEN leaf.perms='project:assignment:edit'
THEN '历史归属维护' ELSE leaf.menu_name END,
leaf.update_time=NOW()
WHERE leaf.del_flag=0 AND leaf.perms IN (
'project:team:list', 'project:project:list', 'project:statistics:list', 'project:assignment:edit');
-- 仅拥有项目子菜单/按钮的角色补齐“项目”及原 Web 虚拟根,不给仅有市场权限的角色新增业务权限。
INSERT INTO sys_role_menu (id, role_id, menu_id, tenant_no, create_dept_id, create_time, update_time, del_flag)
SELECT DISTINCT MD5(CONCAT(grant_row.tenant_no, ':project-ancestor:', grant_row.role_id, ':', ancestor.id)),
grant_row.role_id, ancestor.id, grant_row.tenant_no, '', NOW(), NOW(), 0
FROM sys_role_menu grant_row
JOIN sys_role role_row ON role_row.id=grant_row.role_id AND role_row.tenant_no=grant_row.tenant_no
AND role_row.del_flag=0
JOIN sys_menu leaf ON leaf.id=grant_row.menu_id AND leaf.tenant_no=grant_row.tenant_no
AND leaf.del_flag=0 AND leaf.perms IN ('project:team:list', 'project:project:list',
'project:statistics:list', 'project:assignment:edit', 'project:team:edit', 'project:project:edit')
JOIN sys_menu top_menu ON top_menu.tenant_no=grant_row.tenant_no
AND top_menu.perms='menu:project' AND top_menu.del_flag=0
JOIN sys_menu ancestor ON ancestor.tenant_no=grant_row.tenant_no AND ancestor.del_flag=0
AND (ancestor.id=top_menu.id OR ancestor.id=top_menu.parent_id)
WHERE grant_row.del_flag=0
AND NOT EXISTS (SELECT 1 FROM sys_role_menu existing
WHERE existing.tenant_no=grant_row.tenant_no AND existing.role_id=grant_row.role_id
AND existing.menu_id=ancestor.id AND existing.del_flag=0)
ON DUPLICATE KEY UPDATE del_flag=0, update_time=NOW();
COMMIT;
+8
View File
@@ -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 })
}
@@ -0,0 +1,77 @@
<template>
<section class="market-form-section">
<h2>{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
<template v-if="editing">
<van-cell title="所属项目" :value="projectLabel(record)" />
<van-cell title="项目组" :value="record.teamName || '—'" />
<van-cell label="归属调整请使用 Web 项目归属维护" />
</template>
<template v-else>
<PresignSelectField
:model-value="modelValue || ''"
:options="selectOptions"
:disabled="disabled || loading || Boolean(error)"
:loading="loading"
searchable
title="所属项目"
:placeholder="filter ? '全部项目' : '请选择所属项目'"
@update:model-value="emit('update:modelValue', $event)"
/>
<van-cell v-if="loading" title="正在加载项目…" />
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
</template>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { fetchProjectOptions } from '../../api/market.js'
import { projectLabel, projectOptions, projectValidation } from '../project-assignment.js'
import PresignSelectField from './PresignSelectField.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
bizType: { type: String, required: true },
editing: { type: Boolean, default: false },
filter: { type: Boolean, default: false },
context: { type: Object, default: null },
disabled: { type: Boolean, default: false },
record: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])
const options = ref([])
const required = ref(false)
const loading = ref(true)
const error = ref('')
let controller
const selectOptions = computed(() => [
...(!required.value || props.filter ? [{ text: props.filter ? '全部项目' : '暂不归属', value: '' }] : []),
...options.value
])
async function load() {
controller?.abort()
if (props.editing) { loading.value = false; return }
const current = new AbortController()
controller = current
loading.value = true
error.value = ''
options.value = []
try {
const data = await fetchProjectOptions(props.bizType, { signal: current.signal, filter: props.filter, context: props.context })
if (controller !== current) return
options.value = projectOptions(data)
required.value = Boolean(data.required)
} catch (cause) {
if (controller === current && cause?.name !== 'AbortError') error.value = cause?.message || '请稍后重试'
} finally {
if (controller === current) loading.value = false
}
}
watch(() => [props.bizType, props.editing], (next, previous) => {
if (previous && next[0] !== previous[0] && !props.editing) emit('update:modelValue', '')
load()
}, { immediate: true })
onBeforeUnmount(() => { controller?.abort(); controller = null })
defineExpose({ validate: () => projectValidation({ editing: props.editing || props.filter, loading: loading.value, error: error.value, required: required.value, projectId: props.modelValue }) })
</script>
+1
View File
@@ -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') {
@@ -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),
@@ -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 [
'样本编码', '当月核量号', '实验室流转编号', '废弃原因', '样本类型',
'采集地区', '采集医院', '分娩时间', '采集时间', '市场专员', '地区经理',
@@ -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), [
'样本编码', '登记日期', '母亲电话', '母亲身份证号', '父亲姓名',
'所属项目', '项目组', '样本编码', '登记日期', '母亲电话', '母亲身份证号', '父亲姓名',
'新生儿性别', '分娩方式', '新生儿体重(克)', '采集地区', '采集医院',
'市场专员', '样本采集时间', '创建时间'
])
@@ -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)) },
@@ -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') },
@@ -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)
}
@@ -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: '',
@@ -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) },
{
@@ -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' },
@@ -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' }), '')
})
@@ -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 ''
}
@@ -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')
})
@@ -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 ['优惠价', '原价', '存储年限']) {
@@ -12,6 +12,9 @@
</van-empty>
</div>
<template v-else>
<ProjectAssignmentField v-if="context.module === 'protocol'" ref="projectAssignment"
v-model="formData.projectId" :biz-type="formData.source" :editing="mode === 'edit'"
:record="formData" :disabled="fieldsLocked" @update:model-value="dirty = true" />
<MarketFormSection
v-for="section in leadingSections"
:key="section.key"
@@ -186,6 +189,8 @@
</template>
<script setup>
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
const projectAssignment = ref(null)
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
@@ -1019,6 +1024,7 @@ function buildPayload() {
}
if (context.value.module === 'protocol') {
const fields = pickConfiguredPayload(visibleSections.value, formData.value)
if (mode.value === 'create' && formData.value.projectId) fields.projectId = formData.value.projectId
const withLinks = mergeProtocolCustomerLinks(fields, formData.value.customer)
const withSamples = buildProtocolSamplesPayload(
withLinks,
@@ -1043,6 +1049,8 @@ function buildPayload() {
}
async function submit() {
const projectError = projectAssignment.value?.validate()
if (projectError) { showToast(projectError); return }
if (submitting.value || uploadPending.value > 0) return
const transferMessage = context.value.module === 'protocol'
&& isProtocolV2.value
@@ -6,6 +6,8 @@
:show-header="false"
>
<div class="market-list__filters">
<ProjectAssignmentField v-if="context.module === 'protocol'" v-model="listFilters.projectId"
:biz-type="listStatus" filter :context="context" @update:model-value="search" />
<div
v-if="listFilterTabs.length"
class="market-source-tabs"
@@ -152,6 +154,7 @@
</template>
<script setup>
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
@@ -9,6 +9,9 @@
</van-empty>
<template v-else>
<ProjectAssignmentField ref="projectAssignment" v-model="form.projectId"
biz-type="presign" :editing="mode === 'edit'" :record="form"
:disabled="formDisabled" @update:model-value="markDirty" />
<section class="presign-form-section">
<h2>预签信息</h2>
<div class="presign-form-grid">
@@ -223,6 +226,8 @@
</template>
<script setup>
import ProjectAssignmentField from '../../../market/components/ProjectAssignmentField.vue'
const projectAssignment = ref(null)
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
showConfirmDialog,
@@ -697,6 +702,8 @@ async function withdrawTransfer(transfer) {
}
function validationMessage() {
const projectError = projectAssignment.value?.validate()
if (projectError) return projectError
if (!String(form.value.motherName || '').trim()) return '请填写母亲姓名'
if (!Array.isArray(form.value.sampleTypeIds) || !form.value.sampleTypeIds.length) {
return '请至少选择一个样本类型'
@@ -21,6 +21,7 @@
</div>
</div>
<ProjectAssignmentField v-model="projectId" biz-type="presign" filter :context="context" @update:model-value="reload" />
<main class="presign-order-page__content">
<div v-if="loading && !items.length" class="presign-order-state">
<van-loading color="#0a9f6d">加载中…</van-loading>
@@ -76,6 +77,8 @@
</template>
<script setup>
import ProjectAssignmentField from '../../../market/components/ProjectAssignmentField.vue'
const projectId = ref('')
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
@@ -164,6 +167,7 @@ async function loadPage(reset, retry = false) {
buildPresignListParams({
keyword: keyword.value.trim(),
orderStatus: orderStatus.value,
projectId: projectId.value,
page: started.page,
pageSize: PAGE_SIZE
}),
@@ -291,6 +295,7 @@ watch(
() => {
keyword.value = ''
orderStatus.value = ''
projectId.value = ''
collectionActions.value = []
listState.value = createMarketListState()
if (!childActive.value) reload()
@@ -0,0 +1,59 @@
# 实现设计与接口契约
## 模块边界
新增 models/project_management.py、services/project_management.py、api/project_management.py。现有四种主表(预签约、采集协议、科研捐赠、中试/基础实验申请)增加可空 project_id,不向下游样本表复制归属。不更改已有课题名称 project_title。
项目组包含 teamName、leaderUserId、members[{id?,userId}]、regions[{id?,regionId}]、hospitals[{id?,hospitalId}]、enabled、remark。子行按 sync_child_rows 与自然键更新。负责人应计入可选项目成员判断。项目包含 projectNo、projectName、teamId、leaderUserId、bizTypes[]、startDate、endDate、status(pending/active/completed/disabled)、remark。编号服务端生成。项目已有业务后禁止换组;引用中的项目和组只能停用,不能删除。所有引用租户内校验。
## 权限与兼容
管理功能使用 project:team:list/edit、project:project:list/edit、project:statistics:list、project:assignment:edit 菜单权限,复用有效角色并集;super_admin 仍限定本次认证租户。跨组选择使用独立功能权限 `project:project:all`(全部项目),在现有角色菜单树的“项目”下分配,不默认授予现有角色。`project:assignment:edit` 仅允许维护操作,不能代替全部项目权限;普通 admin 不自动放开。super_admin 沿用系统全部权限语义,但始终受当前认证租户限制。订单数据统计、明细及归属调整应用原有 apply_data_scope;项目成员身份不扩权。
授权维护不能由普通业务管理员自授:系统用户、角色、职位、菜单的变更接口分别严格校验 `system:user:list`、`system:role:list`、`system:post:list`、`system:menu:list`,沿用有效角色并集与 super_admin 全权限语义。角色菜单和数据范围读取校验管理权限及目标租户;通用角色/人员下拉保留原有读取用途。目标和关联 ID 均验证认证租户,super_admin 在这些接口也不跨租户。角色菜单保存补齐每个已选节点的同租户祖先(包含各自 Web/移动虚拟根),拒绝断链或循环,不自动勾选兄弟/子权限。质检金额专用角色的授予/撤销/批量撤销同样使用其既有专用管理权限严格校验,阻止另一条用户角色绑定旁路。
新单据项目必填由 PROJECT_ASSIGNMENT_REQUIRED 配置控制,默认 false 供多端滚动升级;全部入口更新且项目资料准备好后由运维设为 true。数据库历史 NULL 继续合法。编辑未传 projectId 时保留旧值;普通表单不可改变已有非空归属,历史补录与归属调整统一从维护入口审计。未改变归属的历史单据不重验成员或启用状态。
公共服务契约:
```python
validate_assignment(db, tenant_no, user, project_id, biz_type, *, current_project_id=None, required=False) -> str | None
project_brief(db, tenant_no, project_id) -> dict # projectId/projectName/teamId/teamName
```
用户 user 使用认证上下文字典。调用方在创建时传 required=settings.PROJECT_ASSIGNMENT_REQUIRED;传入项目必须存在、启用、组启用、业务类型匹配且用户为项目组负责人/有效成员或有全部项目权限(仅项目负责人身份不替代项目组成员资格)。普通更新若归属不同返回“请通过项目归属维护调整”。转换复制来源项目,不按转换操作人重新分配;转换的目标业务类型仍允许保留来源项目历史归属。
## HTTP契约
均复用 success_response。管理前缀 /web/admin/project-management。
- GET /teams:page/pageSize/keyword/leaderUserId/enabled,返回 {list,total};POST /teams;GET/PUT/DELETE /teams/{id}。
- GET /projects:page/pageSize/keyword/teamId/status,返回 {list,total};POST /projects;GET/PUT/DELETE /projects/{id}。
- GET /references:返回 {users:[{id,name}],regions:[{id,name}],hospitals:[{id,name,regionId}]},仅管理权限。
- GET /statistics:groupBy=team|project、teamId/projectId/startDate/endDate,返回 {items:[{id,name,teamName,cell_storage,research_donation,pilot_service,basic_experiment}],totals:{含未归属的四类业务总量},unassignedTotals:{四类业务计数},presignTotal}。
- GET /records:bizType=presign|cell_storage|research_donation|pilot_service|basic_experiment,page/pageSize/teamId/projectId/unassigned/status/keyword/startDate/endDate,返回 {list:[{id,bizType,bizNo,status,projectId,projectName,teamId,teamName,createTime}],total,statusCounts:{原状态码:数量}}。
- POST /assignments/preview:{bizType,id,projectId},返回 {records:[{bizType,id,bizNo,projectId,projectName}]}。
- POST /assignments:{bizType,id,projectId,reason,expectedAssignments:[{bizType,id,projectId}]};锁定并重新校验关联集合及旧归属后一次事务提交,记录审计;不匹配返回冲突,全部单据须有权操作。
- GET /assignments/history:bizType/id,返回 {list},每行包含 oldProjectName/newProjectName/reason/operatorName/createTime。
- GET /project-management/options(独立认证路由,不要求管理菜单):bizType/keyword/currentProjectId,返回 {list:[{id,projectName,projectNo,teamId,teamName,disabled}],required}。currentProjectId不授权读取任意项目;仅补充本人已有权限单据的历史显示,若难以证明则由单据响应的名称回显,不返回额外敏感资料。
业务既有返回新增 projectId/projectName/teamId/teamName(嵌套单据放到 basic);列表支持 projectId/teamId 过滤。认证租户不由客户端传入。前端选择器复用统一 options 接口。
## 关联与统计
预签约转协议继承归属;存储/捐赠互转继承。归属维护将同租户的预签约→协议→捐赠转换记录组成关联集合,循环防重,锁定顺序固定;任一关联记录不可访问则拒绝整个操作,返回信息不暴露无权查看单据。历史软删除记录不参与活跃统计,不复活。
正式存储按协议、捐赠按主单、中试和基础实验按对应 biz_type 申请单计数;预签约另列,不把四类数量称为独立客户数。日期按创建日期(起日含、结束日次日不含),状态显示当前状态。查询先按租户、软删除、原有数据权限过滤,再聚合,不联接子行产生重复;金额不在本期。
## 界面与验证
Web P01/P02/P09,参考 system/unit/index.vue、cell-storage/protocol/ProtocolFormDialog.vue,复用 panel-top/list-table/list-wrap/list-pager-bar/dialog-form-layout 等。移动复用 src/market 契约与既有选择器,保留 Token,不借任务全局换色。
自动化测试使用隔离 SQLite 内存数据库,运行验收使用独立 MySQL 5.7 合成库;不连接或修改真实业务库。用户已明确授权本任务自行切换 backend/web-admin 服务,需保留原配置和运行来源记录。迁移验证覆盖首次新增、排序规则兼容修正及重复执行。最终服务/真机环境不可用时报告验证缺口,不标记完成合并。
### 查询筛选与新建选择的区别
公共 options 默认用于新建/归属调整,校验项目启用、支持业务类型和项目组成员资格或全部项目权限。`usage=filter` 同样限制项目组/全部项目范围,再按现有可见业务主单返回历史项目(包含完成/停用),不扩大订单数据权限。成员退出后不再作为可选项;已有单据的项目名称仍由单据响应回显,普通编辑不清空历史归属。
移动列表使用 `/mini/market/{specialist|manager}/project-options?bizType=...`,复用对应菜单权限及现场数据范围;不借公共筛选扩大现场范围。
普通创建不能伪造来源预签约/转换来源,普通更新不得改来源关联;只有受控转换入口维护来源图。
@@ -0,0 +1,9 @@
# 项目组与项目业务统计
关联工单:https://gitea.aichengyan.com/cykj/zkxw/issues/13
为四类业务增加稳定的项目归属,支持按项目组、项目统计数量与当前状态。区域和医院仅作为项目组资料,不控制业务地区、医院、权限或默认值。
影响 backend、web-admin、mobile-oa-h5 已有预签约及存储/捐赠入口;mobile-h5 只验证现有 WebView 联动。不扩展预算、金额分摊、绩效、移动项目管理入口和业务审批。
用户于 2026-11-02 明确确认 [Figma v1](https://www.figma.com/design/wOQLDAhr1gdlQEXnVaFO9S?node-id=3-17),并要求参考现有界面样式。布局交互依原型;实现复用各端现有组件、全局样式和主题,不复制一套原型 CSS。
@@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: 组织信息与业务地点独立
系统 SHALL 维护项目组负责人、成员及区域医院资料,区域医院仅为信息记录。
#### Scenario: 修改负责医院
- **WHEN** 项目组医院发生修改
- **THEN** 已有订单、业务地区医院候选与权限均保持原有行为。
### Requirement: 稳定且受控的项目归属
系统 SHALL 在业务主单保存项目归属,限制同租户授权选择,人员调整不得迁移历史订单。
#### Scenario: 转换单据
- **WHEN** 预签约转协议或存储捐赠互转
- **THEN** 目标继承来源项目,记录来源关联。
#### Scenario: 历史单据编辑
- **WHEN** 已停用项目的历史单据修改其他字段
- **THEN** 保留其项目归属,不因项目停用拒绝无关编辑。
#### Scenario: 归属纠正
- **WHEN** 有授权的管理员预览并确认调整关联单据归属
- **THEN** 服务端核对权限、关联集合及旧值,统一事务更新并留审计;不一致时拒绝。
### Requirement: 权限内统计
系统 SHALL 按认证租户和现有数据权限统计四类业务,预签约另计,未归属单据单列。
#### Scenario: 汇总钻取
- **WHEN** 用户打开某项目的某类业务明细
- **THEN** 明细总数与相同筛选条件汇总一致,不因子行联接重复计数。
### Requirement: 沿用现有界面样式
Web与移动 SHALL 复用同端现有组件、布局模式和全局样式。
#### Scenario: 新增管理界面
- **WHEN** 实现已确认Figma布局
- **THEN** 列表、弹窗、按钮、标签及选择器遵循现有界面,不引入平行主题。
### Requirement: 所属项目范围与独立全部项目权限
系统 SHALL 在 Web 和移动的所属项目候选及后端归属写入中使用同一范围:本人作为项目组负责人或有效成员所在组的项目,或显式 `project:project:all` 授权下的当前租户项目。多组、有效角色及职位继承角色取并集。普通管理员、项目负责人或归属维护权限本身不得绕过组成员限制;super_admin 沿用系统全部权限语义且不得跨租户。
#### Scenario: 无跨组权限
- **WHEN** 用户未加入某项目组且不是该组负责人
- **THEN** 下拉框不返回其项目,伪造项目 ID 提交被后端拒绝;仅担任该项目负责人不构成组成员资格。
#### Scenario: 分配或撤销全部项目
- **WHEN** 角色或继承职位授予全部项目权限,或该授权被撤销/停用
- **THEN** 后续候选和写入即时按有效授权判定,其他租户、停用项目和不支持业务类型仍不可新选。
#### Scenario: 历史归属与数据权限
- **WHEN** 用户调组或项目停用后查看、编辑历史订单
- **THEN** 原项目仍可回显且无关编辑保留归属;筛选候选取项目范围与原业务数据范围交集,全部项目权限不扩大订单查看和维护权限。
#### Scenario: 禁止业务用户自行授权
- **WHEN** 普通业务管理员直接调用角色菜单、用户角色/职位、职位角色/用户或菜单权限码等变更接口
- **THEN** 缺少对应系统管理权限时返回拒绝;有管理权限者也只能变更当前租户目标和关联,用户账号重置/启停同样受管理权限保护。
#### Scenario: 角色菜单保存
- **WHEN** 管理员只勾选全部项目功能权限
- **THEN** 保存其同租户祖先但不授予其他功能,断链/循环/跨租户节点导致整次保存拒绝。
@@ -0,0 +1,41 @@
# 项目业务归属实施计划
**目标:** 实现工单 #13 已批准的项目归属与数量/状态统计。
**架构:** 新管理模块统一校验与统计;现有业务显式接入;前端复用已有样式。
**技术栈:** FastAPI/SQLAlchemy/Pydantic、Vue3/Element Plus、Vue3/Vant。
用户追加所属项目权限要求:将跨组选择从归属维护权限分离为 `project:project:all`(全部项目),默认仅项目组负责人/有效成员所在组内项目;Web与移动共用后端范围校验。
- [x] 在 `backend/tests/test_project_selection_permissions.py` 增加成员/多组/无组、独立全部项目权限、多角色及职位继承、撤权、跨租户和越权提交用例,先执行确认失败。
- [x] 在 `backend/app/services/project_management.py` 统一候选范围,赋值校验改用独立权限;筛选仍叠加原订单数据范围,历史回显与转换继承保持原有规则。
- [x] 增加幂等 `docs/sql/migrations/2026-11-02-project_all_permission.sql`,在项目一级节点下提供可分配的“全部项目”功能权限,不给现有角色自动授权;同步设计、规格及后端部署入口。
- [x] 封堵实际验收发现的权限自授入口:系统用户/角色/职位/菜单变更接口检查对应严格管理权限,目标与引用按租户校验;角色菜单保存补齐祖先、拒绝断链/循环。`backend/tests/test_project_permission_administration.py` 覆盖拒绝、允许、租户和祖先边界。
- [x] 运行项目权限与现有归属回归、字段一致性检查;隔离 MySQL 验证迁移和 HTTP 权限边界、浏览器角色配置与下拉框;独立审查后提交推送,继续保留待真机验收状态。
用户追加菜单调整:新增与市场/生产/质检平级的“项目”,四个项目管理入口迁入其中。沿用现有顶部导航组件及视觉样式,不新增业务页面。实施顺序:前后端菜单树同步 → 增量菜单迁移(保留叶子 ID/角色授权、补齐新祖先)→ 隔离 MySQL 幂等与角色边界验证 → 浏览器导航/旧地址跳转及构建验收。仍保留待真机验收、不合并的交付状态。
菜单调整验收:两租户 MySQL 迁移各重复执行通过,四子页/原 ID/祖先权限/无额外授权/移动根不变/无重复均核对;后端17项、Web5项测试和定向 ESLint 通过,Web生产构建通过。浏览器确认项目平级、市场移除四菜单及旧地址携带查询参数跳转;独立审查无P1/P2。
列表样式补齐:四个主列表及业务明细、关联预览、归属审计表格增加序号,分页列表按页码连续编号;业务统计、历史归属维护及业务明细的检索按钮复用全局样式右对齐。浏览器逐页和弹窗检查通过,Web5项测试、定向 ESLint、生产构建及独立源码审查通过;仍保留待真机验收状态。
- [x] 读取完整工单、确认范围与Figma v1,创建隔离分支。
- [x] 固定 design.md 接口契约;按文件边界划分任务。
- [x] 后端新模块:先写 tenant/member/disabled/statistics/assignment 链路测试,运行确认失败;实现 models/project_management.py、services/project_management.py、api/project_management.py、增量SQL及菜单种子;运行测试验证。
- [x] 既有后端接入:四主表 project_id、创建/更新 Schema、CRUD、移动服务、转换继承、列表筛选、名称回显;补多入口和转换回归测试。
- [x] Web:新增 api/project-management.ts、共享 ProjectSelect.vue、四管理页面/路由/权限;接入五类主单表单列表详情;使用现有全局样式;验证组件数据映射及构建。
- [x] 移动OA:已有预签约和存储/捐赠选择、提交、列表/详情;使用现有市场组件及请求工具;测试无项目、历史停用、失败重试、不清空旧值,运行构建。
- [x] 契约联调:逐项核对 Create/Update/CRUD/ORM/返回/前端字段,运行字段一致性检查并区分既有失败。
- [x] 独立规格审查、代码质量审查;修复并复验,记录未完成的迁移、运行环境及真机验证。
- [ ] 核对目标分支,完成实际验收及集成验证后提交推送、PR、合并V2.0、同步主目录、回写关单;未满足条件不合并。
预期命令:`PYTHONPATH=backend SECRET_KEY=<测试专用随机值> backend/venv/bin/python -m pytest backend/tests/test_project_management.py backend/tests/test_project_assignment_integration.py`;`python backend/scripts/check_field_consistency.py`;`pnpm --dir web-admin build`;`npm --prefix mobile-oa-h5 run build`。虚拟环境实际复用主目录已安装解释器,测试数据库通过环境显式隔离。
当前验证:新增后端 44 项、移动市场前端 353 项、Web 5 项通过;Web/移动生产构建通过。
较大范围后端回归 357 通过、3 个既有失败(缺失历史迁移、运费子行软删除、旧测试 SimpleNamespace refresh);原 HEAD 已复现后两项,缺文件与仓库现状一致。
Web 类型检查基线和改动版均 413 条诊断,无新增文件/错误码组合;字段一致性检查为 5 项既有警告。以上不代表全仓测试通过。
独立源码审查已修复并复核来源链保护、并发引用检查、统计聚合、历史候选及空筛选条件,无剩余 P1/P2。
用户于 2026-11-02 明确授权自行切换服务,任务版本已在独立 MySQL 5.7.24 合成库运行,原业务数据库未修改。真实 HTTP/MySQL 62 项断言通过,覆盖五类主单新增、三种转换继承、四业务统计、租户/权限、并发归属冲突及审计;结构与菜单迁移重复执行通过,另验证首次 ADD 与旧迁移修正。
浏览器已验收项目组/项目创建、Web/移动预签约关联保存、统计钻取及归属维护;375/414 宽度移动 H5 操作通过。验收中修复前端按钮权限机制、明细弹窗全局样式作用域及 MySQL 关联列排序规则。独立复审无 P1/P2。
仍缺微信真机 WebView 联动、集成候选环境验证及合并后运行环境恢复;不能以移动浏览器模拟尺寸代替真机验收,当前不标记完成或合并。
所属项目权限追加验收:121项相关测试通过;测试先复现跨组及授权配置旁路再修复。隔离MySQL/HTTP共41项检查通过(29项项目/迁移+12项授权闭环),含3租户幂等种子、不自动授予角色、5类主单越权拒绝、移动创建拒绝、同会话授权/撤权、8条自授入口拒绝;浏览器确认角色管理可独立勾选全部项目且保存补祖先。独立复审无P1/P2。字段一致性仍为5项既有警告;本轮无前端源码变更,不重复前端构建,微信真机及集成验收继续待办。
+2
View File
@@ -14,6 +14,8 @@ export type CollectionProtocolRow = Record<string, unknown>
export function fetchCollectionProtocolList(params?: {
page?: number
pageSize?: number
projectId?: string
teamId?: string
keyword?: string
protocolStatus?: string
createTimeStart?: string
+2
View File
@@ -10,6 +10,8 @@ type PageList<T> = {
export function fetchPresignOrderList(params?: {
page?: number
pageSize?: number
projectId?: string
teamId?: string
keyword?: string
orderStatus?: string
}) {
+66
View File
@@ -0,0 +1,66 @@
import request from '@/utils/http'
export type BizType =
| 'presign'
| 'cell_storage'
| 'research_donation'
| 'pilot_service'
| 'basic_experiment'
export const bizLabels: Record<BizType, string> = {
presign: '预签约',
cell_storage: '细胞存储',
research_donation: '科研捐赠',
pilot_service: '中试服务',
basic_experiment: '基础实验'
}
export type ProjectOption = {
id: string
projectName: string
projectNo: string
teamId: string
teamName: string
disabled?: boolean
}
export type ProjectRecord = Record<string, any>
const base = '/web/admin/project-management'
export const fetchProjectOptions = (params: {
bizType?: BizType
keyword?: string
usage?: 'filter'
currentProjectId?: string
}) =>
request.get<{ list: ProjectOption[]; required: boolean }>({
url: '/project-management/options',
params
})
export const fetchProjectReferences = () =>
request.get<{ users: ProjectRecord[]; regions: ProjectRecord[]; hospitals: ProjectRecord[] }>({
url: `${base}/references`
})
export const fetchManagementList = (kind: 'teams' | 'projects', params: Record<string, unknown>) =>
request.get<{ list: ProjectRecord[]; total: number }>({ url: `${base}/${kind}`, params })
export const fetchManagementDetail = (kind: 'teams' | 'projects', id: string) =>
request.get<ProjectRecord>({ url: `${base}/${kind}/${id}` })
export const saveManagement = (kind: 'teams' | 'projects', data: ProjectRecord, id?: string) =>
id
? request.put({ url: `${base}/${kind}/${id}`, data })
: request.post({ url: `${base}/${kind}`, data })
export const deleteManagement = (kind: 'teams' | 'projects', id: string) =>
request.del({ url: `${base}/${kind}/${id}` })
export const fetchProjectStatistics = (params: Record<string, unknown>) =>
request.get<{
items: ProjectRecord[]
totals: ProjectRecord
unassignedTotals: ProjectRecord
presignTotal: number
}>({ url: `${base}/statistics`, params })
export const fetchProjectRecords = (params: Record<string, unknown>) =>
request.get<{ list: ProjectRecord[]; total: number; statusCounts: Record<string, number> }>({
url: `${base}/records`,
params
})
export const previewAssignment = (data: ProjectRecord) =>
request.post<{ records: ProjectRecord[] }>({ url: `${base}/assignments/preview`, data })
export const saveAssignment = (data: ProjectRecord) =>
request.post({ url: `${base}/assignments`, data })
export const fetchAssignmentHistory = (params: ProjectRecord) =>
request.get<{ list: ProjectRecord[] }>({ url: `${base}/assignments/history`, params })
+2
View File
@@ -14,6 +14,8 @@ export type ResearchDonationRow = Record<string, unknown>
export function fetchResearchDonationList(params?: {
page?: number
pageSize?: number
projectId?: string
teamId?: string
keyword?: string
donationStatus?: string
createTimeStart?: string
@@ -0,0 +1,107 @@
<template>
<div style="width: 100%">
<el-select
style="width: 100%"
:model-value="modelValue"
:disabled="disabled"
:loading="loading"
filterable
clearable
:placeholder="team ? '请选择项目组' : '请选择项目'"
@update:model-value="$emit('update:modelValue', $event || '')"
>
<el-option
v-if="modelValue && !displayOptions.some((o) => o.id === modelValue)"
:value="modelValue"
:label="projectName || modelValue"
disabled
/>
<el-option
v-for="option in displayOptions"
:key="option.id"
:value="option.id"
:label="team ? option.teamName : `${option.projectName} · ${option.teamName}`"
:disabled="!filter && option.disabled"
/>
</el-select>
<div v-if="error" class="form-tip"
>项目选项加载失败<el-button link type="primary" @click="retry++"
><el-icon><RefreshRight /></el-icon>重试</el-button
></div
>
</div>
</template>
<script setup lang="ts">
import { projectOptionsParams } from '@/utils/project-management'
import { RefreshRight } from '@element-plus/icons-vue'
import {
fetchProjectOptions,
bizLabels,
type BizType,
type ProjectOption
} from '@/api/project-management'
const props = defineProps<{
filter?: boolean
team?: boolean
modelValue?: string
bizType?: BizType
disabled?: boolean
projectName?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
required: [value: boolean]
ready: [value: boolean]
}>()
const options = ref<ProjectOption[]>([])
const displayOptions = computed(() =>
props.team
? [
...new Map(
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
).values()
]
: options.value
)
const loading = ref(false)
const error = ref(false),
retry = ref(0)
let version = 0
watch(
() => [props.bizType, props.disabled, props.filter, retry.value],
async () => {
const requestVersion = ++version
if (props.disabled) {
loading.value = false
return
}
loading.value = true
error.value = false
emit('ready', false)
try {
const results = await Promise.all(
(props.bizType ? [props.bizType] : (Object.keys(bizLabels) as BizType[])).map((bizType) =>
fetchProjectOptions(projectOptionsParams(bizType, props.filter))
)
)
const result = {
list: [
...new Map(
results.flatMap((result) => result.list).map((option) => [option.id, option])
).values()
],
required: results.some((result) => result.required)
}
if (requestVersion !== version) return
options.value = result.list || []
emit('required', result.required)
emit('ready', true)
} catch {
if (requestVersion === version) error.value = true
} finally {
if (requestVersion === version) loading.value = false
}
},
{ immediate: true }
)
</script>
+41 -1
View File
@@ -1,10 +1,50 @@
import type { AppRouteRecord } from '@/types/router'
/**
* 前端路由(5 个一级菜单分组:市场、生产、质检、运营支撑、系统管理)
* 前端路由(项目、市场、生产、质检、运营支撑、系统管理)
* 与后端菜单对齐时请同步 `backend/app/api/menu.py`。
*/
export const sqxpAdminRoutes: AppRouteRecord[] = [
{
id: 'TOP00000000000000000000000000006',
name: 'Project',
path: '/project',
component: '/index/index',
meta: {
title: '项目',
icon: 'ri:folder-chart-line',
keepAlive: false,
isTopMenu: true,
menuCode: 'menu:project',
roles: ['admin']
},
children: [
{
name: 'ProjectManagementTeams',
path: 'teams',
component: '/project-management/teams/index',
meta: { title: '项目组管理', keepAlive: true, menuCode: 'project:team:list' }
},
{
name: 'ProjectManagementProjects',
path: 'projects',
component: '/project-management/projects/index',
meta: { title: '项目管理', keepAlive: true, menuCode: 'project:project:list' }
},
{
name: 'ProjectManagementStatistics',
path: 'statistics',
component: '/project-management/statistics/index',
meta: { title: '业务统计', keepAlive: true, menuCode: 'project:statistics:list' }
},
{
name: 'ProjectManagementAssignments',
path: 'assignments',
component: '/project-management/assignments/index',
meta: { title: '历史归属维护', keepAlive: true, menuCode: 'project:assignment:edit' }
}
]
},
// ═══════════════════════════════════════════════════════════════════════════
// 1. 市场 (menu:market)
// 二级菜单:存储服务、科研捐赠、市场基础信息、中试服务、基础实验
@@ -11,6 +11,14 @@ import { AppRouteRecordRaw } from '@/utils/router'
* 2、静态路由不管是否登录都可以访问
*/
export const staticRoutes: AppRouteRecordRaw[] = [
...['teams', 'projects', 'statistics', 'assignments'].map((section) => ({
path: `/market/project-management/${section}`,
redirect: (to: { query: Record<string, any>; hash: string }) => ({
path: `/project/${section}`,
query: to.query,
hash: to.hash
})
})),
{
path: '/auth/login',
name: 'Login',
+60
View File
@@ -0,0 +1,60 @@
import type { BizType, ProjectRecord } from '@/api/project-management'
/** Drill-down uses the last successful statistics query, never unsaved filter edits. */
export function buildDrillFilters(
applied: ProjectRecord,
row: ProjectRecord,
bizType: BizType
): ProjectRecord {
const { groupBy, ...filters } = applied
const result = { ...filters, bizType, unassigned: row.id === 'unassigned' } as ProjectRecord
if (!result.unassigned && row.id !== 'total') {
result[groupBy === 'team' ? 'teamId' : 'projectId'] = row.id
}
return result
}
export function assignmentSnapshot(records: ProjectRecord[]) {
return records.map(({ bizType, id, projectId }) => ({
bizType,
id,
projectId: projectId ?? null
}))
}
/** Write bodies intentionally exclude response-only labels, timestamps and generated numbers. */
export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecord): ProjectRecord {
const fields =
kind === 'teams'
? ['teamName', 'leaderUserId', 'enabled', 'remark']
: [
'projectName',
'teamId',
'leaderUserId',
'bizTypes',
'startDate',
'endDate',
'status',
'remark'
]
const data = Object.fromEntries(
fields.filter((key) => form[key] !== undefined).map((key) => [key, form[key]])
)
if (kind === 'teams') {
for (const [key, field] of [
['members', 'userId'],
['regions', 'regionId'],
['hospitals', 'hospitalId']
]) {
data[key] = (form[key] || []).map((row: ProjectRecord) => ({
...(row.id ? { id: row.id } : {}),
[field]: row[field]
}))
}
}
return data
}
export function projectOptionsParams(bizType: BizType, filter = false) {
return filter ? { bizType, usage: 'filter' as const } : { bizType }
}
@@ -23,7 +23,23 @@
scroll-to-error
>
<div class="form-grid-3">
<el-form-item label="业务日期">
<el-form-item
label="所属项目"
prop="projectId"
:required="!!!props.orderId && projectRequired"
><ProjectSelect
v-model="form.projectId"
biz-type="presign"
:disabled="!!props.orderId"
:project-name="form.projectName"
@required="projectRequired = $event"
@ready="projectReady = $event"
/>
<div v-if="!!props.orderId" class="form-tip">
{{ form.teamName || '未归属项目组' }} · 归属调整请使用项目归属维护
</div></el-form-item
>
<el-form-item label="业务日期">
<el-date-picker
v-model="form.orderDate"
type="date"
@@ -388,6 +404,9 @@
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectRequired = ref(false)
const projectReady = ref(false)
import { computed, reactive, ref, watch } from 'vue'
import { Plus, Close, Check, Document, Delete, RefreshLeft } from '@element-plus/icons-vue'
import AmountInput from '@/components/common/amount-input/index.vue'
@@ -499,6 +518,9 @@
}
const form = reactive({
projectId: '',
projectName: '',
teamName: '',
orderDate: todayYmd(),
protocolNo: '',
motherName: '',
@@ -618,6 +640,9 @@
}
function resetForm() {
form.projectId = ''
form.projectName = ''
form.teamName = ''
form.orderDate = todayYmd()
form.protocolNo = ''
form.motherName = ''
@@ -760,6 +785,9 @@
loading.value = true
try {
const data: any = await fetchPresignOrderDetail(props.orderId)
form.projectId = String((data).projectId || '')
form.projectName = String((data).projectName || '')
form.teamName = String((data).teamName || '')
form.orderDate = String(data.orderDate || '')
form.protocolNo = String(data.protocolNo || '')
form.motherName = String(data.motherName || '')
@@ -830,6 +858,14 @@
}
async function handleSave() {
if (!props.orderId && !projectReady.value) {
ElMessage.warning('项目选项尚未加载成功,请稍候或重试')
return
}
if (!(!!props.orderId) && projectRequired.value && !form.projectId) {
ElMessage.warning('请选择所属项目')
return
}
if (saving.value) return
if (loading.value || !formReady.value) {
ElMessage.warning('订单尚未初始化成功,请稍候或重新打开后重试')
@@ -854,6 +890,7 @@
saving.value = true
try {
const payload: Record<string, unknown> = {
...(!(!!props.orderId) ? { projectId: form.projectId || undefined } : {}),
orderDate: form.orderDate || undefined,
motherName: form.motherName,
expectedDeliveryDate: form.expectedDeliveryDate || '',
@@ -6,6 +6,12 @@
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="项目组"
><ProjectSelect filter team v-model="teamFilter" biz-type="presign"
/></el-form-item>
<el-form-item label="项目"
><ProjectSelect filter v-model="projectFilter" biz-type="presign"
/></el-form-item>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
@@ -63,7 +69,8 @@
@selection-change="(rows: any[]) => (selectedRows = rows)"
@row-dblclick="handleEdit"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -184,6 +191,9 @@
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectFilter = ref('')
const teamFilter = ref('')
import { onMounted, reactive, ref } from 'vue'
import { Plus, Search, RefreshLeft, Edit, Delete, Check } from '@element-plus/icons-vue'
import { formatDisplayOrDash } from '@/utils/datetime-display'
@@ -223,6 +233,8 @@
const res = await fetchPresignOrderList({
page: pagination.page,
pageSize: pagination.pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
orderStatus: searchForm.orderStatus || undefined
})
@@ -239,6 +251,8 @@
}
function handleReset() {
projectFilter.value = ''
teamFilter.value = ''
searchForm.keyword = ''
searchForm.orderStatus = ''
pagination.page = 1
@@ -272,5 +286,9 @@
loadData()
}
watch([projectFilter, teamFilter], () => {
pagination.page = 1
loadData()
})
onMounted(loadData)
</script>
@@ -5,6 +5,12 @@
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="项目组"
><ProjectSelect filter team v-model="teamFilter" biz-type="research_donation"
/></el-form-item>
<el-form-item label="项目"
><ProjectSelect filter v-model="projectFilter" biz-type="research_donation"
/></el-form-item>
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="姓名/备注/入库编码" clearable />
</el-form-item>
@@ -126,7 +132,8 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column label="序号" min-width="64" align="center" header-align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
@@ -402,6 +409,9 @@
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectFilter = ref('')
const teamFilter = ref('')
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -500,6 +510,8 @@
const res = await fetchResearchDonationList({
page: pagination.page,
pageSize: pagination.pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
donationStatus: searchForm.donationStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
@@ -522,6 +534,8 @@
}
function handleReset() {
projectFilter.value = ''
teamFilter.value = ''
searchForm.keyword = ''
searchForm.donationStatus = ''
createTimeRange.value = null
@@ -651,6 +665,10 @@
{ debounce: 300, deep: true }
)
watch([projectFilter, teamFilter], () => {
pagination.page = 1
loadData()
})
onMounted(async () => {
await loadVoidTypeDict()
loadData()
@@ -5,6 +5,12 @@
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="项目组"
><ProjectSelect filter team v-model="teamFilter" biz-type="cell_storage"
/></el-form-item>
<el-form-item label="项目"
><ProjectSelect filter v-model="projectFilter" biz-type="cell_storage"
/></el-form-item>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
@@ -138,7 +144,8 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column label="序号" min-width="64" align="center" header-align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
@@ -370,6 +377,9 @@
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectFilter = ref('')
const teamFilter = ref('')
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
@@ -465,6 +475,8 @@
const res = await fetchCollectionProtocolList({
page: pagination.page,
pageSize: pagination.pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
protocolStatus: searchForm.protocolStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
@@ -487,6 +499,8 @@
}
function handleReset() {
projectFilter.value = ''
teamFilter.value = ''
searchForm.keyword = ''
searchForm.protocolStatus = ''
createTimeRange.value = null
@@ -629,6 +643,10 @@
{ debounce: 300, deep: true }
)
watch([projectFilter, teamFilter], () => {
pagination.page = 1
loadData()
})
onMounted(async () => {
await loadVoidTypeDict()
loadData()
@@ -41,6 +41,22 @@
<div class="section-title-left"><h3>基本信息</h3></div>
</div>
<div class="form-grid form-grid--col4">
<el-form-item
label="所属项目"
prop="projectId"
:required="!isEdit && projectRequired"
><ProjectSelect
v-model="formData.projectId"
biz-type="cell_storage"
:disabled="isEdit"
:project-name="formData.projectName"
@required="projectRequired = $event"
@ready="projectReady = $event"
/>
<div v-if="isEdit" class="form-tip">
{{ formData.teamName || '未归属项目组' }} · 归属调整请使用项目归属维护
</div></el-form-item
>
<el-form-item label="协议状态" prop="protocolStatus">
<el-select
v-model="formData.protocolStatus"
@@ -1156,6 +1172,9 @@
</el-dialog>
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectRequired = ref(false)
const projectReady = ref(false)
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
import SampleTransportFormSection from '@/components/business/sample-transport-form-section/index.vue'
import { ElMessage } from 'element-plus'
@@ -1489,6 +1508,9 @@
const activeFormTab = ref('basic')
const formData = reactive({
projectId: '',
projectName: '',
teamName: '',
// Basic
protocolNo: '',
protocolNoId: '',
@@ -1620,6 +1642,9 @@
}
function resetForm() {
formData.projectId = ''
formData.projectName = ''
formData.teamName = ''
managerSearchVersion++
managerDefaultVersion++
managerLoading.value = false
@@ -2141,6 +2166,9 @@
function applyProtocolBundleToForm(bundle: Record<string, any>, row: CollectionProtocolRow) {
const prot = bundle.protocol as Record<string, any> | null
formData.projectId = String((bundle.basic || prot || {}).projectId || '')
formData.projectName = String((bundle.basic || prot || {}).projectName || '')
formData.teamName = String((bundle.basic || prot || {}).teamName || '')
if (prot) {
formData.collectorUserId = String(prot.collectorUserId || formData.collectorUserId || '')
formData.protocolNo = String(prot.protocolNo || formData.protocolNo || '')
@@ -2372,6 +2400,14 @@
}
async function handleSubmit() {
if (!isEdit.value && !projectReady.value) {
ElMessage.warning('项目选项尚未加载成功,请稍候或重试')
return
}
if (!isEdit.value && projectRequired.value && !formData.projectId) {
ElMessage.warning('请选择所属项目')
return
}
if (!formRef.value || submitLoading.value) return
if (isEdit.value && !editDetailLoaded.value) {
ElMessage.warning('协议详情尚未加载成功,请重新打开后重试')
@@ -2476,6 +2512,7 @@
.filter(Boolean)
.join(',')
const payload: Record<string, unknown> = {
...(!isEdit.value ? { projectId: formData.projectId || undefined } : {}),
protocolStatus: formData.protocolStatus,
registerDate: formData.registerDate || '',
collectorUserId: formData.collectorUserId || '',
@@ -37,6 +37,14 @@
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>基本信息</h3></div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">所属项目</div>
<div class="data-value">{{ protocol?.projectName || '未归属' }}</div>
</div>
<div class="data-item">
<div class="data-label">项目组</div>
<div class="data-value">{{ protocol?.teamName || '—' }}</div>
</div>
<div class="data-item"
><div class="data-label">协议状态</div
><div class="data-value"
@@ -6,6 +6,12 @@
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="项目组"
><ProjectSelect filter team v-model="teamFilter" biz-type="cell_storage"
/></el-form-item>
<el-form-item label="项目"
><ProjectSelect filter v-model="projectFilter" biz-type="cell_storage"
/></el-form-item>
<el-form-item label="关键词">
<el-input
v-model="searchForm.keyword"
@@ -99,7 +105,8 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -300,6 +307,9 @@
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectFilter = ref('')
const teamFilter = ref('')
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, View, Edit, Delete, Search, RefreshLeft, More, Coin } from '@element-plus/icons-vue'
@@ -348,6 +358,8 @@
const res = await fetchCollectionProtocolList({
page: pagination.page,
pageSize: pagination.pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
protocolStatus: searchForm.protocolStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
@@ -368,6 +380,8 @@
}
function handleReset() {
projectFilter.value = ''
teamFilter.value = ''
searchForm.keyword = ''
searchForm.protocolStatus = ''
createTimeRange.value = null
@@ -474,6 +488,10 @@
{ debounce: 300, deep: true }
)
watch([projectFilter, teamFilter], () => {
pagination.page = 1
loadData()
})
onMounted(async () => {
await loadVoidTypeDict()
loadData()
@@ -47,6 +47,23 @@
class="form-grid form-grid--col4"
:class="{ 'clinical-application-info-grid': isClinical }"
>
<el-form-item
v-if="!isClinical"
label="所属项目"
prop="projectId"
:required="!isEdit && projectRequired"
><ProjectSelect
v-model="formData.projectId"
:biz-type="isBasicExperiment ? 'basic_experiment' : 'pilot_service'"
:disabled="isEdit"
:project-name="formData.projectName"
@required="projectRequired = $event"
@ready="projectReady = $event"
/>
<div v-if="isEdit" class="form-tip">
{{ formData.teamName || '未归属项目组' }} · 归属调整请使用项目归属维护
</div></el-form-item
>
<el-form-item label="申请单名称" prop="applicationName">
<el-input
v-model="formData.applicationName"
@@ -1210,6 +1227,9 @@
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectRequired = ref(false)
const projectReady = ref(false)
import type { FormInstance } from 'element-plus'
import {
Plus,
@@ -1312,6 +1332,9 @@
const submitting = ref(false)
const formData = reactive({
projectId: '',
projectName: '',
teamName: '',
applicationName: '',
channelId: '',
channelServiceOrderId: '',
@@ -2764,6 +2787,9 @@
}
const resetForm = () => {
formData.projectId = ''
formData.projectName = ''
formData.teamName = ''
formData.applicationName = ''
formData.channelId = ''
formData.channelServiceOrderId = ''
@@ -2814,6 +2840,9 @@
const detail = res?.data ?? res
if (detail) {
formData.applicationName = detail.applicationName || ''
formData.projectId = String((detail).projectId || '')
formData.projectName = String((detail).projectName || '')
formData.teamName = String((detail).teamName || '')
formData.channelId = detail.channelId || ''
formData.channelServiceOrderId = detail.channelServiceOrderId || ''
formData.remark = detail.remark || ''
@@ -2976,6 +3005,14 @@
// ── 提交 ───────────────────────────────────────────────────────────────
const handleSubmit = async () => {
if (!isClinical.value && !isEdit.value && !projectReady.value) {
ElMessage.warning('项目选项尚未加载成功,请稍候或重试')
return
}
if (!isClinical.value && !isEdit.value && projectRequired.value && !formData.projectId) {
ElMessage.warning('请选择所属项目')
return
}
try {
await formRef.value?.validate()
} catch {
@@ -3018,6 +3055,7 @@
}))
const payload: Record<string, any> = {
...(!isClinical.value && !isEdit.value ? { projectId: formData.projectId || undefined } : {}),
applicationName: formData.applicationName || undefined,
remark: formData.remark || undefined,
bizType: props.bizType || undefined
@@ -105,6 +105,16 @@
<!-- 申请单信息 -->
<div id="view-section-application" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>申请单信息</h3></div>
<div v-if="applicationDetail.bizType !== 'clinical_application'" class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">所属项目</div>
<div class="data-value">{{ applicationDetail.projectName || '未归属' }}</div>
</div>
<div class="data-item">
<div class="data-label">项目组</div>
<div class="data-value">{{ applicationDetail.teamName || '—' }}</div>
</div>
</div>
<template v-if="isBasicExperiment">
<div class="data-grid data-grid--5">
<div class="data-item">
@@ -4,6 +4,25 @@
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item v-if="!isClinical" label="项目组"
><ProjectSelect filter
team
v-model="teamFilter"
:biz-type="
props.bizType === 'basic_experiment'
? 'basic_experiment'
: 'pilot_service'
"
/></el-form-item>
<el-form-item v-if="!isClinical" label="项目"
><ProjectSelect filter
v-model="projectFilter"
:biz-type="
props.bizType === 'basic_experiment'
? 'basic_experiment'
: 'pilot_service'
"
/></el-form-item>
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" :placeholder="keywordPlaceholder" clearable />
</el-form-item>
@@ -97,7 +116,9 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -278,6 +299,9 @@
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectFilter = ref('')
const teamFilter = ref('')
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
@@ -389,6 +413,8 @@
if (searchForm.keyword) params.keyword = searchForm.keyword
if (searchForm.channelId) params.channelId = searchForm.channelId
if (searchForm.applicationStatus) params.applicationStatus = searchForm.applicationStatus
params.projectId = projectFilter.value || undefined
params.teamId = teamFilter.value || undefined
params.bizType = resolvePilotBizType(props.bizType)
const res: any = await fetchPilotApplicationList(params)
@@ -407,6 +433,8 @@
}
const handleReset = () => {
projectFilter.value = ''
teamFilter.value = ''
searchForm.keyword = ''
searchForm.channelId = ''
searchForm.applicationStatus = ''
@@ -647,6 +675,10 @@
)
// ── 初始化 ────────────────────────────────────────────────────────────
watch([projectFilter, teamFilter], () => {
pagination.page = 1
loadData()
})
onMounted(() => {
loadData()
})
@@ -0,0 +1,402 @@
<template>
<div class="page-container"
><section class="panel">
<div class="filter-block"
><el-form class="filter-form" label-position="top" @submit.prevent="search">
<div class="filter-grid">
<el-form-item label="关键词"
><el-input v-model="query.keyword" clearable placeholder="名称 / 编号"
/></el-form-item>
<el-form-item v-if="isTeam" label="负责人"
><el-select v-model="query.leaderUserId" filterable clearable
><el-option
v-for="user in refs.users"
:key="user.id"
:value="user.id"
:label="user.name" /></el-select
></el-form-item>
<el-form-item v-if="isTeam" label="启用状态"
><el-select v-model="query.enabled" clearable
><el-option label="启用" :value="true" /><el-option
label="停用"
:value="false" /></el-select
></el-form-item>
<el-form-item v-else label="项目组"
><el-select v-model="query.teamId" filterable clearable
><el-option
v-for="team in teams"
:key="team.id"
:value="team.id"
:label="team.teamName" /></el-select
></el-form-item>
<el-form-item v-if="!isTeam" label="状态"
><el-select v-model="query.status" clearable
><el-option
v-for="(label, value) in statuses"
:key="value"
:value="value"
:label="label" /></el-select
></el-form-item> </div
><div class="filter-actions-row"
><div class="filter-actions-end">
<el-button @click="reset"
><el-icon><RefreshLeft /></el-icon>重置</el-button
>
<el-button type="primary" @click="search"
><el-icon><Search /></el-icon>搜索</el-button
>
</div></div
>
</el-form></div
>
<div class="list-toolbar"
><div class="list-toolbar-row"
><el-button v-if="canEdit" type="primary" @click="edit()"
><el-icon><Plus /></el-icon>新增{{ isTeam ? '项目组' : '项目' }}</el-button
></div
></div
>
<div class="list-body list-wrap"
><el-table :data="rows" v-loading="loading" class="list-table" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (query.page - 1) * query.pageSize + index + 1"
/>
<el-table-column
:prop="isTeam ? 'teamName' : 'projectName'"
:label="isTeam ? '项目组' : '项目名称'"
min-width="180"
/>
<el-table-column v-if="!isTeam" prop="projectNo" label="项目编号" min-width="160" />
<el-table-column v-if="!isTeam" prop="teamName" label="所属项目组" min-width="160" />
<el-table-column prop="leaderName" label="负责人" min-width="120" />
<el-table-column label="状态" min-width="110"
><template #default="{ row }"
><el-tag
:type="
isTeam
? row.enabled
? 'success'
: 'info'
: row.status === 'active'
? 'success'
: row.status === 'disabled'
? 'info'
: 'warning'
"
>{{ isTeam ? (row.enabled ? '启用' : '停用') : statuses[row.status] }}</el-tag
></template
></el-table-column
>
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip />
<el-table-column label="操作" fixed="right" min-width="260"
><template #default="{ row }">
<el-button class="btn-op-item" @click="edit(row, true)"
><el-icon><View /></el-icon>查看</el-button
>
<el-button v-if="canEdit" class="btn-op-item" @click="edit(row)"
><el-icon><Edit /></el-icon>编辑</el-button
>
<el-button v-if="canEdit" class="btn-op-item" @click="remove(row)"
><el-icon><Delete /></el-icon>删除</el-button
>
</template></el-table-column
> </el-table
><div class="pagination list-pager-bar"
><el-pagination
v-model:current-page="query.page"
v-model:page-size="query.pageSize"
:total="total"
layout="total, sizes, prev, pager, next"
@current-change="load"
@size-change="search" /></div
></div>
<el-dialog
v-model="visible"
:title="`${readonly ? '查看' : editingId ? '编辑' : '新增'}${isTeam ? '项目组' : '项目'}`"
width="720px"
class="dialog-form-layout"
append-to-body
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
>
<el-form
ref="formRef"
:model="form"
label-position="top"
:disabled="readonly"
scroll-to-error
><div class="form-grid-2">
<el-form-item
:label="isTeam ? '项目组名称' : '项目名称'"
:prop="isTeam ? 'teamName' : 'projectName'"
:rules="[{ required: true, message: '请输入名称' }]"
><el-input v-if="isTeam" v-model="form.teamName" /><el-input
v-else
v-model="form.projectName"
/></el-form-item>
<el-form-item
v-if="!isTeam"
label="所属项目组"
prop="teamId"
:rules="[{ required: true, message: '请选择项目组' }]"
><el-select v-model="form.teamId" filterable
><el-option
v-for="team in teams"
:key="team.id"
:value="team.id"
:label="team.teamName" /></el-select
></el-form-item>
<el-form-item
label="负责人"
prop="leaderUserId"
:rules="[{ required: true, message: '请选择负责人' }]"
><el-select v-model="form.leaderUserId" filterable clearable
><el-option
v-for="user in refs.users"
:key="user.id"
:value="user.id"
:label="user.name" /></el-select
></el-form-item>
<template v-if="isTeam">
<el-form-item label="成员"
><el-select v-model="memberIds" multiple filterable
><el-option
v-for="user in refs.users"
:key="user.id"
:value="user.id"
:label="user.name" /></el-select
></el-form-item>
<el-form-item label="负责地区(信息记录)"
><el-select v-model="regionIds" multiple filterable
><el-option
v-for="region in refs.regions"
:key="region.id"
:value="region.id"
:label="region.name" /></el-select
></el-form-item>
<el-form-item label="负责医院(信息记录)"
><el-select v-model="hospitalIds" multiple filterable
><el-option
v-for="hospital in refs.hospitals"
:key="hospital.id"
:value="hospital.id"
:label="hospital.name" /></el-select
></el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
<div class="form-tip form-col-span-2"
>地区、医院仅记录项目组职责,不改变业务地点候选或数据权限。</div
>
</template>
<template v-else>
<el-form-item
label="业务类型"
prop="bizTypes"
:rules="[{ required: true, type: 'array', min: 1, message: '请选择业务类型' }]"
><el-select v-model="form.bizTypes" multiple
><el-option
v-for="(label, key) in projectBizLabels"
:key="key"
:value="key"
:label="label" /></el-select
></el-form-item>
<el-form-item label="开始日期"
><el-date-picker v-model="form.startDate" value-format="YYYY-MM-DD"
/></el-form-item>
<el-form-item label="结束日期"
><el-date-picker v-model="form.endDate" value-format="YYYY-MM-DD"
/></el-form-item>
<el-form-item label="状态"
><el-select v-model="form.status"
><el-option
v-for="(label, key) in statuses"
:key="key"
:value="key"
:label="label" /></el-select
></el-form-item>
</template>
<el-form-item label="备注" class="form-col-span-2"
><el-input v-model="form.remark" type="textarea" :rows="3"
/></el-form-item> </div
></el-form>
<template #footer
><el-button @click="visible = false"
><el-icon><Close /></el-icon>关闭</el-button
><el-button v-if="!readonly" type="primary" :loading="saving" @click="save"
><el-icon><Check /></el-icon>保存</el-button
></template
>
</el-dialog>
</section></div
>
</template>
<script setup lang="ts">
import { managementPayload } from '@/utils/project-management'
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
import {
Plus,
Search,
RefreshLeft,
View,
Edit,
Delete,
Check,
Close
} from '@element-plus/icons-vue'
import {
bizLabels,
fetchManagementList,
fetchManagementDetail,
saveManagement,
deleteManagement,
fetchProjectReferences,
type ProjectRecord
} from '@/api/project-management'
const projectBizLabels = Object.fromEntries(
Object.entries(bizLabels).filter(([key]) => key !== 'presign')
)
const props = defineProps<{ kind: 'teams' | 'projects' }>()
const isTeam = computed(() => props.kind === 'teams')
const { hasMenuPerm } = useMenuPerm()
const canEdit = computed(() =>
hasMenuPerm(isTeam.value ? 'project:team:edit' : 'project:project:edit')
)
const statuses: Record<string, string> = {
pending: '待启动',
active: '进行中',
completed: '已完成',
disabled: '停用'
}
const query = reactive({
keyword: '',
leaderUserId: '',
enabled: undefined as boolean | undefined,
teamId: '',
status: '',
page: 1,
pageSize: 20
})
const rows = ref<ProjectRecord[]>([]),
teams = ref<ProjectRecord[]>([]),
total = ref(0),
loading = ref(false),
visible = ref(false),
saving = ref(false),
readonly = ref(false),
editingId = ref(''),
formRef = ref()
const refs = reactive<{
users: ProjectRecord[]
regions: ProjectRecord[]
hospitals: ProjectRecord[]
}>({ users: [], regions: [], hospitals: [] })
const form = ref<ProjectRecord>({}),
memberIds = ref<string[]>([]),
regionIds = ref<string[]>([]),
hospitalIds = ref<string[]>([])
async function load() {
loading.value = true
try {
const result = await fetchManagementList(props.kind, {
...query,
enabled: typeof query.enabled === 'boolean' ? query.enabled : undefined
})
rows.value = result.list
total.value = result.total
} finally {
loading.value = false
}
}
function search() {
query.page = 1
load()
}
function reset() {
Object.assign(query, {
keyword: '',
leaderUserId: '',
enabled: undefined,
teamId: '',
status: ''
})
search()
}
async function edit(row?: ProjectRecord, view = false) {
readonly.value = view
editingId.value = row?.id || ''
form.value = row
? await fetchManagementDetail(props.kind, row.id)
: {
teamName: '',
projectName: '',
leaderUserId: '',
enabled: true,
status: 'pending',
bizTypes: [],
remark: ''
}
memberIds.value = (form.value.members || []).map((x: ProjectRecord) => x.userId)
regionIds.value = (form.value.regions || []).map((x: ProjectRecord) => x.regionId)
hospitalIds.value = (form.value.hospitals || []).map((x: ProjectRecord) => x.hospitalId)
visible.value = true
}
function children(key: string, field: string, ids: string[]) {
return ids.map((id) => ({
...(form.value[key] || []).find((x: ProjectRecord) => x[field] === id),
[field]: id
}))
}
async function save() {
await formRef.value.validate()
if (form.value.startDate && form.value.endDate && form.value.startDate > form.value.endDate) {
ElMessage.warning('结束日期不能早于开始日期')
return
}
saving.value = true
try {
const data = { ...form.value }
if (isTeam.value)
Object.assign(data, {
members: children('members', 'userId', memberIds.value),
regions: children('regions', 'regionId', regionIds.value),
hospitals: children('hospitals', 'hospitalId', hospitalIds.value)
})
await saveManagement(
props.kind,
managementPayload(props.kind, data),
editingId.value || undefined
)
visible.value = false
ElMessage.success('保存成功')
await load()
} finally {
saving.value = false
}
}
async function remove(row: ProjectRecord) {
await ElMessageBox.confirm('确认删除?已被业务引用的项目或项目组请改为停用。', '删除确认', {
type: 'warning'
})
await deleteManagement(props.kind, row.id)
await load()
}
onMounted(async () => {
await load()
Object.assign(refs, await fetchProjectReferences())
if (!isTeam.value) {
let page = 1
let teamTotal = Infinity
do {
const result = await fetchManagementList('teams', { page, pageSize: 100 })
teamTotal = result.total
teams.value.push(...result.list)
if (teams.value.length >= result.total || !result.list.length) break
page++
} while (teams.value.length < teamTotal)
}
})
</script>
@@ -0,0 +1,311 @@
<template>
<div class="page-container">
<div class="filter-block">
<el-form class="filter-form" label-position="top" @submit.prevent="search"
><div class="filter-grid">
<el-form-item label="业务类型"
><el-select v-model="query.bizType" @change="search"
><el-option
v-for="(label, key) in bizLabels"
:key="key"
:value="key"
:label="label" /></el-select
></el-form-item>
<el-form-item label="项目组"
><ProjectSelect filter team v-model="query.teamId" :biz-type="query.bizType"
/></el-form-item>
<el-form-item label="项目"
><ProjectSelect filter v-model="query.projectId" :biz-type="query.bizType"
/></el-form-item>
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable /></el-form-item>
<el-form-item label="当前状态"
><el-select v-model="query.status" clearable
><el-option
v-for="(count, status) in statusCounts"
:key="status"
:value="status"
:label="`${statusLabel(String(status))}(${count})`" /></el-select
></el-form-item>
<el-form-item label="创建日期"
><el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
/></el-form-item>
<el-form-item label="归属"
><el-checkbox v-model="query.unassigned">仅未归属</el-checkbox></el-form-item
> </div
><div class="filter-actions-row"
><div class="filter-actions-end"
><el-button type="primary" @click="search"
><el-icon><Search /></el-icon>搜索</el-button
></div
></div
></el-form
>
</div>
<div class="list-body list-wrap"
><el-table :data="rows" v-loading="loading" class="list-table" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
:index="(index) => (query.page - 1) * query.pageSize + index + 1"
/>
<el-table-column prop="bizNo" label="单据编号" min-width="180" />
<el-table-column prop="teamName" label="项目组" min-width="140" />
<el-table-column prop="projectName" label="项目" min-width="160"
><template #default="{ row }">{{
row.projectName || '未归属'
}}</template></el-table-column
>
<el-table-column label="状态" min-width="110"
><template #default="{ row }"
><el-tag type="info">{{ statusLabel(row.status) }}</el-tag></template
></el-table-column
>
<el-table-column prop="createTime" label="创建时间" min-width="180">
<template #default="{ row }">{{ formatDisplayOrDash(row.createTime) }}</template>
</el-table-column>
<el-table-column v-if="assignment" label="操作" fixed="right" min-width="260"
><template #default="{ row }">
<el-button class="btn-op-item" @click="open(row)"
><el-icon><Edit /></el-icon>调整归属</el-button
>
<el-button class="btn-op-item" @click="history(row)"
><el-icon><Clock /></el-icon>审计记录</el-button
>
</template></el-table-column
> </el-table
><div class="pagination list-pager-bar"
><el-pagination
v-model:current-page="query.page"
:page-size="query.pageSize"
:total="total"
layout="total, prev, pager, next"
@current-change="load" /></div
></div>
<el-dialog
v-model="visible"
title="项目归属维护"
width="720px"
class="dialog-form-layout"
append-to-body
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
>
<el-form label-position="top"
><el-form-item label="目标项目" required
><ProjectSelect
v-model="target"
:biz-type="query.bizType"
@update:model-value="previewRows = []" /></el-form-item
><el-form-item label="调整原因" required
><el-input v-model="reason" type="textarea" :maxlength="500" /></el-form-item
></el-form>
<el-alert
title="请先预览全部关联单据,再确认调整。关联集合或归属发生变化时需要重新预览。"
type="info"
:closable="false"
/>
<el-table :data="previewRows" class="list-table" stripe
><el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center" />
<el-table-column prop="bizNo" label="关联单据" min-width="200" /><el-table-column
prop="projectName"
label="原项目"
min-width="160"
/></el-table>
<template #footer
><el-button @click="visible = false"
><el-icon><Close /></el-icon>取消</el-button
><el-button :loading="busy" @click="preview"
><el-icon><View /></el-icon>预览关联单据</el-button
><el-button
type="primary"
:disabled="!previewRows.length || !reason.trim()"
:loading="busy"
@click="commit"
><el-icon><Check /></el-icon>确认调整</el-button
></template
>
</el-dialog>
<el-dialog
v-model="historyVisible"
title="归属审计记录"
width="800px"
append-to-body
class="dialog-form-layout"
><el-table :data="historyRows" class="list-table" stripe
><el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column prop="oldProjectName" label="原项目" min-width="140" /><el-table-column
prop="newProjectName"
label="新项目"
min-width="140"
/><el-table-column prop="reason" label="原因" min-width="180" /><el-table-column
prop="operatorName"
label="操作人"
min-width="120"
/><el-table-column prop="createTime" label="时间" min-width="180"
><template #default="{ row }">{{
formatDisplayOrDash(row.createTime)
}}</template></el-table-column
></el-table
></el-dialog
>
</div>
</template>
<script setup lang="ts">
import { formatDisplayOrDash } from '@/utils/datetime-display'
import {
protocolStatusLabel,
donationStatusLabel,
presignOrderStatusLabel,
applicationStatusLabel
} from '@/utils/status-tag-registry'
import { Search, Edit, Clock, Close, View, Check } from '@element-plus/icons-vue'
import ProjectSelect from '@/components/business/ProjectSelect.vue'
import { assignmentSnapshot } from '@/utils/project-management'
import {
bizLabels,
fetchProjectRecords,
previewAssignment,
saveAssignment,
fetchAssignmentHistory,
type BizType,
type ProjectRecord
} from '@/api/project-management'
const props = defineProps<{ assignment?: boolean; filters?: Record<string, any> }>()
const dateRange = ref<string[]>([])
const query = reactive({
bizType: 'cell_storage' as BizType,
projectId: '',
teamId: '',
keyword: '',
status: '',
unassigned: false,
page: 1,
pageSize: 20
})
const rows = ref<ProjectRecord[]>([]),
total = ref(0),
statusCounts = ref<Record<string, number>>({}),
loading = ref(false),
visible = ref(false),
busy = ref(false),
target = ref(''),
reason = ref(''),
selected = ref<ProjectRecord>({}),
previewRows = ref<ProjectRecord[]>([]),
historyVisible = ref(false),
historyRows = ref<ProjectRecord[]>([])
function statusLabel(status: string) {
return query.bizType === 'presign'
? presignOrderStatusLabel(status)
: query.bizType === 'cell_storage'
? protocolStatusLabel(status)
: query.bizType === 'research_donation'
? donationStatusLabel(status)
: applicationStatusLabel(status)
}
async function load() {
loading.value = true
try {
const result = await fetchProjectRecords({
...props.filters,
...query,
teamId: query.teamId || undefined,
startDate: dateRange.value?.[0],
endDate: dateRange.value?.[1]
})
rows.value = result.list
total.value = result.total
statusCounts.value = result.statusCounts || {}
} finally {
loading.value = false
}
}
function search() {
query.page = 1
load()
}
function open(row: ProjectRecord) {
selected.value = row
target.value = ''
reason.value = ''
previewRows.value = []
visible.value = true
}
async function preview() {
if (!target.value) {
ElMessage.warning('请选择目标项目')
return
}
busy.value = true
const previewTarget = target.value
try {
const result = await previewAssignment({
bizType: selected.value.bizType,
id: selected.value.id,
projectId: target.value
})
previewRows.value = target.value === previewTarget ? result.records : []
} finally {
busy.value = false
}
}
async function commit() {
busy.value = true
try {
await saveAssignment({
bizType: selected.value.bizType,
id: selected.value.id,
projectId: target.value,
reason: reason.value.trim(),
expectedAssignments: assignmentSnapshot(previewRows.value)
})
visible.value = false
ElMessage.success('归属已调整')
await load()
} catch {
previewRows.value = []
} finally {
busy.value = false
}
}
async function history(row: ProjectRecord) {
const result = await fetchAssignmentHistory({ bizType: row.bizType, id: row.id })
historyRows.value = result.list
historyVisible.value = true
}
watch(
() => props.filters,
() => {
dateRange.value =
props.filters?.startDate && props.filters?.endDate
? [props.filters.startDate, props.filters.endDate]
: []
Object.assign(
query,
{ bizType: 'cell_storage', projectId: '', teamId: '', unassigned: false },
props.filters || {}
)
search()
},
{ immediate: true, deep: true }
)
</script>
@@ -0,0 +1,8 @@
<template>
<div class="page-container"
><section class="panel"><RecordList assignment /></section
></div>
</template>
<script setup lang="ts">
import RecordList from '../RecordList.vue'
</script>
@@ -0,0 +1,4 @@
<template><ManagementPage kind="projects" /></template>
<script setup lang="ts">
import ManagementPage from '../ManagementPage.vue'
</script>
@@ -0,0 +1,126 @@
<template>
<div class="page-container"
><section class="panel">
<div class="filter-block"
><el-form class="filter-form" label-position="top"
><div class="filter-grid">
<el-form-item label="汇总维度"
><el-select v-model="query.groupBy"
><el-option value="team" label="项目组" /><el-option
value="project"
label="项目" /></el-select
></el-form-item>
<el-form-item label="项目组"
><ProjectSelect filter v-model="query.teamId" team /></el-form-item
><el-form-item label="项目"
><ProjectSelect filter v-model="query.projectId"
/></el-form-item>
<el-form-item label="创建日期"
><el-date-picker
v-model="dateRange"
type="daterange"
value-format="YYYY-MM-DD"
start-placeholder="开始日期"
end-placeholder="结束日期"
/></el-form-item> </div
><div class="filter-actions-row"
><div class="filter-actions-end"
><el-button type="primary" @click="load"
><el-icon><Search /></el-icon>查询</el-button
></div
></div
></el-form
></div
>
<el-alert
title="按业务主单计数,各业务量不代表独立客户人数;预签约单独统计。点击数量查看对应明细。"
type="info"
:closable="false"
/>
<div class="list-body list-wrap"
><el-table :data="displayRows" v-loading="loading" class="list-table" stripe>
<el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center"
/>
<el-table-column prop="name" label="项目组 / 项目" min-width="200" /><el-table-column
prop="teamName"
label="所属项目组"
min-width="150"
/>
<el-table-column
v-for="key in formalTypes"
:key="key"
:label="bizLabels[key]"
min-width="130"
><template #default="{ row }"
><el-button link type="primary" @click="drill(row, key)"
><el-icon><View /></el-icon>{{ row[key] || 0 }}</el-button
></template
></el-table-column
> </el-table
><p class="form-tip">预签约:{{ data.presignTotal }} 单(单独计数)</p></div
>
<el-dialog
v-model="detailsVisible"
title="业务明细"
width="90%"
append-to-body
class="dialog-form-layout"
><RecordList v-if="detailsVisible" :filters="detailFilters"
/></el-dialog> </section
></div>
</template>
<script setup lang="ts">
import { Search, View } from '@element-plus/icons-vue'
import ProjectSelect from '@/components/business/ProjectSelect.vue'
import RecordList from '../RecordList.vue'
import { buildDrillFilters } from '@/utils/project-management'
import {
fetchProjectStatistics,
bizLabels,
type BizType,
type ProjectRecord
} from '@/api/project-management'
const query = reactive({ groupBy: 'team', projectId: '', teamId: '' }),
dateRange = ref<string[]>([]),
loading = ref(false),
detailsVisible = ref(false),
detailFilters = ref<ProjectRecord>({})
const data = ref<{
items: ProjectRecord[]
totals: ProjectRecord
unassignedTotals: ProjectRecord
presignTotal: number
}>({ items: [], totals: {}, unassignedTotals: {}, presignTotal: 0 })
const formalTypes: BizType[] = [
'cell_storage',
'research_donation',
'pilot_service',
'basic_experiment'
]
const applied = ref<ProjectRecord>({ groupBy: 'team' })
const displayRows = computed(() => [
...data.value.items,
{ ...data.value.totals, id: 'total', name: '业务总量' },
{ ...data.value.unassignedTotals, id: 'unassigned', name: '未归属' }
])
async function load() {
loading.value = true
try {
const params = { ...query, startDate: dateRange.value?.[0], endDate: dateRange.value?.[1] }
data.value = await fetchProjectStatistics(params)
applied.value = params
} finally {
loading.value = false
}
}
function drill(row: ProjectRecord, bizType: BizType) {
detailFilters.value = buildDrillFilters(applied.value, row, bizType)
detailsVisible.value = true
}
onMounted(load)
</script>
@@ -0,0 +1,4 @@
<template><ManagementPage kind="teams" /></template>
<script setup lang="ts">
import ManagementPage from '../ManagementPage.vue'
</script>
@@ -41,6 +41,22 @@
<div class="section-title-left"><h3>基本信息</h3></div>
</div>
<div class="form-grid form-grid--col4">
<el-form-item
label="所属项目"
prop="projectId"
:required="!isEdit && projectRequired"
><ProjectSelect
v-model="formData.projectId"
biz-type="research_donation"
:disabled="isEdit"
:project-name="formData.projectName"
@required="projectRequired = $event"
@ready="projectReady = $event"
/>
<div v-if="isEdit" class="form-tip">
{{ formData.teamName || '未归属项目组' }} · 归属调整请使用项目归属维护
</div></el-form-item
>
<el-form-item label="捐赠状态" prop="donationStatus">
<el-select
v-model="formData.donationStatus"
@@ -772,6 +788,9 @@
</el-dialog>
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectRequired = ref(false)
const projectReady = ref(false)
import DialogFormBody from '@/components/business/dialog-form-body/index.vue'
import SampleTransportFormSection from '@/components/business/sample-transport-form-section/index.vue'
import { ElMessage } from 'element-plus'
@@ -959,6 +978,9 @@
}
const formData = reactive({
projectId: '',
projectName: '',
teamName: '',
// Basic
donationNo: '',
donationNoId: '',
@@ -1010,6 +1032,9 @@
}))
function resetForm() {
formData.projectId = ''
formData.projectName = ''
formData.teamName = ''
Object.assign(formData, {
donationNo: '',
donationNoId: '',
@@ -1156,6 +1181,9 @@
function applyDonationBundleToForm(bundle: Record<string, any>) {
const donation = (bundle.donation ?? bundle.protocol) as Record<string, any> | null
formData.projectId = String((bundle.basic || donation || {}).projectId || '')
formData.projectName = String((bundle.basic || donation || {}).projectName || '')
formData.teamName = String((bundle.basic || donation || {}).teamName || '')
if (donation) {
formData.collectorUserId = String(donation.collectorUserId || formData.collectorUserId || '')
formData.donationNo = String(donation.donationNo || formData.donationNo || '')
@@ -1331,6 +1359,14 @@
}
async function handleSubmit() {
if (!isEdit.value && !projectReady.value) {
ElMessage.warning('项目选项尚未加载成功,请稍候或重试')
return
}
if (!isEdit.value && projectRequired.value && !formData.projectId) {
ElMessage.warning('请选择所属项目')
return
}
if (!formRef.value) return
await formRef.value.validate()
try {
@@ -1360,6 +1396,7 @@
submitLoading.value = true
try {
const payload: Record<string, unknown> = {
...(!isEdit.value ? { projectId: formData.projectId || undefined } : {}),
donationStatus: formData.donationStatus,
donationDate: formData.donationDate || undefined,
collectorUserId: formData.collectorUserId || undefined,
@@ -37,6 +37,14 @@
<div id="view-section-basic" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>基本信息</h3></div>
<div class="data-grid data-grid--4">
<div class="data-item">
<div class="data-label">所属项目</div>
<div class="data-value">{{ donation?.projectName || '未归属' }}</div>
</div>
<div class="data-item">
<div class="data-label">项目组</div>
<div class="data-value">{{ donation?.teamName || '—' }}</div>
</div>
<div class="data-item"
><div class="data-label">状态</div
><div class="data-value"
@@ -6,6 +6,12 @@
<div class="filter-block">
<el-form :model="searchForm" class="filter-form" @submit.prevent>
<div class="filter-grid">
<el-form-item label="项目组"
><ProjectSelect filter team v-model="teamFilter" biz-type="research_donation"
/></el-form-item>
<el-form-item label="项目"
><ProjectSelect filter v-model="projectFilter" biz-type="research_donation"
/></el-form-item>
<el-form-item label="关键词">
<el-input v-model="searchForm.keyword" placeholder="姓名/备注" clearable />
</el-form-item>
@@ -87,7 +93,8 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -275,6 +282,9 @@
</template>
<script setup lang="ts">
import ProjectSelect from '@/components/business/ProjectSelect.vue'
const projectFilter = ref('')
const teamFilter = ref('')
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, View, Edit, Delete, Search, RefreshLeft, More } from '@element-plus/icons-vue'
@@ -323,6 +333,8 @@
const res = await fetchResearchDonationList({
page: pagination.page,
pageSize: pagination.pageSize,
projectId: projectFilter.value || undefined,
teamId: teamFilter.value || undefined,
keyword: searchForm.keyword || undefined,
donationStatus: searchForm.donationStatus || undefined,
createTimeStart: createTimeRange.value?.[0] || undefined,
@@ -343,6 +355,8 @@
}
function handleReset() {
projectFilter.value = ''
teamFilter.value = ''
searchForm.keyword = ''
searchForm.donationStatus = ''
createTimeRange.value = null
@@ -434,6 +448,10 @@
{ debounce: 300, deep: true }
)
watch([projectFilter, teamFilter], () => {
pagination.page = 1
loadData()
})
onMounted(async () => {
await loadVoidTypeDict()
loadData()
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildDrillFilters, assignmentSnapshot } from '../src/utils/project-management'
test('drill preserves applied dates and selected project when a team is opened', () => {
assert.deepEqual(
buildDrillFilters(
{ groupBy: 'team', projectId: 'project-1', startDate: '2026-01-01', endDate: '2026-01-31' },
{ id: 'team-1' },
'cell_storage'
),
{
projectId: 'project-1',
teamId: 'team-1',
startDate: '2026-01-01',
endDate: '2026-01-31',
bizType: 'cell_storage',
unassigned: false
}
)
})
test('unassigned drill preserves active scope so detail counts match the filtered summary', () => {
assert.deepEqual(
buildDrillFilters(
{ groupBy: 'project', projectId: 'p', teamId: 't' },
{ id: 'unassigned' },
'research_donation'
),
{ projectId: 'p', teamId: 't', bizType: 'research_donation', unassigned: true }
)
})
test('assignment snapshot preserves nullable old ownership and excludes display-only data', () => {
assert.deepEqual(
assignmentSnapshot([
{ bizType: 'presign', id: 'a', projectId: null, projectName: 'old', secret: 'x' }
]),
[{ bizType: 'presign', id: 'a', projectId: null }]
)
})
test('management payload excludes readonly response fields and preserves child IDs', async () => {
const { managementPayload } = await import('../src/utils/project-management')
assert.deepEqual(
managementPayload('teams', {
id: 't',
teamName: 'Team',
leaderUserId: 'u',
leaderName: 'Name',
enabled: true,
remark: '',
members: [{ id: 'm', userId: 'u', name: 'Name' }],
regions: [],
hospitals: [],
createTime: 'today'
}),
{
teamName: 'Team',
leaderUserId: 'u',
enabled: true,
remark: '',
members: [{ id: 'm', userId: 'u' }],
regions: [],
hospitals: []
}
)
})
test('filter options use visible historical scope; create and reassignment remain strict', async () => {
const { projectOptionsParams } = await import('../src/utils/project-management')
assert.deepEqual(projectOptionsParams('cell_storage', true), {
bizType: 'cell_storage',
usage: 'filter'
})
assert.deepEqual(projectOptionsParams('cell_storage', false), { bizType: 'cell_storage' })
})