Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bddf38e52 | ||
|
|
b510d18439 | ||
|
|
f41e6f60d6 | ||
|
|
a4c4028144 | ||
|
|
bd62416263 |
No files matched your search
@@ -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 和移动表单,调整配置后的服务重启由用户执行。
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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)
|
||||
# ────────────────────────────────────────────
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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("当前状态不允许编辑")
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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="课题名称(基础实验)")
|
||||
|
||||
@@ -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))
|
||||
@@ -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")
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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')
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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项既有警告;本轮无前端源码变更,不重复前端构建,微信真机及集成验收继续待办。
|
||||
@@ -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
|
||||
|
||||
@@ -10,6 +10,8 @@ type PageList<T> = {
|
||||
export function fetchPresignOrderList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
projectId?: string
|
||||
teamId?: string
|
||||
keyword?: string
|
||||
orderStatus?: string
|
||||
}) {
|
||||
|
||||
@@ -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 })
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
Reference in new issue
Block a user