Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c40feab76a | ||
|
|
9cf1110495 | ||
|
|
fefef41524 | ||
|
|
e97f47b06a | ||
|
|
a4defb2c94 | ||
|
|
8717702b58 | ||
|
|
d30dbad788 | ||
|
|
e92112e9fd | ||
|
|
d6dc768efb | ||
|
|
96518c08fb | ||
|
|
54a34b24ba | ||
|
|
5dc695a138 | ||
|
|
406ed0ee6d | ||
|
|
6cba55f5c8 | ||
|
|
352a4b7402 | ||
|
|
e895daf8fc | ||
|
|
3bdbefb56e | ||
|
|
ecfdf65d4f | ||
|
|
1fd1100b8f | ||
|
|
2b2ddc8dd5 | ||
|
|
9af53e81f4 | ||
|
|
9e3a67a8e4 | ||
|
|
ebaab7d210 | ||
|
|
ac9db561a4 | ||
|
|
4a12d92631 | ||
|
|
1fb25f4fc2 | ||
|
|
e2e9059c17 | ||
|
|
65aaaae885 | ||
|
|
71051461a2 | ||
|
|
25c12ad4ff | ||
|
|
5b440df17e | ||
|
|
67846493fa | ||
|
|
81154f4c94 | ||
|
|
125398a3f5 | ||
|
|
af730ad050 | ||
|
|
2e21884d65 | ||
|
|
6292badf8e | ||
|
|
5a55e9f574 | ||
|
|
a511004812 | ||
|
|
f1b4a5ce26 | ||
|
|
01fef051da | ||
|
|
7ac2ff282c | ||
|
|
79ca01ac0d | ||
|
|
ce8babcb7b | ||
|
|
fa5efa9629 | ||
|
|
f04e24186f | ||
|
|
6e682f8d18 | ||
|
|
74064e137c | ||
|
|
29f9866a24 | ||
|
|
2a7e763cdf | ||
|
|
560bb3dde2 | ||
|
|
ab019f6e0e | ||
|
|
801a5a75d2 | ||
|
|
dae1d04672 | ||
|
|
88b971e1b7 | ||
|
|
4b1bf0f783 | ||
|
|
d8d7001b9b | ||
|
|
c4d157b23a | ||
|
|
0bddf38e52 | ||
|
|
b510d18439 | ||
|
|
f41e6f60d6 | ||
|
|
a4c4028144 | ||
|
|
bd62416263 | ||
|
|
e9df9d65e1 |
No files matched your search
@@ -240,6 +240,10 @@ class Tracker:
|
||||
if not value:
|
||||
return records
|
||||
records.extend(value)
|
||||
# Gitea comment pages may ignore page and keep returning the same short
|
||||
# list; a short page is the conventional last-page signal.
|
||||
if len(value) < PAGE_SIZE:
|
||||
return records
|
||||
raise InstallError('issue tracker pagination limit exceeded')
|
||||
|
||||
def issues(self):
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"figma": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,3 +49,36 @@ 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),
|
||||
最后执行[项目管理分组迁移](../docs/sql/migrations/2026-11-04-project_management_group.sql)。
|
||||
已执行前四个脚本的数据库只需追加分组迁移;完整重跑时也须将分组迁移放在最后。
|
||||
“项目”与“市场、生产、质检”平级,下设“项目管理”目录,其下包含项目组管理、项目管理、业务统计、历史归属维护。
|
||||
分组迁移保留四个页面的 ID 和权限,仅为已授权子页面/编辑按钮的角色补齐祖先;
|
||||
旧 `/project/{页面}` 和 `/market/project-management/{页面}` 地址跳转至 `/project/management/{页面}`。
|
||||
菜单种子不会给角色新增业务权限,应在现有角色管理分配项目管理权限及祖先菜单;
|
||||
一级菜单迁移保留已有叶子 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
|
||||
@@ -15,6 +16,7 @@ from app.core.response import error_response, success_response
|
||||
from app.core.utils import parse_datetime
|
||||
from app.crud import collection_protocol as cp_svc
|
||||
from app.crud import collection_region as region_svc
|
||||
from app.crud import protocol_operation_log as op_log
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolPartyCustomer,
|
||||
@@ -118,6 +120,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 +136,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 +178,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 +206,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 +385,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 +404,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,
|
||||
@@ -432,6 +443,16 @@ async def create_collection_protocol(
|
||||
commit=True,
|
||||
)
|
||||
db.refresh(obj)
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id=protocol_id,
|
||||
action=op_log.ACTION_CREATE,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=None,
|
||||
)
|
||||
db.commit()
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=protocol_id,
|
||||
@@ -443,7 +464,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 +484,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",
|
||||
@@ -493,6 +521,9 @@ async def update_collection_protocol(
|
||||
before_codes = sample_codes_from_orm_rows(
|
||||
cp_svc.list_samples(db, protocol_id, tenant_no)
|
||||
)
|
||||
before_snap = op_log.snapshot_cell_protocol(
|
||||
db, protocol_id=protocol_id, tenant_no=tenant_no
|
||||
)
|
||||
obj = cp_svc.update_collection_protocol(
|
||||
db,
|
||||
protocol_id,
|
||||
@@ -523,6 +554,16 @@ async def update_collection_protocol(
|
||||
commit=True,
|
||||
)
|
||||
db.refresh(obj)
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id=protocol_id,
|
||||
action=op_log.ACTION_UPDATE,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=before_snap,
|
||||
)
|
||||
db.commit()
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=protocol_id,
|
||||
@@ -534,7 +575,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))
|
||||
@@ -551,12 +592,26 @@ async def delete_collection_protocol(
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(404, "协议单不存在或无权操作")
|
||||
try:
|
||||
before_snap = op_log.snapshot_cell_protocol(
|
||||
db, protocol_id=protocol_id, tenant_no=tenant_no
|
||||
)
|
||||
cp_svc.soft_delete_collection_protocol(
|
||||
db,
|
||||
protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id=protocol_id,
|
||||
action=op_log.ACTION_DELETE,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=before_snap,
|
||||
after=None,
|
||||
)
|
||||
db.commit()
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -610,6 +665,9 @@ async def submit_collection_protocol_audit(
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权访问该协议或协议不存在")
|
||||
try:
|
||||
before_snap = op_log.snapshot_cell_protocol(
|
||||
db, protocol_id=protocol_id, tenant_no=tenant_no
|
||||
)
|
||||
cp_svc.create_audit(
|
||||
db,
|
||||
collection_protocol_id=protocol_id,
|
||||
@@ -618,6 +676,17 @@ async def submit_collection_protocol_audit(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
)
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id=protocol_id,
|
||||
action=op_log.ACTION_AUDIT,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=before_snap,
|
||||
remark=(body.audit_remark or "").strip() or None,
|
||||
)
|
||||
db.commit()
|
||||
p = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == protocol_id).first()
|
||||
pn = (p.protocol_no if p else "") or ""
|
||||
return success_response({"protocolNo": pn}, message="审核已提交")
|
||||
@@ -642,6 +711,24 @@ async def list_collection_protocol_status_changes(
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
@router.get("/collection-protocols/{protocol_id}/operation-logs")
|
||||
async def list_collection_protocol_operation_logs(
|
||||
protocol_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
if not _protocol_accessible(db, protocol_id, tenant_no, user):
|
||||
return error_response(403, "无权访问该协议或协议不存在")
|
||||
items = op_log.list_protocol_operation_logs(
|
||||
db,
|
||||
biz_type=op_log.BIZ_CELL,
|
||||
biz_id=protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 收款确认
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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')
|
||||
+29
-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,36 @@ 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": "ProjectManagement", "path": "management", "component": "",
|
||||
"meta": {"title": "项目管理", "icon": "ri:folder-chart-line",
|
||||
"keepAlive": False, "menuCode": "menu:project:management"},
|
||||
"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,205 @@
|
||||
"""项目管理;所有管理路由显式校验精确菜单权限。"""
|
||||
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
|
||||
from app.services import project_assignment_records
|
||||
|
||||
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)
|
||||
projectNo: str = Field(min_length=1, max_length=64)
|
||||
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.get(PREFIX + '/statistics/records')
|
||||
def statistics_records(
|
||||
bizType: Literal['cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'],
|
||||
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')), db: Session=Depends(get_db),
|
||||
):
|
||||
return success_response(project_assignment_records.statistics_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.get(PREFIX + '/assignments/records')
|
||||
def assignment_records(
|
||||
bizType: Literal['cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'],
|
||||
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:assignment:edit')), db: Session=Depends(get_db),
|
||||
):
|
||||
return success_response(project_assignment_records.records(
|
||||
db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId,
|
||||
unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate,
|
||||
))
|
||||
|
||||
|
||||
@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,10 +5,14 @@ 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
|
||||
from app.crud import research_donation as rd_svc
|
||||
from app.crud import protocol_operation_log as op_log
|
||||
from app.models.models import SysUser
|
||||
from app.models.research_donation import BizResearchDonation
|
||||
from app.services.sample_collect_wx_notify import (
|
||||
@@ -44,6 +48,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 +76,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 +149,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 +166,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 +260,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
|
||||
@@ -260,6 +273,16 @@ async def create_research_donation(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=obj.id,
|
||||
action=op_log.ACTION_CREATE,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=None,
|
||||
)
|
||||
db.commit()
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=obj.id,
|
||||
@@ -295,10 +318,14 @@ 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)
|
||||
)
|
||||
before_snap = op_log.snapshot_donation(
|
||||
db, donation_id=donation_id, tenant_no=tenant_no
|
||||
)
|
||||
unset_fields = body.model_dump(exclude_unset=True)
|
||||
obj = rd_svc.update_donation(
|
||||
db,
|
||||
@@ -307,6 +334,16 @@ async def update_research_donation(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=donation_id,
|
||||
action=op_log.ACTION_UPDATE,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=before_snap,
|
||||
)
|
||||
db.commit()
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=tenant_no,
|
||||
protocol_id=obj.id,
|
||||
@@ -340,12 +377,26 @@ async def delete_research_donation(
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
before_snap = op_log.snapshot_donation(
|
||||
db, donation_id=donation_id, tenant_no=tenant_no
|
||||
)
|
||||
rd_svc.delete_donation(
|
||||
db,
|
||||
donation_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=donation_id,
|
||||
action=op_log.ACTION_DELETE,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=before_snap,
|
||||
after=None,
|
||||
)
|
||||
db.commit()
|
||||
return success_response(None, message="删除成功")
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
@@ -390,6 +441,9 @@ async def submit_donation_audit(
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
try:
|
||||
before_snap = op_log.snapshot_donation(
|
||||
db, donation_id=donation_id, tenant_no=tenant_no
|
||||
)
|
||||
rd_svc.submit_audit(
|
||||
db,
|
||||
donation_id,
|
||||
@@ -398,6 +452,17 @@ async def submit_donation_audit(
|
||||
user_id=user.get("user_id"),
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=donation_id,
|
||||
action=op_log.ACTION_AUDIT,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user.get("user_id"),
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=before_snap,
|
||||
remark=(body.audit_remark or "").strip() or None,
|
||||
)
|
||||
db.commit()
|
||||
d = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(BizResearchDonation.id == donation_id)
|
||||
@@ -424,6 +489,9 @@ async def change_donation_status(
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
before_snap = op_log.snapshot_donation(
|
||||
db, donation_id=donation_id, tenant_no=tenant_no
|
||||
)
|
||||
obj = rd_svc.change_status(
|
||||
db,
|
||||
donation_id,
|
||||
@@ -434,6 +502,17 @@ async def change_donation_status(
|
||||
user_id=user_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=donation_id,
|
||||
action=op_log.ACTION_UPDATE,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=before_snap,
|
||||
remark=(body.desc or "").strip() or None,
|
||||
)
|
||||
db.commit()
|
||||
return success_response(
|
||||
rd_svc._donation_to_dict(obj, db, tenant_no),
|
||||
message="状态变更成功",
|
||||
@@ -468,6 +547,33 @@ async def list_research_donation_status_changes(
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
@router.get("/research-donations/{donation_id}/operation-logs")
|
||||
async def list_research_donation_operation_logs(
|
||||
donation_id: str,
|
||||
user: dict = Depends(require_admin_or_super_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
obj = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(
|
||||
BizResearchDonation.id == donation_id,
|
||||
BizResearchDonation.tenant_no == tenant_no,
|
||||
BizResearchDonation.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
return error_response(404, "捐赠单不存在")
|
||||
items = op_log.list_protocol_operation_logs(
|
||||
db,
|
||||
biz_type=op_log.BIZ_DONATION,
|
||||
biz_id=donation_id,
|
||||
tenant_no=tenant_no,
|
||||
)
|
||||
return success_response({"list": items})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 10. 细胞存储 → 科研捐赠
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -482,12 +588,36 @@ 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)
|
||||
before_cell = op_log.snapshot_cell_protocol(
|
||||
db, protocol_id=body.protocol_id, tenant_no=tenant_no
|
||||
)
|
||||
obj = rd_svc.convert_from_storage(
|
||||
db,
|
||||
body.protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id=body.protocol_id,
|
||||
action=op_log.ACTION_TO_RESEARCH,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=before_cell,
|
||||
)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=obj.id,
|
||||
action=op_log.ACTION_CREATE,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
client_channel=op_log.CHANNEL_WEB,
|
||||
before=None,
|
||||
remark="convert_from_storage",
|
||||
)
|
||||
db.commit()
|
||||
return success_response(
|
||||
rd_svc._donation_to_dict(obj, db, tenant_no),
|
||||
message="转换成功",
|
||||
@@ -495,7 +625,6 @@ async def convert_from_storage(
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. 科研捐赠 → 细胞存储
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -510,6 +639,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
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ _DONATION_STATUS_LABELS = {
|
||||
_VOID_TYPE_FALLBACK_LABELS = {
|
||||
"khhc": "客户毁存",
|
||||
"jcbhg": "检测不合格",
|
||||
"sysfq": "实验室废弃",
|
||||
}
|
||||
|
||||
# 非法来源状态视为终态,不允许再转出
|
||||
|
||||
@@ -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,
|
||||
@@ -2635,6 +2642,7 @@ def update_collection_protocol(
|
||||
source_presign_order_id: Optional[str] = None,
|
||||
remark: Optional[str] = None,
|
||||
commit: bool = True,
|
||||
sync_reception_discard: bool = True,
|
||||
) -> BizCollectionProtocol:
|
||||
obj = get_collection_protocol(
|
||||
db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id
|
||||
@@ -2643,6 +2651,7 @@ def update_collection_protocol(
|
||||
raise ValueError("协议单不存在或无权操作")
|
||||
from_status = (obj.protocol_status or "").strip()
|
||||
status_log_payload: Optional[Dict[str, Any]] = None
|
||||
voided_now = False
|
||||
if protocol_no is not None:
|
||||
pn = protocol_no.strip()
|
||||
if pn != (obj.protocol_no or ""):
|
||||
@@ -2653,6 +2662,8 @@ def update_collection_protocol(
|
||||
next_status = protocol_status.strip()
|
||||
assert_protocol_status_transition(obj.protocol_status or "", next_status, kind="protocol")
|
||||
obj.protocol_status = next_status
|
||||
if next_status == "yzf" and from_status != "yzf":
|
||||
voided_now = True
|
||||
if next_status == "yzf" and void_time is not None:
|
||||
if void_type is None or not str(void_type).strip():
|
||||
raise ValueError("请选择作废类型")
|
||||
@@ -2722,8 +2733,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
|
||||
@@ -2742,6 +2753,16 @@ def update_collection_protocol(
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
if voided_now and sync_reception_discard:
|
||||
from app.services.protocol_reception_discard_sync import after_protocol_voided
|
||||
|
||||
after_protocol_voided(
|
||||
db,
|
||||
source="cell_storage",
|
||||
protocol_id=obj.id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
if commit:
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
"""协议单操作过程日志(Issue #24)。
|
||||
|
||||
记录细胞存储 / 科研捐赠协议在 Web、小程序端的 create/update/delete/状态类动作,
|
||||
保存操作前、动作、操作后快照,供责任归属追溯。append-only,不 commit。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import BizCollectionProtocol, BizProtocolOperationLog, SysUser
|
||||
|
||||
BIZ_CELL = "cell_storage"
|
||||
BIZ_DONATION = "research_donation"
|
||||
|
||||
ACTION_CREATE = "create"
|
||||
ACTION_UPDATE = "update"
|
||||
ACTION_DELETE = "delete"
|
||||
ACTION_VOID = "void"
|
||||
ACTION_ACTIVATE = "activate"
|
||||
ACTION_EXPIRE = "expire"
|
||||
ACTION_TO_RESEARCH = "to_research"
|
||||
ACTION_TO_STORAGE = "to_storage"
|
||||
ACTION_AUDIT = "audit"
|
||||
|
||||
CHANNEL_WEB = "web"
|
||||
CHANNEL_MINI = "mini"
|
||||
|
||||
_STATUS_ACTION_MAP = {
|
||||
"sxz": ACTION_ACTIVATE,
|
||||
"ygq": ACTION_EXPIRE,
|
||||
"yzf": ACTION_VOID,
|
||||
"zyk": ACTION_TO_RESEARCH,
|
||||
"zcck": ACTION_TO_STORAGE,
|
||||
}
|
||||
|
||||
|
||||
def _json_dumps(payload: Optional[Dict[str, Any]]) -> Optional[str]:
|
||||
if payload is None:
|
||||
return None
|
||||
return json.dumps(payload, ensure_ascii=False, default=str, separators=(",", ":"))
|
||||
|
||||
|
||||
def _json_loads(raw: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
return value if isinstance(value, dict) else None
|
||||
|
||||
|
||||
def resolve_action_from_status(
|
||||
*,
|
||||
default_action: str,
|
||||
before_status: Optional[str] = None,
|
||||
after_status: Optional[str] = None,
|
||||
) -> str:
|
||||
"""仅对 update 默认动作按状态流转细化;显式动作(create/delete/audit 等)保持原值。"""
|
||||
action = (default_action or "").strip() or ACTION_UPDATE
|
||||
if action != ACTION_UPDATE:
|
||||
return action
|
||||
before = (before_status or "").strip()
|
||||
after = (after_status or "").strip()
|
||||
if after and after != before and after in _STATUS_ACTION_MAP:
|
||||
return _STATUS_ACTION_MAP[after]
|
||||
return action
|
||||
|
||||
|
||||
def _operator_name(db: Session, user_id: Optional[str]) -> str:
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return ""
|
||||
row = db.query(SysUser.nick_name).filter(SysUser.id == uid).first()
|
||||
if not row:
|
||||
return ""
|
||||
return (row[0] or "").strip()
|
||||
|
||||
|
||||
def snapshot_cell_protocol(
|
||||
db: Session,
|
||||
*,
|
||||
protocol_id: str,
|
||||
tenant_no: str,
|
||||
include_deleted: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""采集协议主表 + 当事人 + 采集信息 + 样本快照。"""
|
||||
from app.crud import collection_protocol as cell_crud
|
||||
|
||||
pid = (protocol_id or "").strip()
|
||||
if not pid:
|
||||
return None
|
||||
q = db.query(BizCollectionProtocol).filter(
|
||||
BizCollectionProtocol.id == pid,
|
||||
BizCollectionProtocol.tenant_no == tenant_no,
|
||||
)
|
||||
if not include_deleted:
|
||||
q = q.filter(BizCollectionProtocol.del_flag == 0)
|
||||
row = q.first()
|
||||
if not row:
|
||||
return None
|
||||
party = cell_crud.get_party_customer(db, pid, tenant_no)
|
||||
collection = cell_crud.get_collection_info(db, pid, tenant_no)
|
||||
samples = cell_crud.list_samples(db, pid, tenant_no)
|
||||
return {
|
||||
"protocol": cell_crud._protocol_to_dict(row, db),
|
||||
"partyCustomer": cell_crud._party_customer_to_dict(party) if party else None,
|
||||
"collectionInfo": cell_crud._collection_info_to_dict(collection)
|
||||
if collection
|
||||
else None,
|
||||
"samples": [cell_crud._sample_to_dict(s) for s in samples],
|
||||
}
|
||||
|
||||
|
||||
def snapshot_donation(
|
||||
db: Session,
|
||||
*,
|
||||
donation_id: str,
|
||||
tenant_no: str,
|
||||
include_deleted: bool = False,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""科研捐赠主表 + 当事人 + 采集信息 + 样本快照。"""
|
||||
from app.crud import research_donation as donation_crud
|
||||
from app.models.research_donation import BizResearchDonation
|
||||
|
||||
did = (donation_id or "").strip()
|
||||
if not did:
|
||||
return None
|
||||
q = db.query(BizResearchDonation).filter(
|
||||
BizResearchDonation.id == did,
|
||||
BizResearchDonation.tenant_no == tenant_no,
|
||||
)
|
||||
if not include_deleted:
|
||||
q = q.filter(BizResearchDonation.del_flag == 0)
|
||||
row = q.first()
|
||||
if not row:
|
||||
return None
|
||||
party = donation_crud.get_party(db, did, tenant_no)
|
||||
collection = donation_crud.get_collection(db, did, tenant_no)
|
||||
samples = donation_crud.list_samples(db, did, tenant_no)
|
||||
return {
|
||||
"donation": donation_crud._donation_to_dict(row, db, tenant_no),
|
||||
"party": donation_crud._party_to_dict(party) if party else None,
|
||||
"collection": donation_crud._collection_to_dict(collection)
|
||||
if collection
|
||||
else None,
|
||||
"samples": [donation_crud._sample_to_dict(s) for s in samples],
|
||||
}
|
||||
|
||||
|
||||
def _protocol_no_from_snapshot(
|
||||
biz_type: str, snapshot: Optional[Dict[str, Any]]
|
||||
) -> Optional[str]:
|
||||
if not snapshot:
|
||||
return None
|
||||
if biz_type == BIZ_CELL:
|
||||
protocol = snapshot.get("protocol") or {}
|
||||
return (protocol.get("protocolNo") or "").strip() or None
|
||||
donation = snapshot.get("donation") or {}
|
||||
return (donation.get("donationNo") or "").strip() or None
|
||||
|
||||
|
||||
def _status_from_snapshot(
|
||||
biz_type: str, snapshot: Optional[Dict[str, Any]]
|
||||
) -> Optional[str]:
|
||||
if not snapshot:
|
||||
return None
|
||||
if biz_type == BIZ_CELL:
|
||||
protocol = snapshot.get("protocol") or {}
|
||||
return (protocol.get("protocolStatus") or "").strip() or None
|
||||
donation = snapshot.get("donation") or {}
|
||||
return (donation.get("donationStatus") or "").strip() or None
|
||||
|
||||
|
||||
def append_protocol_operation_log(
|
||||
db: Session,
|
||||
*,
|
||||
biz_type: str,
|
||||
biz_id: str,
|
||||
action: str,
|
||||
client_channel: str = CHANNEL_WEB,
|
||||
before: Optional[Dict[str, Any]] = None,
|
||||
after: Optional[Dict[str, Any]] = None,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
operator_user_name: Optional[str] = None,
|
||||
operate_time: Optional[datetime] = None,
|
||||
remark: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> BizProtocolOperationLog:
|
||||
"""追加一条操作日志(不 commit)。"""
|
||||
now = operate_time or datetime.now()
|
||||
name = (operator_user_name or "").strip() or _operator_name(db, user_id)
|
||||
protocol_no = _protocol_no_from_snapshot(biz_type, after) or _protocol_no_from_snapshot(
|
||||
biz_type, before
|
||||
)
|
||||
obj = BizProtocolOperationLog(
|
||||
id=generate_uuid(),
|
||||
biz_type=(biz_type or "").strip(),
|
||||
biz_id=(biz_id or "").strip(),
|
||||
protocol_no=protocol_no,
|
||||
action=(action or "").strip() or ACTION_UPDATE,
|
||||
client_channel=(client_channel or CHANNEL_WEB).strip() or CHANNEL_WEB,
|
||||
before_json=_json_dumps(before),
|
||||
after_json=_json_dumps(after),
|
||||
operator_user_id=(user_id or "").strip() or None,
|
||||
operator_user_name=name or None,
|
||||
operate_time=now,
|
||||
tenant_no=tenant_no,
|
||||
order_num=0,
|
||||
remark=(remark or "").strip() or None,
|
||||
create_by=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def record_cell_operation(
|
||||
db: Session,
|
||||
*,
|
||||
protocol_id: str,
|
||||
action: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
client_channel: str = CHANNEL_WEB,
|
||||
before: Optional[Dict[str, Any]] = None,
|
||||
after: Optional[Dict[str, Any]] = None,
|
||||
remark: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> Optional[BizProtocolOperationLog]:
|
||||
"""记录细胞存储协议操作;after 缺省时按当前库态补齐。"""
|
||||
pid = (protocol_id or "").strip()
|
||||
if not pid:
|
||||
return None
|
||||
after_snap = after
|
||||
if after_snap is None and action != ACTION_DELETE:
|
||||
after_snap = snapshot_cell_protocol(db, protocol_id=pid, tenant_no=tenant_no)
|
||||
resolved = resolve_action_from_status(
|
||||
default_action=action,
|
||||
before_status=_status_from_snapshot(BIZ_CELL, before),
|
||||
after_status=_status_from_snapshot(BIZ_CELL, after_snap),
|
||||
)
|
||||
return append_protocol_operation_log(
|
||||
db,
|
||||
biz_type=BIZ_CELL,
|
||||
biz_id=pid,
|
||||
action=resolved,
|
||||
client_channel=client_channel,
|
||||
before=before,
|
||||
after=after_snap,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
remark=remark,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
|
||||
def record_donation_operation(
|
||||
db: Session,
|
||||
*,
|
||||
donation_id: str,
|
||||
action: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
client_channel: str = CHANNEL_WEB,
|
||||
before: Optional[Dict[str, Any]] = None,
|
||||
after: Optional[Dict[str, Any]] = None,
|
||||
remark: Optional[str] = None,
|
||||
create_dept_id: str = "",
|
||||
) -> Optional[BizProtocolOperationLog]:
|
||||
"""记录科研捐赠协议操作;after 缺省时按当前库态补齐。"""
|
||||
did = (donation_id or "").strip()
|
||||
if not did:
|
||||
return None
|
||||
after_snap = after
|
||||
if after_snap is None and action != ACTION_DELETE:
|
||||
after_snap = snapshot_donation(db, donation_id=did, tenant_no=tenant_no)
|
||||
resolved = resolve_action_from_status(
|
||||
default_action=action,
|
||||
before_status=_status_from_snapshot(BIZ_DONATION, before),
|
||||
after_status=_status_from_snapshot(BIZ_DONATION, after_snap),
|
||||
)
|
||||
return append_protocol_operation_log(
|
||||
db,
|
||||
biz_type=BIZ_DONATION,
|
||||
biz_id=did,
|
||||
action=resolved,
|
||||
client_channel=client_channel,
|
||||
before=before,
|
||||
after=after_snap,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
remark=remark,
|
||||
create_dept_id=create_dept_id,
|
||||
)
|
||||
|
||||
|
||||
def _row_to_dict(row: BizProtocolOperationLog) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row.id,
|
||||
"bizType": row.biz_type,
|
||||
"bizId": row.biz_id,
|
||||
"protocolNo": row.protocol_no or "",
|
||||
"action": row.action,
|
||||
"clientChannel": row.client_channel or CHANNEL_WEB,
|
||||
"before": _json_loads(row.before_json),
|
||||
"after": _json_loads(row.after_json),
|
||||
"operatorUserId": row.operator_user_id or "",
|
||||
"operatorUserName": row.operator_user_name or "",
|
||||
"operateTime": row.operate_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if row.operate_time
|
||||
else "",
|
||||
"tenantNo": row.tenant_no,
|
||||
"remark": row.remark or "",
|
||||
"createTime": row.create_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if row.create_time
|
||||
else "",
|
||||
}
|
||||
|
||||
|
||||
def list_protocol_operation_logs(
|
||||
db: Session,
|
||||
*,
|
||||
biz_type: str,
|
||||
biz_id: str,
|
||||
tenant_no: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
rows = (
|
||||
db.query(BizProtocolOperationLog)
|
||||
.filter(
|
||||
BizProtocolOperationLog.biz_type == (biz_type or "").strip(),
|
||||
BizProtocolOperationLog.biz_id == (biz_id or "").strip(),
|
||||
BizProtocolOperationLog.tenant_no == tenant_no,
|
||||
BizProtocolOperationLog.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizProtocolOperationLog.operate_time.asc(),
|
||||
BizProtocolOperationLog.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
@@ -56,12 +56,14 @@ def _validate_batch_payload(batch: Dict[str, Any], index: int) -> None:
|
||||
assignees = batch.get("assigneeUserIds") or batch.get("assignee_user_ids") or []
|
||||
if not batch_name:
|
||||
raise ValueError(f"{label}:请填写批次名称")
|
||||
if not assignees:
|
||||
raise ValueError(f"{label}:请指定负责人")
|
||||
if not lab_ids and not third_ids:
|
||||
raise ValueError(f"{label}:请至少选择一个检测项目")
|
||||
if len(lab_ids) != len(set(lab_ids)):
|
||||
raise ValueError(f"{label}:实验室项目不能重复")
|
||||
if len(third_ids) != len(set(third_ids)):
|
||||
raise ValueError(f"{label}:第三方项目不能重复")
|
||||
if not assignees:
|
||||
raise ValueError(f"{label}:请指定负责人")
|
||||
|
||||
|
||||
def _order_source_for_wo(wo: BizQcWorkOrder) -> str:
|
||||
@@ -834,8 +836,13 @@ def configure_work_order_batches(
|
||||
}
|
||||
keep_batch_ids = {((b.get("id") or "").strip()) for b in batches if (b.get("id") or "").strip()}
|
||||
|
||||
# 提交空 batches = 显式清空:强制软删批次及关联任务(含进行中/已完成)
|
||||
# 提交空 batches = 显式清空;提交批次与库中无交集 = 前端「清空全部后再添加」全量重建
|
||||
# 两种情况都强制软删旧批次及关联任务(含进行中/已完成)
|
||||
force_clear_all = not batches
|
||||
full_replace = bool(batches) and bool(existing_batches) and keep_batch_ids.isdisjoint(
|
||||
existing_batches.keys()
|
||||
)
|
||||
force_remove = is_voided or force_clear_all or full_replace
|
||||
for bid, batch_row in existing_batches.items():
|
||||
if bid not in keep_batch_ids:
|
||||
_soft_delete_batch(
|
||||
@@ -843,7 +850,7 @@ def configure_work_order_batches(
|
||||
batch_row,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
force=is_voided or force_clear_all,
|
||||
force=force_remove,
|
||||
)
|
||||
|
||||
fee_total = Decimal("0")
|
||||
|
||||
@@ -3050,6 +3050,17 @@ def configure_work_order(
|
||||
|
||||
|
||||
def try_complete_work_order(db: Session, work_order_id: str, tenant_no: str) -> None:
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
active = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
pending = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
@@ -3060,28 +3071,31 @@ def try_complete_work_order(db: Session, work_order_id: str, tenant_no: str) ->
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if pending:
|
||||
return
|
||||
wo = get_work_order(db, work_order_id, tenant_no=tenant_no)
|
||||
if wo and wo.work_order_status == "ypz":
|
||||
wo.work_order_status = "ywc"
|
||||
task_conclusions = [
|
||||
c
|
||||
for (c,) in db.query(BizQcTask.conclusion)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
]
|
||||
derived = build_qc_conclusion_fields(task_conclusions)["qcConclusion"]
|
||||
wo.conclusion = derived or None
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
|
||||
if not wo or not can_auto_complete_work_order(
|
||||
status=wo.work_order_status or "",
|
||||
active_task_count=active,
|
||||
pending_task_count=pending,
|
||||
):
|
||||
return
|
||||
wo.work_order_status = "ywc"
|
||||
task_conclusions = [
|
||||
c
|
||||
for (c,) in db.query(BizQcTask.conclusion)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
]
|
||||
derived = build_qc_conclusion_fields(task_conclusions)["qcConclusion"]
|
||||
wo.conclusion = derived or None
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
|
||||
|
||||
sync_schedule_from_qc_work_order(db, wo)
|
||||
sync_schedule_from_qc_work_order(db, wo)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -3418,6 +3432,18 @@ def configure_temp_qc_work_order(
|
||||
def try_complete_temp_qc_work_order(
|
||||
db: Session, work_order_id: str, tenant_no: str
|
||||
) -> None:
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
active = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
BizQcTask.work_order_id == work_order_id,
|
||||
BizQcTask.order_source == "temporary",
|
||||
BizQcTask.tenant_no == tenant_no,
|
||||
BizQcTask.del_flag == 0,
|
||||
)
|
||||
.count()
|
||||
)
|
||||
pending = (
|
||||
db.query(BizQcTask)
|
||||
.filter(
|
||||
@@ -3429,14 +3455,17 @@ def try_complete_temp_qc_work_order(
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if pending:
|
||||
return
|
||||
wo = get_temp_qc_work_order(db, work_order_id, tenant_no)
|
||||
if wo and wo.work_order_status == "ypz":
|
||||
wo.work_order_status = "ywc"
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
db.commit()
|
||||
if not wo or not can_auto_complete_work_order(
|
||||
status=wo.work_order_status or "",
|
||||
active_task_count=active,
|
||||
pending_task_count=pending,
|
||||
):
|
||||
return
|
||||
wo.work_order_status = "ywc"
|
||||
wo.update_time = datetime.now()
|
||||
db.add(wo)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_temp_qc_work_order(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1676,6 +1686,16 @@ def change_status(
|
||||
)
|
||||
|
||||
db.add(obj)
|
||||
if ns == "yzf" and (current or "").strip() != "yzf":
|
||||
from app.services.protocol_reception_discard_sync import after_protocol_voided
|
||||
|
||||
after_protocol_voided(
|
||||
db,
|
||||
source="research_donation",
|
||||
protocol_id=obj.id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
@@ -1704,6 +1724,7 @@ def convert_from_storage(
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.populate_existing().with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not proto:
|
||||
@@ -1772,6 +1793,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 +1997,7 @@ def convert_to_storage(
|
||||
BizResearchDonation.del_flag == 0,
|
||||
)
|
||||
)
|
||||
.populate_existing().with_for_update()
|
||||
.first()
|
||||
)
|
||||
if not donation:
|
||||
@@ -2033,6 +2056,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,
|
||||
|
||||
@@ -287,9 +287,15 @@ def _donation_rows_to_sample_list_items(
|
||||
),
|
||||
"sourceType": "research_donation",
|
||||
"sourceTypeLabel": "科研捐赠",
|
||||
"consentAttachmentFiles": urls_to_attachment_list(
|
||||
donation.consent_form_urls if donation else None, db, tenant_no
|
||||
),
|
||||
"infectiousDiseaseReportAttachmentFiles": urls_to_attachment_list(
|
||||
donation.infectious_disease_report_urls if donation else None, db, tenant_no
|
||||
),
|
||||
"attachmentFiles": urls_to_attachment_list(
|
||||
donation.attachment_urls if donation else None, db, tenant_no
|
||||
),
|
||||
"etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
|
||||
"transportType": (transport.transport_type if transport else "") or "",
|
||||
"waybillNo": (transport.waybill_no if transport else "") or "",
|
||||
@@ -462,6 +468,12 @@ def _rows_to_sample_list_items(
|
||||
**sample_special_to_dict(sample),
|
||||
"sourceType": "cell_storage",
|
||||
"sourceTypeLabel": "细胞存储",
|
||||
"protocolAttachmentFiles": urls_to_attachment_list(
|
||||
protocol.protocol_attachments if protocol else None, db, tenant_no
|
||||
),
|
||||
"consentAttachmentFiles": urls_to_attachment_list(
|
||||
protocol.consent_form_urls if protocol else None, db, tenant_no
|
||||
),
|
||||
"healthSurveyAttachmentFiles": urls_to_attachment_list(
|
||||
protocol.health_survey_form_urls if protocol else None, db, tenant_no
|
||||
),
|
||||
@@ -1033,6 +1045,12 @@ def create_sample_reception(
|
||||
user_id=user_id,
|
||||
create_dept_id=create_dept_id or "",
|
||||
)
|
||||
if conclusion == "bj":
|
||||
from app.services.protocol_reception_discard_sync import after_sample_reception_saved
|
||||
|
||||
after_sample_reception_saved(
|
||||
db, reception=obj, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
@@ -1151,6 +1169,12 @@ def update_sample_reception(
|
||||
)
|
||||
check_result_svc.sync_legacy_columns_from_results(db, rec, saved_rows)
|
||||
db.add(rec)
|
||||
if conclusion == "bj":
|
||||
from app.services.protocol_reception_discard_sync import after_sample_reception_saved
|
||||
|
||||
after_sample_reception_saved(
|
||||
db, reception=rec, tenant_no=tenant_no, user_id=user_id
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(rec)
|
||||
return rec
|
||||
|
||||
+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, ProjectTeamHistory,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Project", "ProjectTeam", "ProjectTeamMember", "ProjectTeamRegion",
|
||||
"ProjectTeamHospital", "ProjectAssignmentAudit", "ProjectTeamHistory",
|
||||
"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="协议状态")
|
||||
@@ -1148,6 +1149,33 @@ class BizProtocolStatusChange(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizProtocolOperationLog(Base):
|
||||
"""协议单操作过程日志 biz_protocol_operation_log(Issue #24)"""
|
||||
|
||||
__tablename__ = "biz_protocol_operation_log"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
biz_type = Column(String(32), nullable=False, comment="业务类型 cell_storage/research_donation")
|
||||
biz_id = Column(String(64), nullable=False, comment="协议单或捐赠单ID")
|
||||
protocol_no = Column(String(64), nullable=True, comment="协议/捐赠编号快照")
|
||||
action = Column(String(32), nullable=False, comment="动作 create/update/delete/void/...")
|
||||
client_channel = Column(String(16), nullable=False, default="web", comment="来源端 web/mini")
|
||||
before_json = Column(Text, nullable=True, comment="操作前协议信息JSON")
|
||||
after_json = Column(Text, nullable=True, comment="操作后协议信息JSON")
|
||||
operator_user_id = Column(String(64), nullable=True, comment="操作人用户ID")
|
||||
operator_user_name = Column(String(64), nullable=True, comment="操作人姓名快照")
|
||||
operate_time = Column(DateTime, nullable=False, comment="业务操作时间")
|
||||
tenant_no = Column(String(64), nullable=False, default="zkxw", comment="租户编号")
|
||||
order_num = Column(Integer, nullable=True, comment="排序号")
|
||||
remark = Column(String(512), nullable=True, comment="备注")
|
||||
create_by = Column(String(64), nullable=True, comment="创建人id")
|
||||
create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID")
|
||||
create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间")
|
||||
update_by = Column(String(64), nullable=True, comment="更新人id")
|
||||
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间")
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizStorageReleaseReview(Base):
|
||||
"""入库放行审核 biz_storage_release_review"""
|
||||
|
||||
@@ -1361,6 +1389,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 +4065,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,80 @@
|
||||
"""租户项目及归属审计;区域医院关联只记录组织资料。"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, Text, 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))
|
||||
|
||||
|
||||
class ProjectTeamHistory(ProjectFields, Base):
|
||||
"""项目组版本历史;每次成功写入记完整快照。"""
|
||||
__tablename__ = 'biz_project_team_history'
|
||||
__table_args__ = (UniqueConstraint('tenant_no', 'team_id', 'version_no', name='uk_team_version'),)
|
||||
team_id = Column(String(64), nullable=False, index=True)
|
||||
version_no = Column(Integer, nullable=False)
|
||||
action = Column(String(16), nullable=False)
|
||||
snapshot_json = Column(Text, nullable=False)
|
||||
operator_user_id = Column(String(64))
|
||||
operator_user_name = Column(String(100))
|
||||
operate_time = Column(DateTime, nullable=False, default=datetime.now)
|
||||
@@ -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,
|
||||
@@ -16,6 +22,7 @@ from app.core.protocol_status_display import (
|
||||
resolve_void_type_label,
|
||||
)
|
||||
from app.crud import collection_protocol as cell_crud
|
||||
from app.crud import protocol_operation_log as op_log
|
||||
from app.crud import protocol_sample_payment as psp_crud
|
||||
from app.crud import research_donation as donation_crud
|
||||
from app.crud import sample_freight as sample_freight_crud
|
||||
@@ -853,6 +860,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 +889,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 +964,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 '',
|
||||
@@ -971,19 +984,28 @@ def list_protocols(
|
||||
for row in cell_rows
|
||||
)
|
||||
if source in (None, SOURCE_DONATION):
|
||||
if status or collected_from or collected_until:
|
||||
donation_enabled = False
|
||||
else:
|
||||
donation_enabled = True
|
||||
if not donation_enabled:
|
||||
# 混合列表(source=None)下状态/采集时间只覆盖细胞存储,捐赠侧跳过。
|
||||
# 科研捐赠单源列表支持 protocolStatus→donation_status;采集时间筛仍不支持捐赠。
|
||||
skip_donation = False
|
||||
if source is None and (status or collected_from or collected_until):
|
||||
skip_donation = True
|
||||
elif source == SOURCE_DONATION and (collected_from or collected_until):
|
||||
skip_donation = True
|
||||
if skip_donation:
|
||||
query = None
|
||||
else:
|
||||
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:
|
||||
if status and source == SOURCE_DONATION:
|
||||
query = query.filter(BizResearchDonation.donation_status == status)
|
||||
if created_from:
|
||||
query = query.filter(BizResearchDonation.create_time >= created_from)
|
||||
if created_until:
|
||||
@@ -1036,6 +1058,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 +1481,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:
|
||||
@@ -1514,6 +1537,12 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic
|
||||
collection_protocol_id=row.id,
|
||||
tenant_no=tenant_no,
|
||||
),
|
||||
'operationLogs': op_log.list_protocol_operation_logs(
|
||||
db,
|
||||
biz_type=op_log.BIZ_CELL,
|
||||
biz_id=row.id,
|
||||
tenant_no=tenant_no,
|
||||
),
|
||||
'attachments': {
|
||||
'protocolAttachments': row.protocol_attachments or '',
|
||||
'consentFormUrls': row.consent_form_urls or '',
|
||||
@@ -1532,7 +1561,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(
|
||||
@@ -1554,6 +1583,12 @@ def _donation_detail(
|
||||
research_donation_id=row.id,
|
||||
tenant_no=tenant_no,
|
||||
),
|
||||
'operationLogs': op_log.list_protocol_operation_logs(
|
||||
db,
|
||||
biz_type=op_log.BIZ_DONATION,
|
||||
biz_id=row.id,
|
||||
tenant_no=tenant_no,
|
||||
),
|
||||
'attachments': {
|
||||
'consentFormUrls': basic.get('consentFormUrls') or '',
|
||||
'attachmentUrls': basic.get('attachmentUrls') or '',
|
||||
@@ -1824,17 +1859,45 @@ def delete_protocol(
|
||||
_lock_cell_protocol_access(
|
||||
db, protocol_id, ctx, user_info=user_info
|
||||
)
|
||||
before_snap = op_log.snapshot_cell_protocol(
|
||||
db, protocol_id=protocol_id, tenant_no=ctx.tenant_no
|
||||
)
|
||||
cell_crud.soft_delete_collection_protocol(
|
||||
db, protocol_id, ctx.tenant_no, user_id=ctx.user_id
|
||||
)
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id=protocol_id,
|
||||
action=op_log.ACTION_DELETE,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
client_channel=op_log.CHANNEL_MINI,
|
||||
before=before_snap,
|
||||
after=None,
|
||||
)
|
||||
db.commit()
|
||||
return
|
||||
if source == SOURCE_DONATION:
|
||||
_lock_donation_access(
|
||||
db, protocol_id, ctx, user_info=user_info
|
||||
)
|
||||
before_snap = op_log.snapshot_donation(
|
||||
db, donation_id=protocol_id, tenant_no=ctx.tenant_no
|
||||
)
|
||||
donation_crud.delete_donation(
|
||||
db, protocol_id, ctx.tenant_no, user_id=ctx.user_id
|
||||
)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=protocol_id,
|
||||
action=op_log.ACTION_DELETE,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
client_channel=op_log.CHANNEL_MINI,
|
||||
before=before_snap,
|
||||
after=None,
|
||||
)
|
||||
db.commit()
|
||||
return
|
||||
raise ValueError('不支持的协议来源')
|
||||
|
||||
@@ -1852,6 +1915,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 +1934,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',
|
||||
@@ -1937,6 +2004,17 @@ def create_protocol(
|
||||
assert_cell_protocol_access(db, row.id, ctx, user_info=user_info)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id=row.id,
|
||||
action=op_log.ACTION_CREATE,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
client_channel=op_log.CHANNEL_MINI,
|
||||
before=None,
|
||||
create_dept_id=dept_id,
|
||||
)
|
||||
db.commit()
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=ctx.tenant_no,
|
||||
protocol_id=row.id,
|
||||
@@ -1949,6 +2027,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',
|
||||
@@ -1991,6 +2070,17 @@ def create_protocol(
|
||||
assert_donation_access(db, row.id, ctx, user_info=user_info)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=row.id,
|
||||
action=op_log.ACTION_CREATE,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
client_channel=op_log.CHANNEL_MINI,
|
||||
before=None,
|
||||
create_dept_id=dept_id,
|
||||
)
|
||||
db.commit()
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=ctx.tenant_no,
|
||||
protocol_id=row.id,
|
||||
@@ -2027,6 +2117,10 @@ def update_protocol(
|
||||
row = _lock_cell_protocol_access(
|
||||
db, protocol_id, ctx, user_info=user_info
|
||||
)
|
||||
guard_project_edit(row, _dump(body))
|
||||
before_snap = op_log.snapshot_cell_protocol(
|
||||
db, protocol_id=row.id, tenant_no=ctx.tenant_no
|
||||
)
|
||||
current_collection = cell_crud.get_collection_info(
|
||||
db, row.id, ctx.tenant_no
|
||||
)
|
||||
@@ -2155,6 +2249,17 @@ def update_protocol(
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id=row.id,
|
||||
action=op_log.ACTION_UPDATE,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
client_channel=op_log.CHANNEL_MINI,
|
||||
before=before_snap,
|
||||
create_dept_id=dept_id,
|
||||
)
|
||||
db.commit()
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=ctx.tenant_no,
|
||||
protocol_id=row.id,
|
||||
@@ -2171,6 +2276,10 @@ def update_protocol(
|
||||
row = _lock_donation_access(
|
||||
db, protocol_id, ctx, user_info=user_info
|
||||
)
|
||||
guard_project_edit(row, _dump(body))
|
||||
before_snap = op_log.snapshot_donation(
|
||||
db, donation_id=row.id, tenant_no=ctx.tenant_no
|
||||
)
|
||||
current_collection = donation_crud.get_collection(
|
||||
db, row.id, ctx.tenant_no
|
||||
)
|
||||
@@ -2254,6 +2363,17 @@ def update_protocol(
|
||||
row = assert_donation_access(db, row.id, ctx, user_info=user_info)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
op_log.record_donation_operation(
|
||||
db,
|
||||
donation_id=row.id,
|
||||
action=op_log.ACTION_UPDATE,
|
||||
tenant_no=ctx.tenant_no,
|
||||
user_id=ctx.user_id,
|
||||
client_channel=op_log.CHANNEL_MINI,
|
||||
before=before_snap,
|
||||
create_dept_id=dept_id,
|
||||
)
|
||||
db.commit()
|
||||
schedule_sample_collect_notify(
|
||||
tenant_no=ctx.tenant_no,
|
||||
protocol_id=row.id,
|
||||
|
||||
@@ -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,197 @@
|
||||
"""项目业务明细检索;统计读取与归属维护分别校验菜单权限。"""
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import and_, func, or_
|
||||
|
||||
from app.models.models import (
|
||||
BizChannelServiceOrder, BizCollectionHospital,
|
||||
BizPilotPatient, BizProtocolCollectionInfo, BizProtocolPartyCustomer, BizProtocolSample, SysUser,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonationCollection, BizResearchDonationParty, BizResearchDonationSample,
|
||||
)
|
||||
from app.models.project_management import Project, ProjectTeam
|
||||
from app.services import project_management as projects
|
||||
|
||||
|
||||
PARTY_MODELS = {
|
||||
'cell_storage': (BizProtocolPartyCustomer, BizProtocolCollectionInfo, 'collection_protocol_id'),
|
||||
'research_donation': (BizResearchDonationParty, BizResearchDonationCollection, 'research_donation_id'),
|
||||
}
|
||||
SAMPLE_MODELS = {
|
||||
'cell_storage': BizProtocolSample,
|
||||
'research_donation': BizResearchDonationSample,
|
||||
}
|
||||
|
||||
|
||||
def keyword_condition(db, tenant, kind, keyword):
|
||||
"""用 EXISTS 检索子表,避免多条患者/采集信息放大主单计数。"""
|
||||
model, number, _ = projects.BUSINESS[kind]
|
||||
conditions = [getattr(model, number).contains(keyword, autoescape=True)]
|
||||
if kind in PARTY_MODELS:
|
||||
party, collection, parent_key = PARTY_MODELS[kind]
|
||||
sample = SAMPLE_MODELS[kind]
|
||||
conditions.append(projects.scoped(db, sample, tenant).filter(
|
||||
getattr(sample, parent_key) == model.id,
|
||||
sample.sample_code.contains(keyword, autoescape=True),
|
||||
).exists())
|
||||
conditions.append(projects.scoped(db, party, tenant).filter(
|
||||
getattr(party, parent_key) == model.id,
|
||||
or_(party.mother_name.contains(keyword, autoescape=True),
|
||||
party.mother_phone.contains(keyword, autoescape=True)),
|
||||
).exists())
|
||||
hospitals = projects.scoped(db, BizCollectionHospital, tenant).filter(
|
||||
BizCollectionHospital.hospital_name.contains(keyword, autoescape=True),
|
||||
).with_entities(BizCollectionHospital.id)
|
||||
sales_users = projects.scoped(db, SysUser, tenant).filter(
|
||||
SysUser.nick_name.contains(keyword, autoescape=True),
|
||||
).with_entities(SysUser.id)
|
||||
conditions.append(projects.scoped(db, collection, tenant).filter(
|
||||
getattr(collection, parent_key) == model.id,
|
||||
or_(collection.hospital_id.in_(hospitals), collection.sales_user_id.in_(sales_users)),
|
||||
).exists())
|
||||
elif kind == 'pilot_service':
|
||||
conditions.extend(column.contains(keyword, autoescape=True) for column in (
|
||||
model.application_name, model.channel_name,
|
||||
))
|
||||
conditions.append(projects.scoped(db, BizPilotPatient, tenant).filter(
|
||||
BizPilotPatient.pilot_application_id == model.id,
|
||||
or_(BizPilotPatient.patient_name.contains(keyword, autoescape=True),
|
||||
BizPilotPatient.phone_no.contains(keyword, autoescape=True)),
|
||||
).exists())
|
||||
conditions.append(projects.scoped(db, BizChannelServiceOrder, tenant).filter(
|
||||
BizChannelServiceOrder.id == model.channel_service_order_id,
|
||||
BizChannelServiceOrder.order_name.contains(keyword, autoescape=True),
|
||||
).exists())
|
||||
else:
|
||||
conditions.extend(column.contains(keyword, autoescape=True) for column in (
|
||||
model.project_title, model.customer_name, model.customer_contact, model.customer_phone,
|
||||
))
|
||||
return or_(*conditions)
|
||||
|
||||
|
||||
def child_rows(db, tenant, model, parent_key, ids, fields):
|
||||
"""仅批量读取当前页所需字段;稳定排序保留多条有效子记录。"""
|
||||
result = defaultdict(list)
|
||||
if ids:
|
||||
parent = getattr(model, parent_key)
|
||||
rows = projects.scoped(db, model, tenant).filter(parent.in_(ids)).with_entities(
|
||||
parent, *(getattr(model, field) for field in fields),
|
||||
).order_by(model.create_time, model.id).all()
|
||||
for row in rows:
|
||||
result[getattr(row, parent_key)].append(row)
|
||||
return result
|
||||
|
||||
|
||||
def joined_values(rows, field):
|
||||
return '、'.join(dict.fromkeys(str(getattr(row, field)) for row in rows if getattr(row, field)))
|
||||
|
||||
|
||||
def project_metadata(db, tenant, rows):
|
||||
ids = {row.project_id for row in rows if row.project_id}
|
||||
if not ids:
|
||||
return {}
|
||||
values = projects.scoped(db, Project, tenant).outerjoin(ProjectTeam, and_(
|
||||
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant, ProjectTeam.del_flag == 0,
|
||||
)).filter(Project.id.in_(ids)).with_entities(
|
||||
Project.id, Project.project_name, Project.team_id, ProjectTeam.team_name,
|
||||
).all()
|
||||
return {row.id: dict(projectName=row.project_name, teamId=row.team_id, teamName=row.team_name)
|
||||
for row in values}
|
||||
|
||||
|
||||
def business_details(db, tenant, kind, rows):
|
||||
ids = [row.id for row in rows]
|
||||
result = {row.id: {} for row in rows}
|
||||
if not ids:
|
||||
return result
|
||||
if kind in PARTY_MODELS:
|
||||
party, collection, parent_key = PARTY_MODELS[kind]
|
||||
parties = child_rows(db, tenant, party, parent_key, ids, ('mother_name', 'mother_phone'))
|
||||
samples = child_rows(db, tenant, SAMPLE_MODELS[kind], parent_key, ids, ('sample_code',))
|
||||
collections = child_rows(db, tenant, collection, parent_key, ids,
|
||||
('hospital_id', 'sales_user_id', 'sample_collect_time'))
|
||||
all_collections = [item for values in collections.values() for item in values]
|
||||
hospital_ids = {item.hospital_id for item in all_collections if item.hospital_id}
|
||||
sales_ids = {item.sales_user_id for item in all_collections if item.sales_user_id}
|
||||
hospitals = dict(projects.scoped(db, BizCollectionHospital, tenant).filter(
|
||||
BizCollectionHospital.id.in_(hospital_ids),
|
||||
).with_entities(BizCollectionHospital.id, BizCollectionHospital.hospital_name).all()) if hospital_ids else {}
|
||||
sales_users = dict(projects.scoped(db, SysUser, tenant).filter(
|
||||
SysUser.id.in_(sales_ids),
|
||||
).with_entities(SysUser.id, SysUser.nick_name).all()) if sales_ids else {}
|
||||
for row in rows:
|
||||
info = collections[row.id]
|
||||
details = dict(
|
||||
sampleCodes=joined_values(samples[row.id], 'sample_code'),
|
||||
motherName=joined_values(parties[row.id], 'mother_name'),
|
||||
motherPhone=joined_values(parties[row.id], 'mother_phone'),
|
||||
hospitalName='、'.join(dict.fromkeys(hospitals[item.hospital_id] for item in info
|
||||
if hospitals.get(item.hospital_id))),
|
||||
salesUserName='、'.join(dict.fromkeys(sales_users[item.sales_user_id] for item in info
|
||||
if sales_users.get(item.sales_user_id))),
|
||||
)
|
||||
if kind == 'cell_storage':
|
||||
details['collectionDate'] = '、'.join(dict.fromkeys(
|
||||
item.sample_collect_time.strftime('%Y-%m-%d') for item in info if item.sample_collect_time))
|
||||
else:
|
||||
value = row.donation_date or ''
|
||||
details['donationDate'] = f'{value[:4]}-{value[4:6]}-{value[6:]}' if len(value) == 8 else value
|
||||
result[row.id] = details
|
||||
elif kind == 'pilot_service':
|
||||
patients = child_rows(db, tenant, BizPilotPatient, 'pilot_application_id', ids,
|
||||
('patient_name', 'phone_no'))
|
||||
order_ids = {row.channel_service_order_id for row in rows if row.channel_service_order_id}
|
||||
orders = dict(projects.scoped(db, BizChannelServiceOrder, tenant).filter(
|
||||
BizChannelServiceOrder.id.in_(order_ids),
|
||||
).with_entities(BizChannelServiceOrder.id, BizChannelServiceOrder.order_name).all()) if order_ids else {}
|
||||
for row in rows:
|
||||
result[row.id] = dict(
|
||||
applicationName=row.application_name, channelName=row.channel_name,
|
||||
serviceOrderName=orders.get(row.channel_service_order_id),
|
||||
patientName=joined_values(patients[row.id], 'patient_name'),
|
||||
patientPhone=joined_values(patients[row.id], 'phone_no'),
|
||||
)
|
||||
else:
|
||||
for row in rows:
|
||||
result[row.id] = dict(projectTitle=row.project_title, customerName=row.customer_name,
|
||||
customerContact=row.customer_contact, customerPhone=row.customer_phone)
|
||||
return result
|
||||
|
||||
|
||||
def records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
|
||||
projects.check_permission(db, tenant, user, 'project:assignment:edit')
|
||||
return _records(db, tenant, user, biz_type, page, pageSize, **filters)
|
||||
|
||||
|
||||
def statistics_records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
|
||||
projects.check_permission(db, tenant, user, 'project:statistics:list')
|
||||
return _records(db, tenant, user, biz_type, page, pageSize, **filters)
|
||||
|
||||
|
||||
def _records(db, tenant, user, biz_type, page, pageSize, **filters):
|
||||
if biz_type not in projects.COUNT_TYPES:
|
||||
raise HTTPException(400, '业务明细仅支持四类正式业务')
|
||||
model, number, status = projects.BUSINESS[biz_type]
|
||||
# 继续使用现有租户、主单数据范围、项目/项目组和日期条件。
|
||||
query = projects.filtered_query(db, tenant, user, biz_type, {**filters, 'keyword': None})
|
||||
keyword = (filters.get('keyword') or '').strip()
|
||||
if keyword:
|
||||
query = query.filter(keyword_condition(db, tenant, biz_type, keyword))
|
||||
status_column = getattr(model, status)
|
||||
status_counts = {str(value or ''): count for value, count in query.with_entities(
|
||||
status_column, func.count(model.id),
|
||||
).group_by(status_column).all()}
|
||||
if filters.get('status'):
|
||||
query = query.filter(status_column == filters['status'])
|
||||
total = query.count()
|
||||
rows = query.order_by(model.create_time.desc(), model.id).offset((page - 1) * pageSize).limit(pageSize).all()
|
||||
metadata = project_metadata(db, tenant, rows)
|
||||
details = business_details(db, tenant, biz_type, rows)
|
||||
return dict(list=[dict(
|
||||
id=row.id, bizType=biz_type, bizNo=getattr(row, number), status=getattr(row, status),
|
||||
createTime=row.create_time, projectId=row.project_id,
|
||||
**metadata.get(row.project_id, dict(projectName=None, teamId=None, teamName=None)),
|
||||
**details[row.id],
|
||||
) for row in rows], total=total, statusCounts=status_counts)
|
||||
@@ -0,0 +1,467 @@
|
||||
"""项目资料、权限内统计和关联归属事务。"""
|
||||
import json
|
||||
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, ProjectTeamHistory)
|
||||
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, '分组方式不支持')
|
||||
statistics_types = ('presign', *COUNT_TYPES)
|
||||
totals = dict.fromkeys(statistics_types, 0)
|
||||
unassigned = dict.fromkeys(statistics_types, 0)
|
||||
counts = {}
|
||||
for kind in statistics_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(statistics_types, 0)))
|
||||
item[kind] += count
|
||||
return dict(items=list(items.values()), totals=totals, unassignedTotals=unassigned,
|
||||
presignTotal=totals['presign'])
|
||||
|
||||
|
||||
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 _next_team_history_version(db, tenant, team_id):
|
||||
current = (db.query(ProjectTeamHistory.version_no)
|
||||
.filter(ProjectTeamHistory.tenant_no == tenant,
|
||||
ProjectTeamHistory.team_id == team_id,
|
||||
ProjectTeamHistory.del_flag == 0)
|
||||
.order_by(ProjectTeamHistory.version_no.desc())
|
||||
.with_for_update()
|
||||
.first())
|
||||
return (current[0] if current else 0) + 1
|
||||
|
||||
|
||||
def record_team_history(db, tenant, user, row, action):
|
||||
"""Append-only team snapshot; caller owns the surrounding transaction."""
|
||||
if not isinstance(row, ProjectTeam):
|
||||
return
|
||||
snapshot = serialize_entity(db, tenant, row)
|
||||
operator_id = user.get('user_id')
|
||||
operator = scoped(db, SysUser, tenant).filter(SysUser.id == operator_id).first() if operator_id else None
|
||||
operator_name = (operator.nick_name if operator else None) or (operator_id or '')
|
||||
now = datetime.now()
|
||||
db.add(ProjectTeamHistory(
|
||||
id=generate_uuid(),
|
||||
tenant_no=tenant,
|
||||
team_id=row.id,
|
||||
version_no=_next_team_history_version(db, tenant, row.id),
|
||||
action=action,
|
||||
snapshot_json=json.dumps(snapshot, ensure_ascii=False, default=str, separators=(',', ':')),
|
||||
operator_user_id=operator_id,
|
||||
operator_user_name=operator_name,
|
||||
operate_time=now,
|
||||
create_by=operator_id,
|
||||
create_dept_id=user.get('dept_id') or '',
|
||||
update_by=operator_id,
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
))
|
||||
|
||||
|
||||
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))
|
||||
record_team_history(db, tenant, user, row, 'update' if row_id else 'create')
|
||||
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
|
||||
project_no = str(body.get('projectNo') or '').strip()
|
||||
if not project_no:
|
||||
raise HTTPException(400, '项目编号不能为空')
|
||||
if len(project_no) > 64:
|
||||
raise HTTPException(400, '项目编号不能超过 64 个字符')
|
||||
conflict = scoped(db, Project, tenant).filter(Project.project_no == project_no)
|
||||
if row_id:
|
||||
conflict = conflict.filter(Project.id != row.id)
|
||||
if conflict.with_for_update().first():
|
||||
raise HTTPException(400, '项目编号已存在')
|
||||
row.project_no = project_no
|
||||
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, '项目组已被项目引用,请停用')
|
||||
if model is ProjectTeam:
|
||||
record_team_history(db, tenant, user, row, 'delete')
|
||||
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}
|
||||
@@ -0,0 +1,421 @@
|
||||
"""市场协议作废 ↔ 质检样本接收废弃双向联动。
|
||||
|
||||
规则(细胞存储 / 科研捐赠):
|
||||
1. 协议变为已作废(yzf)时,协议下全部样本接收结论自动为废弃(bj)。
|
||||
2. 协议下全部有效样本均已废弃/拒收(bj)时,协议自动已作废,作废类型为「实验室废弃」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolSample,
|
||||
BizSampleReception,
|
||||
SysDictData,
|
||||
)
|
||||
from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
|
||||
|
||||
LAB_VOID_TYPE_CODE = "sysfq"
|
||||
LAB_VOID_TYPE_LABEL = "实验室废弃"
|
||||
VOID_TYPE_DICT = "xyzflx"
|
||||
|
||||
_AUTO_DISCARD_REMARK = "协议作废自动废弃"
|
||||
_AUTO_VOID_DESC = "全部样本接收废弃/拒收,系统自动作废"
|
||||
|
||||
|
||||
def ensure_lab_void_type(db: Session, *, tenant_no: str) -> str:
|
||||
"""确保租户字典 xyzflx 存在「实验室废弃」,返回 dict_value。"""
|
||||
tenant = (tenant_no or "").strip()
|
||||
if not tenant:
|
||||
raise ValueError("租户编号不能为空")
|
||||
|
||||
by_value = (
|
||||
db.query(SysDictData)
|
||||
.filter(
|
||||
SysDictData.dict_type == VOID_TYPE_DICT,
|
||||
SysDictData.dict_value == LAB_VOID_TYPE_CODE,
|
||||
SysDictData.tenant_no == tenant,
|
||||
SysDictData.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if by_value:
|
||||
return LAB_VOID_TYPE_CODE
|
||||
|
||||
by_label = (
|
||||
db.query(SysDictData)
|
||||
.filter(
|
||||
SysDictData.dict_type == VOID_TYPE_DICT,
|
||||
SysDictData.dict_label == LAB_VOID_TYPE_LABEL,
|
||||
SysDictData.tenant_no == tenant,
|
||||
SysDictData.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if by_label and (by_label.dict_value or "").strip():
|
||||
return (by_label.dict_value or "").strip()
|
||||
|
||||
now = datetime.now()
|
||||
db.add(
|
||||
SysDictData(
|
||||
id=generate_uuid(),
|
||||
dict_label=LAB_VOID_TYPE_LABEL,
|
||||
dict_value=LAB_VOID_TYPE_CODE,
|
||||
dict_type=VOID_TYPE_DICT,
|
||||
tenant_no=tenant,
|
||||
order_num=90,
|
||||
remark="issue#31 实验室废弃自动作废类型",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
return LAB_VOID_TYPE_CODE
|
||||
|
||||
|
||||
def _list_cell_samples(db: Session, *, protocol_id: str, tenant_no: str) -> List[BizProtocolSample]:
|
||||
return (
|
||||
db.query(BizProtocolSample)
|
||||
.filter(
|
||||
BizProtocolSample.collection_protocol_id == protocol_id,
|
||||
BizProtocolSample.tenant_no == tenant_no,
|
||||
BizProtocolSample.del_flag == 0,
|
||||
)
|
||||
.order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _list_donation_samples(
|
||||
db: Session, *, donation_id: str, tenant_no: str
|
||||
) -> List[BizResearchDonationSample]:
|
||||
return (
|
||||
db.query(BizResearchDonationSample)
|
||||
.filter(
|
||||
BizResearchDonationSample.research_donation_id == donation_id,
|
||||
BizResearchDonationSample.tenant_no == tenant_no,
|
||||
BizResearchDonationSample.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizResearchDonationSample.order_num.asc(),
|
||||
BizResearchDonationSample.create_time.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _find_reception(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
source: str,
|
||||
sample_code: str,
|
||||
) -> Optional[BizSampleReception]:
|
||||
code = (sample_code or "").strip()
|
||||
if not code:
|
||||
return None
|
||||
return (
|
||||
db.query(BizSampleReception)
|
||||
.filter(
|
||||
BizSampleReception.tenant_no == tenant_no,
|
||||
BizSampleReception.del_flag == 0,
|
||||
BizSampleReception.reception_source == source,
|
||||
BizSampleReception.sample_code == code,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _upsert_discard_reception(
|
||||
db: Session,
|
||||
*,
|
||||
source: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str],
|
||||
sample_code: str,
|
||||
protocol_sample_id: str = "",
|
||||
collection_protocol_id: str = "",
|
||||
donation_sample_id: Optional[str] = None,
|
||||
research_donation_id: Optional[str] = None,
|
||||
now: Optional[datetime] = None,
|
||||
) -> None:
|
||||
code = (sample_code or "").strip()
|
||||
if not code:
|
||||
return
|
||||
ts = now or datetime.now()
|
||||
rec = _find_reception(db, tenant_no=tenant_no, source=source, sample_code=code)
|
||||
if rec:
|
||||
if (rec.accept_conclusion or "").strip() == "bj":
|
||||
return
|
||||
rec.accept_conclusion = "bj"
|
||||
rec.update_by = user_id
|
||||
rec.update_time = ts
|
||||
if not (rec.remark or "").strip():
|
||||
rec.remark = _AUTO_DISCARD_REMARK
|
||||
db.add(rec)
|
||||
return
|
||||
|
||||
obj = BizSampleReception(
|
||||
id=generate_uuid(),
|
||||
protocol_sample_id=protocol_sample_id or "",
|
||||
collection_protocol_id=collection_protocol_id or "",
|
||||
reception_time=ts,
|
||||
handler_user_id=user_id,
|
||||
handler_name=None,
|
||||
receive_temperature=None,
|
||||
accept_conclusion="bj",
|
||||
receive_attachments=None,
|
||||
reception_source=source,
|
||||
donation_sample_id=donation_sample_id,
|
||||
research_donation_id=research_donation_id,
|
||||
sample_code=code,
|
||||
lab_storage_code=None,
|
||||
tenant_no=tenant_no,
|
||||
remark=_AUTO_DISCARD_REMARK,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
update_by=user_id,
|
||||
create_time=ts,
|
||||
update_time=ts,
|
||||
del_flag=0,
|
||||
check_packaging=0,
|
||||
check_sealing=0,
|
||||
check_no_damage=0,
|
||||
check_no_leakage=0,
|
||||
check_color=0,
|
||||
check_tying_rope=0,
|
||||
check_no_stratification=0,
|
||||
)
|
||||
db.add(obj)
|
||||
|
||||
|
||||
def discard_receptions_for_voided_protocol(
|
||||
db: Session,
|
||||
*,
|
||||
source: str,
|
||||
protocol_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> int:
|
||||
"""协议已作废后,将其下样本接收全部置为废弃。返回处理样本数。"""
|
||||
src = (source or "").strip()
|
||||
pid = (protocol_id or "").strip()
|
||||
tenant = (tenant_no or "").strip()
|
||||
if src not in ("cell_storage", "research_donation") or not pid or not tenant:
|
||||
return 0
|
||||
|
||||
now = datetime.now()
|
||||
if src == "cell_storage":
|
||||
samples = _list_cell_samples(db, protocol_id=pid, tenant_no=tenant)
|
||||
for sample in samples:
|
||||
_upsert_discard_reception(
|
||||
db,
|
||||
source=src,
|
||||
tenant_no=tenant,
|
||||
user_id=user_id,
|
||||
sample_code=sample.sample_code or "",
|
||||
protocol_sample_id=sample.id,
|
||||
collection_protocol_id=pid,
|
||||
now=now,
|
||||
)
|
||||
else:
|
||||
samples = _list_donation_samples(db, donation_id=pid, tenant_no=tenant)
|
||||
for sample in samples:
|
||||
_upsert_discard_reception(
|
||||
db,
|
||||
source=src,
|
||||
tenant_no=tenant,
|
||||
user_id=user_id,
|
||||
sample_code=sample.sample_code or "",
|
||||
donation_sample_id=sample.id,
|
||||
research_donation_id=pid,
|
||||
now=now,
|
||||
)
|
||||
db.flush()
|
||||
return len(samples)
|
||||
|
||||
|
||||
def _all_samples_discarded(
|
||||
db: Session,
|
||||
*,
|
||||
source: str,
|
||||
protocol_id: str,
|
||||
tenant_no: str,
|
||||
) -> bool:
|
||||
src = (source or "").strip()
|
||||
if src == "cell_storage":
|
||||
samples = _list_cell_samples(db, protocol_id=protocol_id, tenant_no=tenant_no)
|
||||
else:
|
||||
samples = _list_donation_samples(db, donation_id=protocol_id, tenant_no=tenant_no)
|
||||
if not samples:
|
||||
return False
|
||||
for sample in samples:
|
||||
code = (sample.sample_code or "").strip()
|
||||
if not code:
|
||||
return False
|
||||
rec = _find_reception(db, tenant_no=tenant_no, source=src, sample_code=code)
|
||||
if not rec or (rec.accept_conclusion or "").strip() != "bj":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def maybe_void_protocol_when_all_samples_discarded(
|
||||
db: Session,
|
||||
*,
|
||||
source: str,
|
||||
protocol_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""若协议下样本全部废弃,则自动作废协议(作废类型=实验室废弃)。返回是否执行了作废。"""
|
||||
src = (source or "").strip()
|
||||
pid = (protocol_id or "").strip()
|
||||
tenant = (tenant_no or "").strip()
|
||||
if src not in ("cell_storage", "research_donation") or not pid or not tenant:
|
||||
return False
|
||||
if not _all_samples_discarded(db, source=src, protocol_id=pid, tenant_no=tenant):
|
||||
return False
|
||||
|
||||
void_type = ensure_lab_void_type(db, tenant_no=tenant)
|
||||
now = datetime.now()
|
||||
|
||||
if src == "cell_storage":
|
||||
obj = (
|
||||
db.query(BizCollectionProtocol)
|
||||
.filter(
|
||||
BizCollectionProtocol.id == pid,
|
||||
BizCollectionProtocol.tenant_no == tenant,
|
||||
BizCollectionProtocol.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
return False
|
||||
status = (obj.protocol_status or "").strip()
|
||||
if status in ("yzf", "sdfq"):
|
||||
return False
|
||||
from app.core.protocol_status_display import assert_protocol_status_transition
|
||||
from app.crud.collection_protocol import update_collection_protocol
|
||||
|
||||
try:
|
||||
assert_protocol_status_transition(status, "yzf", kind="protocol")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
update_collection_protocol(
|
||||
db,
|
||||
pid,
|
||||
tenant_no=tenant,
|
||||
user_id=user_id,
|
||||
protocol_status="yzf",
|
||||
void_time=now,
|
||||
void_desc=_AUTO_VOID_DESC,
|
||||
void_type=void_type,
|
||||
commit=False,
|
||||
sync_reception_discard=False,
|
||||
)
|
||||
return True
|
||||
|
||||
obj = (
|
||||
db.query(BizResearchDonation)
|
||||
.filter(
|
||||
BizResearchDonation.id == pid,
|
||||
BizResearchDonation.tenant_no == tenant,
|
||||
BizResearchDonation.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
return False
|
||||
status = (obj.donation_status or "").strip()
|
||||
if status in ("yzf", "sdfq"):
|
||||
return False
|
||||
|
||||
# 捐赠 change_status 默认 commit;此处内联等价逻辑避免提前提交。
|
||||
from app.core.protocol_status_display import assert_protocol_status_transition
|
||||
from app.crud.dict_data import normalize_void_type
|
||||
from app.crud.research_donation import (
|
||||
_DONATION_STATUS_CHANGE_TYPE,
|
||||
append_donation_status_change,
|
||||
)
|
||||
|
||||
current = obj.donation_status or ""
|
||||
try:
|
||||
assert_protocol_status_transition(current, "yzf", kind="donation")
|
||||
except ValueError:
|
||||
return False
|
||||
normalized = normalize_void_type(db, value=void_type, tenant_no=tenant)
|
||||
obj.donation_status = "yzf"
|
||||
obj.void_time = now
|
||||
obj.void_desc = _AUTO_VOID_DESC
|
||||
obj.void_type = normalized
|
||||
obj.update_by = user_id
|
||||
obj.update_time = now
|
||||
change_type = _DONATION_STATUS_CHANGE_TYPE.get("yzf")
|
||||
if change_type:
|
||||
append_donation_status_change(
|
||||
db,
|
||||
research_donation_id=obj.id,
|
||||
change_type=change_type,
|
||||
from_status=current,
|
||||
to_status="yzf",
|
||||
change_time=now,
|
||||
change_desc=_AUTO_VOID_DESC,
|
||||
void_type=normalized,
|
||||
tenant_no=tenant,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return True
|
||||
|
||||
|
||||
def after_sample_reception_saved(
|
||||
db: Session,
|
||||
*,
|
||||
reception: BizSampleReception,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""样本接收保存后:若结论为废弃,尝试自动作废所属协议。"""
|
||||
if (reception.accept_conclusion or "").strip() != "bj":
|
||||
return
|
||||
source = (reception.reception_source or "cell_storage").strip() or "cell_storage"
|
||||
if source == "cell_storage":
|
||||
protocol_id = (reception.collection_protocol_id or "").strip()
|
||||
else:
|
||||
protocol_id = (reception.research_donation_id or "").strip()
|
||||
if not protocol_id:
|
||||
return
|
||||
maybe_void_protocol_when_all_samples_discarded(
|
||||
db,
|
||||
source=source,
|
||||
protocol_id=protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
|
||||
def after_protocol_voided(
|
||||
db: Session,
|
||||
*,
|
||||
source: str,
|
||||
protocol_id: str,
|
||||
tenant_no: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""协议作废后:同步废弃其下全部样本接收。"""
|
||||
discard_receptions_for_voided_protocol(
|
||||
db,
|
||||
source=source,
|
||||
protocol_id=protocol_id,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""质检工单自动完成判定(无 DB 依赖,供 CRUD 与单测共用)。"""
|
||||
|
||||
|
||||
def can_auto_complete_work_order(
|
||||
*, status: str, active_task_count: int, pending_task_count: int
|
||||
) -> bool:
|
||||
"""仅当工单为已配置、存在活跃任务且全部已完成时,才可标为已完成。
|
||||
|
||||
活跃任务数为 0 时不得完成:调整检测清空后重配若尚未产生任务,
|
||||
否则会把「已配置」误标为「已完成」。
|
||||
"""
|
||||
if (status or "").strip() != "ypz":
|
||||
return False
|
||||
if active_task_count <= 0:
|
||||
return False
|
||||
if pending_task_count > 0:
|
||||
return False
|
||||
return True
|
||||
@@ -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'
|
||||
@@ -3733,6 +3733,29 @@ def test_protocol_list_filters_status_create_time_and_sample_collect_time():
|
||||
assert [item['id'] for item in items] == ['hit']
|
||||
|
||||
|
||||
def test_donation_list_filters_by_protocol_status_alias():
|
||||
from app.services.market_protocol import list_protocols
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
hit = _donation(db, 'don-hit', created=datetime(2026, 10, 8, 9, 0))
|
||||
hit.donation_status = 'sxz'
|
||||
miss = _donation(db, 'don-miss', created=datetime(2026, 10, 8, 9, 0))
|
||||
miss.donation_status = 'dsx'
|
||||
db.commit()
|
||||
|
||||
items, total = list_protocols(
|
||||
db,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
source='research_donation',
|
||||
protocol_status='sxz',
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert [item['id'] for item in items] == ['don-hit']
|
||||
|
||||
|
||||
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
|
||||
from app.services.market_protocol import create_protocol
|
||||
|
||||
|
||||
@@ -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,248 @@
|
||||
"""归属维护业务检索的权限、分页及关联资料边界。"""
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.models import (
|
||||
BizChannelServiceOrder, BizCollectionHospital, BizCollectionProtocol,
|
||||
BizPilotApplication, BizPilotPatient, BizProtocolCollectionInfo,
|
||||
BizProtocolPartyCustomer, BizProtocolSample, SysMenu, SysRole, SysRoleMenu, SysUser, SysUserRole,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizResearchDonation, BizResearchDonationParty, BizResearchDonationSample,
|
||||
)
|
||||
from app.services import project_assignment_records as svc
|
||||
from app.services import project_management
|
||||
|
||||
|
||||
class ProjectAssignmentRecordsTest(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='u', role='super_admin', tenant_no='t')
|
||||
|
||||
def tearDown(self):
|
||||
self.db.close()
|
||||
self.engine.dispose()
|
||||
|
||||
def records(self, kind='cell_storage', **filters):
|
||||
return svc.records(self.db, 't', self.user, kind, **filters)
|
||||
|
||||
def grant(self, code):
|
||||
self.db.add_all([
|
||||
SysRole(id=code, tenant_no='t', role_name=code, data_scope='4'),
|
||||
SysMenu(id=code, tenant_no='t', menu_name=code, menu_type='F', perms=code),
|
||||
SysRoleMenu(id=code, tenant_no='t', role_id=code, menu_id=code),
|
||||
SysUserRole(id=code, tenant_no='t', role_id=code, user_id='u'),
|
||||
])
|
||||
self.db.commit()
|
||||
|
||||
def test_assignment_permission_and_parent_data_scope(self):
|
||||
self.user['role'] = 'admin'
|
||||
self.grant('project:statistics:list')
|
||||
with self.assertRaises(HTTPException) as denied:
|
||||
self.records()
|
||||
self.assertEqual(denied.exception.status_code, 403)
|
||||
self.grant('project:assignment:edit')
|
||||
self.grant('project:project:all')
|
||||
for row_id, tenant, owner, deleted in [
|
||||
('own', 't', 'u', 0), ('other', 't', 'other', 0),
|
||||
('foreign', 'foreign', 'u', 0), ('deleted', 't', 'u', 1),
|
||||
]:
|
||||
self.db.add(BizCollectionProtocol(id=row_id, tenant_no=tenant, protocol_no=row_id,
|
||||
create_by=owner, del_flag=deleted))
|
||||
self.db.commit()
|
||||
self.assertEqual([r['id'] for r in self.records()['list']], ['own'])
|
||||
|
||||
def test_statistics_detail_permission_is_separate_from_assignment(self):
|
||||
self.user['role'] = 'admin'
|
||||
self.grant('project:statistics:list')
|
||||
self.db.add_all([
|
||||
BizCollectionProtocol(id='own', tenant_no='t', protocol_no='OWN', create_by='u'),
|
||||
BizCollectionProtocol(id='other', tenant_no='t', protocol_no='OTHER', create_by='other'),
|
||||
BizCollectionProtocol(id='foreign', tenant_no='foreign', protocol_no='FOREIGN', create_by='u'),
|
||||
BizCollectionProtocol(id='deleted', tenant_no='t', protocol_no='DELETED', create_by='u', del_flag=1),
|
||||
BizProtocolPartyCustomer(id='p', tenant_no='t', collection_protocol_id='own', mother_name='Mother', mother_phone='13800000000'),
|
||||
BizProtocolSample(id='s', tenant_no='t', collection_protocol_id='own', sample_code='SAMPLE'),
|
||||
])
|
||||
self.db.commit()
|
||||
result = svc.statistics_records(self.db, 't', self.user, 'cell_storage', keyword='SAMPLE')
|
||||
self.assertEqual(result['total'], 1)
|
||||
self.assertEqual(result['list'][0]['motherName'], 'Mother')
|
||||
self.assertEqual(result['list'][0]['motherPhone'], '13800000000')
|
||||
self.assertEqual(result['list'][0]['sampleCodes'], 'SAMPLE')
|
||||
self.assertEqual(svc.statistics_records(self.db, 't', self.user, 'cell_storage')['total'], 1)
|
||||
with self.assertRaises(HTTPException) as denied:
|
||||
self.records()
|
||||
self.assertEqual(denied.exception.status_code, 403)
|
||||
self.db.get(SysRole, 'project:statistics:list').status = '0'
|
||||
self.db.commit()
|
||||
self.grant('project:assignment:edit')
|
||||
self.assertEqual(self.records()['total'], 1)
|
||||
with self.assertRaises(HTTPException) as denied:
|
||||
svc.statistics_records(self.db, 't', self.user, 'cell_storage')
|
||||
self.assertEqual(denied.exception.status_code, 403)
|
||||
|
||||
def test_four_statistics_details_share_fields_and_match_summary_counts(self):
|
||||
cases = (
|
||||
('cell_storage', BizCollectionProtocol, dict(protocol_no='C'), 'sampleCodes'),
|
||||
('research_donation', BizResearchDonation, dict(donation_no='D'), 'motherName'),
|
||||
('pilot_service', BizPilotApplication, dict(application_no='P', channel_service_order_id='', biz_type='pilot_service', application_name='Pilot'), 'applicationName'),
|
||||
('basic_experiment', BizPilotApplication, dict(application_no='B', channel_service_order_id='', biz_type='basic_experiment', project_title='Study'), 'projectTitle'),
|
||||
)
|
||||
for kind, model, fields, detail_key in cases:
|
||||
self.db.add_all([
|
||||
model(id=kind + '-assigned', tenant_no='t', project_id='project', create_time=datetime(2026, 11, 4), **fields),
|
||||
model(id=kind + '-empty', tenant_no='t', create_time=datetime(2026, 11, 4), **fields),
|
||||
model(id=kind + '-old', tenant_no='t', create_time=datetime(2026, 11, 1), **fields),
|
||||
])
|
||||
self.db.commit()
|
||||
filters = dict(startDate='2026-11-04', endDate='2026-11-04')
|
||||
summary = project_management.statistics(self.db, 't', self.user, **filters)
|
||||
for kind, _, _, detail_key in cases:
|
||||
with self.subTest(kind=kind):
|
||||
details = svc.statistics_records(self.db, 't', self.user, kind, **filters)
|
||||
self.assertEqual(details, self.records(kind, **filters))
|
||||
self.assertEqual(details['total'], summary['totals'][kind])
|
||||
self.assertEqual(details['total'], 2)
|
||||
self.assertIn(detail_key, details['list'][0])
|
||||
unassigned = svc.statistics_records(self.db, 't', self.user, kind, unassigned=True, **filters)
|
||||
self.assertEqual(unassigned['total'], summary['unassignedTotals'][kind])
|
||||
self.assertEqual(unassigned['list'][0]['id'], kind + '-empty')
|
||||
assigned = svc.statistics_records(self.db, 't', self.user, kind, projectId='project', **filters)
|
||||
self.assertEqual(assigned['total'], 1)
|
||||
self.assertEqual(assigned['list'][0]['id'], kind + '-assigned')
|
||||
|
||||
def test_children_do_not_duplicate_counts_or_leak_foreign_deleted_data(self):
|
||||
self.db.add_all([
|
||||
BizCollectionProtocol(id='a', tenant_no='t', protocol_no='A', protocol_status='sxz'),
|
||||
BizCollectionProtocol(id='b', tenant_no='t', protocol_no='B', protocol_status='dsx'),
|
||||
BizProtocolPartyCustomer(id='p1', tenant_no='t', collection_protocol_id='a', mother_name='Alice', mother_phone='13800000001'),
|
||||
BizProtocolPartyCustomer(id='p2', tenant_no='t', collection_protocol_id='a', mother_name='Alice'),
|
||||
BizProtocolPartyCustomer(id='foreign', tenant_no='foreign', collection_protocol_id='a', mother_name='ForeignOnly'),
|
||||
BizProtocolPartyCustomer(id='deleted', tenant_no='t', collection_protocol_id='a', mother_name='DeletedOnly', del_flag=1),
|
||||
BizCollectionHospital(id='h', tenant_no='t', region_id='region', hospital_name='Central'),
|
||||
SysUser(id='sales', tenant_no='t', user_no='sales', nick_name='Salesperson'),
|
||||
BizProtocolCollectionInfo(id='c', tenant_no='t', collection_protocol_id='a', hospital_id='h', sales_user_id='sales', sample_collect_time=datetime(2026, 11, 4, 10)),
|
||||
])
|
||||
self.db.commit()
|
||||
for keyword in ('Alice', '13800000001', 'Central', 'Salesperson'):
|
||||
result = self.records(keyword=keyword, pageSize=1)
|
||||
self.assertEqual(result['total'], 1)
|
||||
self.assertEqual(result['statusCounts'], {'sxz': 1})
|
||||
self.assertEqual(result['list'][0]['motherName'], 'Alice')
|
||||
self.assertEqual(result['list'][0]['collectionDate'], '2026-11-04')
|
||||
for keyword in ('ForeignOnly', 'DeletedOnly', '%', '_'):
|
||||
self.assertEqual(self.records(keyword=keyword)['total'], 0)
|
||||
self.assertEqual(self.records(status='sxz')['statusCounts'], {'sxz': 1, 'dsx': 1})
|
||||
self.assertEqual(self.records(status='sxz')['total'], 1)
|
||||
self.assertEqual(self.records(keyword='Alice', page=2, pageSize=1)['list'], [])
|
||||
self.db.get(BizCollectionHospital, 'h').tenant_no = 'foreign'
|
||||
self.db.get(SysUser, 'sales').del_flag = 1
|
||||
self.db.commit()
|
||||
row = self.records(keyword='Alice')['list'][0]
|
||||
self.assertEqual(row['hospitalName'], '')
|
||||
self.assertEqual(row['salesUserName'], '')
|
||||
self.assertEqual(self.records(keyword='Central')['total'], 0)
|
||||
self.assertEqual(self.records(keyword='Salesperson')['total'], 0)
|
||||
|
||||
def test_donation_fields_and_statistics_contract(self):
|
||||
self.db.add_all([
|
||||
BizResearchDonation(id='d', tenant_no='t', donation_no='D1', donation_date='20261104'),
|
||||
BizResearchDonationParty(id='p', tenant_no='t', research_donation_id='d', mother_name='Donor', mother_phone='13900000000'),
|
||||
])
|
||||
self.db.commit()
|
||||
detail = self.records('research_donation', keyword='13900000000')['list'][0]
|
||||
self.assertEqual(detail['donationDate'], '2026-11-04')
|
||||
self.assertEqual(detail['motherName'], 'Donor')
|
||||
old = project_management.records(self.db, 't', self.user, 'research_donation')['list'][0]
|
||||
self.assertNotIn('motherName', old)
|
||||
self.assertNotIn('motherPhone', old)
|
||||
with self.assertRaises(HTTPException) as unsupported:
|
||||
self.records('presign')
|
||||
self.assertEqual(unsupported.exception.status_code, 400)
|
||||
|
||||
def test_pilot_and_basic_search_and_relation_boundaries(self):
|
||||
self.db.add_all([
|
||||
BizPilotApplication(id='pilot', tenant_no='t', application_no='P1', application_name='Trial', channel_name='Channel', channel_service_order_id='order', biz_type='pilot_service'),
|
||||
BizPilotApplication(id='basic', tenant_no='t', application_no='B1', channel_service_order_id='', biz_type='basic_experiment', project_title='Study', customer_name='Customer', customer_contact='Contact', customer_phone='13600000000'),
|
||||
BizChannelServiceOrder(id='order', tenant_no='t', order_name='Service', channel_id='channel', order_type='gm'),
|
||||
BizPilotPatient(id='p1', tenant_no='t', pilot_application_id='pilot', patient_name='Patient', phone_no='13700000000'),
|
||||
BizPilotPatient(id='p2', tenant_no='t', pilot_application_id='pilot', patient_name='Patient'),
|
||||
BizPilotPatient(id='foreign', tenant_no='foreign', pilot_application_id='pilot', patient_name='ForeignPatient'),
|
||||
BizPilotPatient(id='deleted', tenant_no='t', pilot_application_id='pilot', patient_name='DeletedPatient', del_flag=1),
|
||||
])
|
||||
self.db.commit()
|
||||
for keyword in ('P1', 'Trial', 'Channel', 'Service', 'Patient', '13700000000'):
|
||||
result = self.records('pilot_service', keyword=keyword)
|
||||
self.assertEqual(result['total'], 1)
|
||||
self.assertEqual(result['list'][0]['patientName'], 'Patient')
|
||||
self.assertEqual(result['list'][0]['serviceOrderName'], 'Service')
|
||||
for keyword in ('B1', 'Study', 'Customer', 'Contact', '13600000000'):
|
||||
self.assertEqual(self.records('basic_experiment', keyword=keyword)['total'], 1)
|
||||
self.assertEqual(self.records('pilot_service', keyword=keyword)['total'], 0)
|
||||
for keyword in ('ForeignPatient', 'DeletedPatient'):
|
||||
self.assertEqual(self.records('pilot_service', keyword=keyword)['total'], 0)
|
||||
self.db.get(BizChannelServiceOrder, 'order').tenant_no = 'foreign'
|
||||
self.db.commit()
|
||||
self.assertEqual(self.records('pilot_service', keyword='Service')['total'], 0)
|
||||
self.assertIsNone(self.records('pilot_service')['list'][0]['serviceOrderName'])
|
||||
|
||||
def test_batch_enrichment_does_not_query_per_row(self):
|
||||
for index in range(20):
|
||||
row_id = str(index)
|
||||
self.db.add(BizCollectionProtocol(id=row_id, tenant_no='t', protocol_no=row_id))
|
||||
self.db.add(BizProtocolPartyCustomer(id=row_id, tenant_no='t', collection_protocol_id=row_id, mother_name='Mother'))
|
||||
self.db.commit()
|
||||
statements = []
|
||||
def count_statement(conn, cursor, statement, parameters, context, executemany):
|
||||
statements.append(statement)
|
||||
event.listen(self.engine, 'before_cursor_execute', count_statement)
|
||||
try:
|
||||
self.records(pageSize=1)
|
||||
small = len(statements)
|
||||
statements.clear()
|
||||
self.assertEqual(len(self.records(pageSize=20)['list']), 20)
|
||||
self.assertEqual(len(statements), small)
|
||||
finally:
|
||||
event.remove(self.engine, 'before_cursor_execute', count_statement)
|
||||
|
||||
def test_sample_codes_keep_one_main_row_and_respect_tenant_and_soft_delete(self):
|
||||
for kind, model, parent_key in (
|
||||
('cell_storage', BizProtocolSample, 'collection_protocol_id'),
|
||||
('research_donation', BizResearchDonationSample, 'research_donation_id'),
|
||||
):
|
||||
with self.subTest(kind=kind):
|
||||
if kind == 'cell_storage':
|
||||
parents = [BizCollectionProtocol(id=kind + suffix, tenant_no='t', protocol_no=suffix)
|
||||
for suffix in ('one', 'empty')]
|
||||
else:
|
||||
parents = [BizResearchDonation(id=kind + suffix, tenant_no='t', donation_no=suffix)
|
||||
for suffix in ('one', 'empty')]
|
||||
self.db.add_all(parents)
|
||||
for row_id, code, tenant, deleted in (
|
||||
('1', 'SAMPLE-A', 't', 0), ('2', 'SAMPLE-B', 't', 0),
|
||||
('3', 'SAMPLE-A', 't', 0), ('4', 'FOREIGN-SAMPLE', 'foreign', 0),
|
||||
('5', 'DELETED-SAMPLE', 't', 1), ('6', '', 't', 0), ('7', None, 't', 0),
|
||||
):
|
||||
self.db.add(model(id=kind + row_id, tenant_no=tenant, del_flag=deleted,
|
||||
sample_code=code, create_time=datetime(2026, 11, 4),
|
||||
**{parent_key: kind + 'one'}))
|
||||
self.db.commit()
|
||||
for keyword in ('SAMPLE-A', 'SAMPLE-B', 'SAMPLE-'):
|
||||
result = self.records(kind, keyword=keyword, pageSize=1)
|
||||
self.assertEqual(result['total'], 1)
|
||||
self.assertEqual(sum(result['statusCounts'].values()), 1)
|
||||
self.assertEqual(result['list'][0]['sampleCodes'], 'SAMPLE-A、SAMPLE-B')
|
||||
self.assertEqual(result['list'][0]['id'], kind + 'one')
|
||||
self.assertEqual(self.records(kind, keyword='SAMPLE-', page=2, pageSize=1)['list'], [])
|
||||
for keyword in ('FOREIGN-SAMPLE', 'DELETED-SAMPLE'):
|
||||
self.assertEqual(self.records(kind, keyword=keyword)['total'], 0)
|
||||
rows = {row['id']: row for row in self.records(kind)['list']}
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[kind + 'empty']['sampleCodes'], '')
|
||||
@@ -0,0 +1,293 @@
|
||||
"""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, ProjectTeamHistory
|
||||
from app.models.base import Base
|
||||
self.svc, self.Project, self.Team = svc, Project, ProjectTeam
|
||||
self.History = ProjectTeamHistory
|
||||
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_presign_statistics_column_groups_totals_and_drill_down(self):
|
||||
from datetime import datetime
|
||||
from app.models.models import BizCollectionProtocol, BizPresignOrder
|
||||
self.db.add_all([
|
||||
self.Team(id='g2', tenant_no='t', team_name='Presign only', leader_user_id='u'),
|
||||
self.Project(id='p2', tenant_no='t', project_no='P2', project_name='Presign only',
|
||||
team_id='g2', leader_user_id='u', biz_types='cell_storage', status='active'),
|
||||
BizCollectionProtocol(id='protocol', tenant_no='t', protocol_no='CP', project_id='p',
|
||||
create_time=datetime(2026, 11, 4)),
|
||||
])
|
||||
for row_id, project_id, status, tenant, deleted, day in (
|
||||
('active', 'p', 'active', 't', 0, 4),
|
||||
('converted', 'p', 'converted', 't', 0, 4),
|
||||
('draft', 'p2', 'draft', 't', 0, 4),
|
||||
('void', None, 'void', 't', 0, 4),
|
||||
('foreign', 'p', 'active', 'foreign', 0, 4),
|
||||
('deleted', 'p', 'active', 't', 1, 4),
|
||||
('old', 'p', 'active', 't', 0, 1),
|
||||
):
|
||||
self.db.add(BizPresignOrder(
|
||||
id=row_id, tenant_no=tenant, protocol_no=row_id, project_id=project_id,
|
||||
order_status=status, del_flag=deleted, create_by='u',
|
||||
create_time=datetime(2026, 11, day),
|
||||
converted_protocol_id='protocol' if status == 'converted' else None,
|
||||
))
|
||||
self.db.commit()
|
||||
filters = dict(startDate='2026-11-04', endDate='2026-11-04')
|
||||
for group_by, expected in (('project', {'p': 2, 'p2': 1}), ('team', {'g': 2, 'g2': 1})):
|
||||
with self.subTest(group_by=group_by):
|
||||
result = self.svc.statistics(self.db, 't', self.user, groupBy=group_by, **filters)
|
||||
self.assertEqual(result['totals']['presign'], 4)
|
||||
self.assertEqual(result['presignTotal'], 4)
|
||||
self.assertEqual(result['unassignedTotals']['presign'], 1)
|
||||
self.assertEqual(result['totals']['cell_storage'], 1)
|
||||
self.assertEqual({r['id']: r['presign'] for r in result['items']}, expected)
|
||||
for row in result['items']:
|
||||
drill = {('teamId' if group_by == 'team' else 'projectId'): row['id']}
|
||||
self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', **drill, **filters)['total'], row['presign'])
|
||||
for selection in (dict(teamId='g'), dict(projectId='p')):
|
||||
result = self.svc.statistics(self.db, 't', self.user, **selection, **filters)
|
||||
self.assertEqual(result['totals']['presign'], 2)
|
||||
self.assertEqual(result['unassignedTotals']['presign'], 0)
|
||||
self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', unassigned=True, **filters)['total'], 1)
|
||||
denied = self.svc.statistics(self.db, 't', dict(user_id='outsider', role='admin'), **filters)
|
||||
self.assertEqual(denied['totals']['presign'], 0)
|
||||
self.assertEqual(denied['items'], [])
|
||||
self.assertNotIn('presign', self.svc.COUNT_TYPES)
|
||||
|
||||
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', projectNo='CUSTOM-001', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
|
||||
self.assertEqual(result['projectNo'], 'CUSTOM-001')
|
||||
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)
|
||||
|
||||
def test_team_history_versions_on_create_update_delete(self):
|
||||
import json
|
||||
from app.models.models import SysUser, BizCollectionHospital, BizCollectionRegion
|
||||
from app.models.project_management import ProjectTeamHistory
|
||||
self.db.add_all([
|
||||
SysUser(id='u', tenant_no='t', user_no='u', nick_name='User', status='1'),
|
||||
SysUser(id='m', tenant_no='t', user_no='m', nick_name='Member', status='1'),
|
||||
BizCollectionRegion(id='r1', tenant_no='t', region_name='华东'),
|
||||
BizCollectionHospital(id='h1', tenant_no='t', hospital_name='一院', region_id='r1'),
|
||||
])
|
||||
self.db.commit()
|
||||
created = self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
|
||||
teamName='Alpha', leaderUserId='u', enabled=True,
|
||||
members=[{'userId': 'm'}], regions=[{'regionId': 'r1'}], hospitals=[{'hospitalId': 'h1'}],
|
||||
remark='v1',
|
||||
))
|
||||
team_id = created['id']
|
||||
rows = (self.db.query(ProjectTeamHistory)
|
||||
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
|
||||
.order_by(ProjectTeamHistory.version_no).all())
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].action, 'create')
|
||||
self.assertEqual(rows[0].version_no, 1)
|
||||
snap = json.loads(rows[0].snapshot_json)
|
||||
self.assertEqual(snap['teamName'], 'Alpha')
|
||||
self.assertEqual(snap['leaderName'], 'User')
|
||||
self.assertEqual([m['userId'] for m in snap['members']], ['m'])
|
||||
self.assertEqual([h['hospitalId'] for h in snap['hospitals']], ['h1'])
|
||||
|
||||
self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
|
||||
teamName='Alpha2', leaderUserId='u', enabled=True,
|
||||
members=[{'userId': 'm'}, {'userId': 'u'}], regions=[{'regionId': 'r1'}],
|
||||
hospitals=[{'hospitalId': 'h1'}], remark='v2',
|
||||
), team_id)
|
||||
rows = (self.db.query(ProjectTeamHistory)
|
||||
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
|
||||
.order_by(ProjectTeamHistory.version_no).all())
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[1].action, 'update')
|
||||
self.assertEqual(rows[1].version_no, 2)
|
||||
self.assertEqual(json.loads(rows[1].snapshot_json)['teamName'], 'Alpha2')
|
||||
self.assertEqual(len(json.loads(rows[1].snapshot_json)['members']), 2)
|
||||
|
||||
self.svc.delete_entity(self.db, 't', self.user, self.Team, team_id)
|
||||
rows = (self.db.query(ProjectTeamHistory)
|
||||
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
|
||||
.order_by(ProjectTeamHistory.version_no).all())
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[2].action, 'delete')
|
||||
self.assertEqual(rows[2].version_no, 3)
|
||||
self.assertEqual(json.loads(rows[2].snapshot_json)['teamName'], 'Alpha2')
|
||||
|
||||
|
||||
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,148 @@
|
||||
"""Issue #24:协议操作过程日志 append-only 与动作映射。"""
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
|
||||
def _session():
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolCollectionInfo,
|
||||
BizProtocolOperationLog,
|
||||
BizProtocolPartyCustomer,
|
||||
BizProtocolSample,
|
||||
SysUser,
|
||||
)
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
BizCollectionProtocol.__table__.create(engine)
|
||||
BizProtocolPartyCustomer.__table__.create(engine)
|
||||
BizProtocolCollectionInfo.__table__.create(engine)
|
||||
BizProtocolSample.__table__.create(engine)
|
||||
BizProtocolOperationLog.__table__.create(engine)
|
||||
SysUser.__table__.create(engine)
|
||||
return sessionmaker(bind=engine, autoflush=False, autocommit=False)(), engine
|
||||
|
||||
|
||||
def test_record_cell_update_appends_before_after_and_maps_void_action():
|
||||
from app.crud import protocol_operation_log as op_log
|
||||
from app.models.models import BizCollectionProtocol, BizProtocolPartyCustomer, SysUser
|
||||
|
||||
db, engine = _session()
|
||||
try:
|
||||
now = datetime(2026, 11, 5, 10, 0, 0)
|
||||
db.add(
|
||||
SysUser(
|
||||
id="u1",
|
||||
user_no="u1",
|
||||
nick_name="采集员甲",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizCollectionProtocol(
|
||||
id="p1",
|
||||
protocol_no="XY2401",
|
||||
protocol_status="sxz",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizProtocolPartyCustomer(
|
||||
id="party1",
|
||||
collection_protocol_id="p1",
|
||||
mother_name="旧姓名",
|
||||
tenant_no="zkxw",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
before = op_log.snapshot_cell_protocol(db, protocol_id="p1", tenant_no="zkxw")
|
||||
assert before is not None
|
||||
assert before["protocol"]["protocolStatus"] == "sxz"
|
||||
assert before["partyCustomer"]["motherName"] == "旧姓名"
|
||||
|
||||
row = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == "p1").one()
|
||||
row.protocol_status = "yzf"
|
||||
party = (
|
||||
db.query(BizProtocolPartyCustomer)
|
||||
.filter(BizProtocolPartyCustomer.collection_protocol_id == "p1")
|
||||
.one()
|
||||
)
|
||||
party.mother_name = "新姓名"
|
||||
db.add(row)
|
||||
db.add(party)
|
||||
db.flush()
|
||||
|
||||
op_log.record_cell_operation(
|
||||
db,
|
||||
protocol_id="p1",
|
||||
action=op_log.ACTION_UPDATE,
|
||||
tenant_no="zkxw",
|
||||
user_id="u1",
|
||||
client_channel=op_log.CHANNEL_MINI,
|
||||
before=before,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
items = op_log.list_protocol_operation_logs(
|
||||
db, biz_type=op_log.BIZ_CELL, biz_id="p1", tenant_no="zkxw"
|
||||
)
|
||||
assert len(items) == 1
|
||||
item = items[0]
|
||||
assert item["action"] == "void"
|
||||
assert item["clientChannel"] == "mini"
|
||||
assert item["operatorUserName"] == "采集员甲"
|
||||
assert item["before"]["partyCustomer"]["motherName"] == "旧姓名"
|
||||
assert item["after"]["partyCustomer"]["motherName"] == "新姓名"
|
||||
assert item["after"]["protocol"]["protocolStatus"] == "yzf"
|
||||
finally:
|
||||
db.close()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_resolve_action_from_status_keeps_default_when_unchanged():
|
||||
from app.crud.protocol_operation_log import (
|
||||
ACTION_AUDIT,
|
||||
ACTION_UPDATE,
|
||||
ACTION_VOID,
|
||||
resolve_action_from_status,
|
||||
)
|
||||
|
||||
assert (
|
||||
resolve_action_from_status(
|
||||
default_action=ACTION_UPDATE,
|
||||
before_status="sxz",
|
||||
after_status="sxz",
|
||||
)
|
||||
== ACTION_UPDATE
|
||||
)
|
||||
assert (
|
||||
resolve_action_from_status(
|
||||
default_action=ACTION_UPDATE,
|
||||
before_status="sxz",
|
||||
after_status="yzf",
|
||||
)
|
||||
== ACTION_VOID
|
||||
)
|
||||
assert (
|
||||
resolve_action_from_status(
|
||||
default_action=ACTION_AUDIT,
|
||||
before_status="dsx",
|
||||
after_status="sxz",
|
||||
)
|
||||
== ACTION_AUDIT
|
||||
)
|
||||
@@ -0,0 +1,328 @@
|
||||
"""协议作废 ↔ 样本接收废弃双向联动。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.models import (
|
||||
BizCollectionProtocol,
|
||||
BizProtocolSample,
|
||||
BizProtocolStatusChange,
|
||||
BizSampleReception,
|
||||
SysDictData,
|
||||
)
|
||||
from app.models.research_donation import (
|
||||
BizDonationStatusChange,
|
||||
BizResearchDonation,
|
||||
BizResearchDonationSample,
|
||||
)
|
||||
from app.services.protocol_reception_discard_sync import (
|
||||
LAB_VOID_TYPE_CODE,
|
||||
after_protocol_voided,
|
||||
after_sample_reception_saved,
|
||||
ensure_lab_void_type,
|
||||
)
|
||||
|
||||
|
||||
def _session():
|
||||
engine = create_engine("sqlite://")
|
||||
Base.metadata.create_all(
|
||||
bind=engine,
|
||||
tables=[
|
||||
SysDictData.__table__,
|
||||
BizCollectionProtocol.__table__,
|
||||
BizProtocolSample.__table__,
|
||||
BizProtocolStatusChange.__table__,
|
||||
BizSampleReception.__table__,
|
||||
BizResearchDonation.__table__,
|
||||
BizResearchDonationSample.__table__,
|
||||
BizDonationStatusChange.__table__,
|
||||
],
|
||||
)
|
||||
return sessionmaker(bind=engine)()
|
||||
|
||||
|
||||
def _now():
|
||||
return datetime(2026, 11, 5, 12, 0, 0)
|
||||
|
||||
|
||||
class TestProtocolReceptionDiscardSync(unittest.TestCase):
|
||||
def test_ensure_lab_void_type_creates_dict_row(self):
|
||||
db = _session()
|
||||
code = ensure_lab_void_type(db, tenant_no="tenant-a")
|
||||
self.assertEqual(code, LAB_VOID_TYPE_CODE)
|
||||
row = (
|
||||
db.query(SysDictData)
|
||||
.filter(
|
||||
SysDictData.tenant_no == "tenant-a",
|
||||
SysDictData.dict_type == "xyzflx",
|
||||
SysDictData.dict_value == LAB_VOID_TYPE_CODE,
|
||||
)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(row.dict_label, "实验室废弃")
|
||||
# idempotent
|
||||
self.assertEqual(ensure_lab_void_type(db, tenant_no="tenant-a"), LAB_VOID_TYPE_CODE)
|
||||
|
||||
def test_protocol_void_marks_all_sample_receptions_discarded(self):
|
||||
db = _session()
|
||||
now = _now()
|
||||
db.add(
|
||||
BizCollectionProtocol(
|
||||
id="p1",
|
||||
protocol_no="CP-1",
|
||||
protocol_status="sxz",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizProtocolSample(
|
||||
id="s1",
|
||||
collection_protocol_id="p1",
|
||||
sample_code="ZK001",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizProtocolSample(
|
||||
id="s2",
|
||||
collection_protocol_id="p1",
|
||||
sample_code="ZK002",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizSampleReception(
|
||||
id="r1",
|
||||
protocol_sample_id="s1",
|
||||
collection_protocol_id="p1",
|
||||
reception_time=now,
|
||||
accept_conclusion="js",
|
||||
reception_source="cell_storage",
|
||||
sample_code="ZK001",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
check_packaging=0,
|
||||
check_sealing=0,
|
||||
check_no_damage=0,
|
||||
check_no_leakage=0,
|
||||
check_color=0,
|
||||
check_tying_rope=0,
|
||||
check_no_stratification=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
after_protocol_voided(
|
||||
db,
|
||||
source="cell_storage",
|
||||
protocol_id="p1",
|
||||
tenant_no="tenant-a",
|
||||
user_id="u1",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
rows = (
|
||||
db.query(BizSampleReception)
|
||||
.filter(BizSampleReception.tenant_no == "tenant-a", BizSampleReception.del_flag == 0)
|
||||
.all()
|
||||
)
|
||||
self.assertEqual(len(rows), 2)
|
||||
by_code = {r.sample_code: r.accept_conclusion for r in rows}
|
||||
self.assertEqual(by_code["ZK001"], "bj")
|
||||
self.assertEqual(by_code["ZK002"], "bj")
|
||||
|
||||
def test_all_samples_discarded_auto_voids_protocol_with_lab_type(self):
|
||||
db = _session()
|
||||
now = _now()
|
||||
db.add(
|
||||
BizCollectionProtocol(
|
||||
id="p2",
|
||||
protocol_no="CP-2",
|
||||
protocol_status="sxz",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizProtocolSample(
|
||||
id="s3",
|
||||
collection_protocol_id="p2",
|
||||
sample_code="ZK003",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
rec = BizSampleReception(
|
||||
id="r2",
|
||||
protocol_sample_id="s3",
|
||||
collection_protocol_id="p2",
|
||||
reception_time=now,
|
||||
accept_conclusion="bj",
|
||||
reception_source="cell_storage",
|
||||
sample_code="ZK003",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
check_packaging=0,
|
||||
check_sealing=0,
|
||||
check_no_damage=0,
|
||||
check_no_leakage=0,
|
||||
check_color=0,
|
||||
check_tying_rope=0,
|
||||
check_no_stratification=0,
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
|
||||
after_sample_reception_saved(
|
||||
db, reception=rec, tenant_no="tenant-a", user_id="u1"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
proto = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == "p2").one()
|
||||
self.assertEqual(proto.protocol_status, "yzf")
|
||||
self.assertEqual(proto.void_type, LAB_VOID_TYPE_CODE)
|
||||
self.assertIn("全部样本接收", proto.void_desc or "")
|
||||
|
||||
def test_partial_discard_does_not_void_protocol(self):
|
||||
db = _session()
|
||||
now = _now()
|
||||
db.add(
|
||||
BizCollectionProtocol(
|
||||
id="p3",
|
||||
protocol_no="CP-3",
|
||||
protocol_status="sxz",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
for sid, code in (("s4", "ZK004"), ("s5", "ZK005")):
|
||||
db.add(
|
||||
BizProtocolSample(
|
||||
id=sid,
|
||||
collection_protocol_id="p3",
|
||||
sample_code=code,
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
rec = BizSampleReception(
|
||||
id="r3",
|
||||
protocol_sample_id="s4",
|
||||
collection_protocol_id="p3",
|
||||
reception_time=now,
|
||||
accept_conclusion="bj",
|
||||
reception_source="cell_storage",
|
||||
sample_code="ZK004",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
check_packaging=0,
|
||||
check_sealing=0,
|
||||
check_no_damage=0,
|
||||
check_no_leakage=0,
|
||||
check_color=0,
|
||||
check_tying_rope=0,
|
||||
check_no_stratification=0,
|
||||
)
|
||||
db.add(rec)
|
||||
db.commit()
|
||||
|
||||
after_sample_reception_saved(
|
||||
db, reception=rec, tenant_no="tenant-a", user_id="u1"
|
||||
)
|
||||
db.commit()
|
||||
|
||||
proto = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == "p3").one()
|
||||
self.assertEqual(proto.protocol_status, "sxz")
|
||||
self.assertFalse(proto.void_type)
|
||||
|
||||
def test_donation_void_creates_discard_receptions(self):
|
||||
db = _session()
|
||||
now = _now()
|
||||
db.add(
|
||||
BizResearchDonation(
|
||||
id="d1",
|
||||
donation_no="RD-1",
|
||||
donation_status="sxz",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
BizResearchDonationSample(
|
||||
id="ds1",
|
||||
research_donation_id="d1",
|
||||
sample_code="RD001",
|
||||
tenant_no="tenant-a",
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
after_protocol_voided(
|
||||
db,
|
||||
source="research_donation",
|
||||
protocol_id="d1",
|
||||
tenant_no="tenant-a",
|
||||
user_id="u1",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
rec = (
|
||||
db.query(BizSampleReception)
|
||||
.filter(
|
||||
BizSampleReception.sample_code == "RD001",
|
||||
BizSampleReception.reception_source == "research_donation",
|
||||
)
|
||||
.one()
|
||||
)
|
||||
self.assertEqual(rec.accept_conclusion, "bj")
|
||||
self.assertEqual(rec.research_donation_id, "d1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,81 @@
|
||||
"""质检工单自动完成条件:无活跃任务时不得标为已完成(issue #32)。"""
|
||||
|
||||
import unittest
|
||||
|
||||
|
||||
class TestCanAutoCompleteWorkOrder(unittest.TestCase):
|
||||
def test_zero_active_tasks_must_not_complete(self):
|
||||
"""调整检测清空后重配若尚未产生任务,不能把已配置打成已完成。"""
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
self.assertFalse(
|
||||
can_auto_complete_work_order(
|
||||
status="ypz", active_task_count=0, pending_task_count=0
|
||||
)
|
||||
)
|
||||
|
||||
def test_all_tasks_completed_can_complete(self):
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
self.assertTrue(
|
||||
can_auto_complete_work_order(
|
||||
status="ypz", active_task_count=3, pending_task_count=0
|
||||
)
|
||||
)
|
||||
|
||||
def test_pending_tasks_block_complete(self):
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
self.assertFalse(
|
||||
can_auto_complete_work_order(
|
||||
status="ypz", active_task_count=3, pending_task_count=1
|
||||
)
|
||||
)
|
||||
|
||||
def test_non_configured_status_not_completed(self):
|
||||
from app.services.qc_work_order_complete import can_auto_complete_work_order
|
||||
|
||||
self.assertFalse(
|
||||
can_auto_complete_work_order(
|
||||
status="dpz", active_task_count=2, pending_task_count=0
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
can_auto_complete_work_order(
|
||||
status="ywc", active_task_count=2, pending_task_count=0
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestValidateBatchRequiresItems(unittest.TestCase):
|
||||
def test_batch_without_items_rejected(self):
|
||||
from app.crud.qc_inspection_batch import _validate_batch_payload
|
||||
|
||||
with self.assertRaises(ValueError) as ctx:
|
||||
_validate_batch_payload(
|
||||
{
|
||||
"batchName": "B1",
|
||||
"assigneeUserIds": ["u1"],
|
||||
"labItemIds": [],
|
||||
"thirdPartyItemIds": [],
|
||||
},
|
||||
0,
|
||||
)
|
||||
self.assertIn("检测项目", str(ctx.exception))
|
||||
|
||||
def test_batch_with_lab_item_ok(self):
|
||||
from app.crud.qc_inspection_batch import _validate_batch_payload
|
||||
|
||||
_validate_batch_payload(
|
||||
{
|
||||
"batchName": "B1",
|
||||
"assigneeUserIds": ["u1"],
|
||||
"labItemIds": ["lab-1"],
|
||||
"thirdPartyItemIds": [],
|
||||
},
|
||||
0,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,129 @@
|
||||
"""样本接收列表项应携带协议侧附件字段,供质检查看。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def query(self, *args, **kwargs):
|
||||
return _FakeQuery()
|
||||
|
||||
|
||||
class TestSampleReceptionProtocolAttachments(unittest.TestCase):
|
||||
def test_cell_storage_item_includes_protocol_consent_health_files(self):
|
||||
from app.crud import sample_reception as svc
|
||||
|
||||
protocol = SimpleNamespace(
|
||||
id="proto-1",
|
||||
protocol_no="P1",
|
||||
protocol_status="effective",
|
||||
protocol_attachments="http://a/p.pdf",
|
||||
consent_form_urls="http://a/c.pdf",
|
||||
health_survey_form_urls="http://a/h.pdf",
|
||||
)
|
||||
sample = SimpleNamespace(
|
||||
id="sample-1",
|
||||
sample_code="S001",
|
||||
sample_type_id="t1",
|
||||
collect_amount=None,
|
||||
sample_status="",
|
||||
)
|
||||
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
|
||||
transport = SimpleNamespace(
|
||||
eta_time=None,
|
||||
transport_type="",
|
||||
waybill_no="",
|
||||
express_company_id="",
|
||||
carrier_name="",
|
||||
carrier_phone="",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
"urls_to_attachment_list",
|
||||
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
|
||||
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
|
||||
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
|
||||
), patch(
|
||||
"app.services.qc_work_order_fee_summary.build_protocol_payment_summaries",
|
||||
return_value={},
|
||||
):
|
||||
items = svc._rows_to_sample_list_items(
|
||||
_FakeDb(),
|
||||
[(sample, protocol, party, transport)],
|
||||
"t1",
|
||||
reception_map={},
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
item = items[0]
|
||||
self.assertEqual(item["protocolAttachmentFiles"][0]["fileUrl"], "http://a/p.pdf")
|
||||
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
|
||||
self.assertEqual(item["healthSurveyAttachmentFiles"][0]["fileUrl"], "http://a/h.pdf")
|
||||
|
||||
def test_donation_item_includes_consent_infectious_other_files(self):
|
||||
from app.crud import sample_reception as svc
|
||||
|
||||
donation = SimpleNamespace(
|
||||
id="don-1",
|
||||
donation_no="D1",
|
||||
donation_status="effective",
|
||||
consent_form_urls="http://a/c.pdf",
|
||||
infectious_disease_report_urls="http://a/i.pdf",
|
||||
attachment_urls="http://a/o.pdf",
|
||||
)
|
||||
sample = SimpleNamespace(
|
||||
id="ds-1",
|
||||
sample_code="D001",
|
||||
sample_type_id="t1",
|
||||
collect_amount=None,
|
||||
)
|
||||
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
|
||||
transport = SimpleNamespace(
|
||||
eta_time=None,
|
||||
transport_type="",
|
||||
waybill_no="",
|
||||
express_company_id="",
|
||||
carrier_name="",
|
||||
carrier_phone="",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
"urls_to_attachment_list",
|
||||
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
|
||||
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
|
||||
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
|
||||
):
|
||||
items = svc._donation_rows_to_sample_list_items(
|
||||
_FakeDb(),
|
||||
[(sample, donation, party, transport)],
|
||||
"t1",
|
||||
reception_map={},
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
item = items[0]
|
||||
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
|
||||
self.assertEqual(
|
||||
item["infectiousDiseaseReportAttachmentFiles"][0]["fileUrl"], "http://a/i.pdf"
|
||||
)
|
||||
self.assertEqual(item["attachmentFiles"][0]["fileUrl"], "http://a/o.pdf")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -60,9 +60,17 @@
|
||||
},
|
||||
"figma": {
|
||||
"references": [
|
||||
"https://www.figma.com/design/jc7D8Fe7lKNTVj4jM3AoGW?node-id=2-2"
|
||||
"https://www.figma.com/design/M9XtXqXRCAKccMWD7BivIL",
|
||||
"https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB"
|
||||
],
|
||||
"status": "user-confirmed-demo-only"
|
||||
"status": "web-and-mobile-design-systems-v1",
|
||||
"authority": {
|
||||
"web-admin": "https://www.figma.com/design/M9XtXqXRCAKccMWD7BivIL",
|
||||
"mobile-h5": "https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB",
|
||||
"mobile-oa-h5": "https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB"
|
||||
},
|
||||
"archive": null,
|
||||
"notes": "Web 用 Fresh Mint(#10b981);移动端用独立 Mobile Design System(规范品牌绿 #0a9f6d),禁止混色。旧整页镜像文件已删除。Figma 色板按规范,不以 styles/tokens.scss 的 #22c55e 覆盖。已补高频状态:Web StateBlock/FormAlert(P01 旁展示);Mobile Empty/ConfirmDialog/WebViewState。"
|
||||
},
|
||||
"delivery": {
|
||||
"provider": "drone",
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Issue #13:在 project_all_permission 之后执行,可重复执行。
|
||||
-- 项目 → 项目管理 → 四个原页面。保留页面 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(top_menu.tenant_no, ':menu:project:management')), '项目管理', top_menu.id,
|
||||
'M', 'management', '', 'menu:project:management', 1, '1', '1', top_menu.tenant_no,
|
||||
1, '', NOW(), NOW(), 0
|
||||
FROM sys_menu top_menu
|
||||
WHERE top_menu.perms='menu:project' AND top_menu.del_flag=0
|
||||
AND NOT EXISTS (SELECT 1 FROM sys_menu existing
|
||||
WHERE existing.tenant_no=top_menu.tenant_no AND existing.perms='menu:project:management');
|
||||
|
||||
UPDATE sys_menu leaf
|
||||
JOIN sys_menu group_menu ON group_menu.tenant_no=leaf.tenant_no
|
||||
AND group_menu.perms='menu:project:management' AND group_menu.del_flag=0
|
||||
SET leaf.parent_id=group_menu.id, 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-management-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 group_menu ON group_menu.tenant_no=grant_row.tenant_no
|
||||
AND group_menu.perms='menu:project:management' AND group_menu.del_flag=0
|
||||
JOIN sys_menu top_menu ON top_menu.id=group_menu.parent_id AND 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 IN (group_menu.id, top_menu.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;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Issue #50: 项目组版本历史(append-only JSON 快照)。MySQL 5.7.
|
||||
-- DDL 隐式提交;该脚本不作为自动应用启动步骤。上线前仍须备份并在目标副本验证。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_project_team_history (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
team_id VARCHAR(64) NOT NULL,
|
||||
version_no INT NOT NULL,
|
||||
action VARCHAR(16) NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
operator_user_id VARCHAR(64) NULL,
|
||||
operator_user_name VARCHAR(100) NULL,
|
||||
operate_time DATETIME 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),
|
||||
UNIQUE KEY uk_team_version (tenant_no, team_id, version_no),
|
||||
KEY ix_team_history (tenant_no, team_id, version_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Issue #24:协议单操作过程日志(before / action / after)
|
||||
-- MySQL 5.7;可重复执行(CREATE IF NOT EXISTS)。上线前须备份并在目标副本验证。
|
||||
-- DDL 隐式提交;该脚本不作为自动应用启动步骤。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_protocol_operation_log (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
biz_type VARCHAR(32) NOT NULL COMMENT '业务类型 cell_storage/research_donation',
|
||||
biz_id VARCHAR(64) NOT NULL COMMENT '协议单或捐赠单ID',
|
||||
protocol_no VARCHAR(64) NULL COMMENT '协议/捐赠编号快照',
|
||||
action VARCHAR(32) NOT NULL COMMENT '动作 create/update/delete/void/activate/expire/to_research/to_storage/audit',
|
||||
client_channel VARCHAR(16) NOT NULL DEFAULT 'web' COMMENT '来源端 web/mini',
|
||||
before_json MEDIUMTEXT NULL COMMENT '操作前协议信息JSON',
|
||||
after_json MEDIUMTEXT NULL COMMENT '操作后协议信息JSON',
|
||||
operator_user_id VARCHAR(64) NULL COMMENT '操作人用户ID',
|
||||
operator_user_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
|
||||
operate_time DATETIME NOT NULL COMMENT '业务操作时间',
|
||||
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_pol_biz (tenant_no, biz_type, biz_id, del_flag),
|
||||
KEY ix_pol_time (tenant_no, operate_time)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Issue #31:作废类型字典补充「实验室废弃」(sysfq)
|
||||
-- 为已有 xyzflx 字典的租户补齐;可重复执行。
|
||||
|
||||
INSERT INTO sys_dict_data (
|
||||
id, dict_label, dict_value, dict_type, tenant_no, order_num, remark,
|
||||
create_dept_id, create_time, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
MD5(CONCAT(t.tenant_no, ':xyzflx:sysfq')),
|
||||
'实验室废弃',
|
||||
'sysfq',
|
||||
'xyzflx',
|
||||
t.tenant_no,
|
||||
90,
|
||||
'issue#31 实验室废弃',
|
||||
'',
|
||||
NOW(),
|
||||
NOW(),
|
||||
0
|
||||
FROM (
|
||||
SELECT DISTINCT tenant_no
|
||||
FROM sys_dict_data
|
||||
WHERE dict_type = 'xyzflx' AND del_flag = 0
|
||||
) t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM sys_dict_data existing
|
||||
WHERE existing.tenant_no = t.tenant_no
|
||||
AND existing.dict_type = 'xyzflx'
|
||||
AND existing.dict_value = 'sysfq'
|
||||
AND existing.del_flag = 0
|
||||
);
|
||||
@@ -0,0 +1,309 @@
|
||||
# 项目组版本历史(仅落库)Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 项目组每次创建/编辑/软删除成功后,向 `biz_project_team_history` 追加一条带递增 `version_no` 的完整 JSON 快照,无需 UI。
|
||||
|
||||
**Architecture:** 在 `backend/app/services/project_management.py` 的 `save_entity` / `delete_entity` 中,对 `ProjectTeam` 在 `commit` 前调用历史写入助手;快照复用 `serialize_entity`;表结构与协议操作日志同类,但按版本号语义建模。
|
||||
|
||||
**Tech Stack:** FastAPI / SQLAlchemy / MySQL 5.7 DDL / unittest + SQLite in-memory(沿用 `test_project_management.py`)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-11-05-project-team-history-design.md`
|
||||
|
||||
**修改端:** 仅 `backend`(不改 `web-admin`)
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| 文件 | 职责 |
|
||||
| --- | --- |
|
||||
| `docs/sql/migrations/2026-11-05-project_team_history.sql` | MySQL 建表 |
|
||||
| `backend/app/models/project_management.py` | `ProjectTeamHistory` ORM |
|
||||
| `backend/app/models/__init__.py` | 导出模型 |
|
||||
| `backend/app/services/project_management.py` | `record_team_history` + 挂钩 save/delete |
|
||||
| `backend/tests/test_project_management.py` | 创建/多次更新/删除历史断言 |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 失败测试(TDD 红)
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/tests/test_project_management.py`
|
||||
- (实现尚未存在时导入 `ProjectTeamHistory` 会失败,或断言历史行数为 0→期望 ≥1)
|
||||
|
||||
- [ ] **Step 1: 在 `ProjectManagementTest` 增加测试方法**
|
||||
|
||||
在 `backend/tests/test_project_management.py` 末尾(同类测试旁)加入:
|
||||
|
||||
```python
|
||||
def test_team_history_versions_on_create_update_delete(self):
|
||||
import json
|
||||
from app.models.models import SysUser, BizCollectionHospital, BizCollectionRegion
|
||||
from app.models.project_management import ProjectTeamHistory
|
||||
self.db.add_all([
|
||||
SysUser(id='u', tenant_no='t', user_no='u', nick_name='User', status='1'),
|
||||
SysUser(id='m', tenant_no='t', user_no='m', nick_name='Member', status='1'),
|
||||
BizCollectionRegion(id='r1', tenant_no='t', region_name='华东'),
|
||||
BizCollectionHospital(id='h1', tenant_no='t', hospital_name='一院', region_id='r1'),
|
||||
])
|
||||
self.db.commit()
|
||||
created = self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
|
||||
teamName='Alpha', leaderUserId='u', enabled=True,
|
||||
members=[{'userId': 'm'}], regions=[{'regionId': 'r1'}], hospitals=[{'hospitalId': 'h1'}],
|
||||
remark='v1',
|
||||
))
|
||||
team_id = created['id']
|
||||
rows = (self.db.query(ProjectTeamHistory)
|
||||
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
|
||||
.order_by(ProjectTeamHistory.version_no).all())
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0].action, 'create')
|
||||
self.assertEqual(rows[0].version_no, 1)
|
||||
snap = json.loads(rows[0].snapshot_json)
|
||||
self.assertEqual(snap['teamName'], 'Alpha')
|
||||
self.assertEqual(snap['leaderName'], 'User')
|
||||
self.assertEqual([m['userId'] for m in snap['members']], ['m'])
|
||||
self.assertEqual([h['hospitalId'] for h in snap['hospitals']], ['h1'])
|
||||
|
||||
self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
|
||||
teamName='Alpha2', leaderUserId='u', enabled=True,
|
||||
members=[{'userId': 'm'}, {'userId': 'u'}], regions=[{'regionId': 'r1'}],
|
||||
hospitals=[{'hospitalId': 'h1'}], remark='v2',
|
||||
), team_id)
|
||||
rows = (self.db.query(ProjectTeamHistory)
|
||||
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
|
||||
.order_by(ProjectTeamHistory.version_no).all())
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[1].action, 'update')
|
||||
self.assertEqual(rows[1].version_no, 2)
|
||||
self.assertEqual(json.loads(rows[1].snapshot_json)['teamName'], 'Alpha2')
|
||||
self.assertEqual(len(json.loads(rows[1].snapshot_json)['members']), 2)
|
||||
|
||||
# 解除「已被项目引用」阻挡:setUp 里的项目挂在 'g',新建组可删
|
||||
self.svc.delete_entity(self.db, 't', self.user, self.Team, team_id)
|
||||
rows = (self.db.query(ProjectTeamHistory)
|
||||
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
|
||||
.order_by(ProjectTeamHistory.version_no).all())
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[2].action, 'delete')
|
||||
self.assertEqual(rows[2].version_no, 3)
|
||||
self.assertEqual(json.loads(rows[2].snapshot_json)['teamName'], 'Alpha2')
|
||||
```
|
||||
|
||||
注意:若 `BizCollectionRegion` / `BizCollectionHospital` 构造字段与模型不一致,按 `backend/app/models/models.py` 实际必填列补齐(与 `references()` 所用表一致)。`SysUser.status` 须为可被 `save_entity` 接受的启用态(现有 `'1'`)。
|
||||
|
||||
- [ ] **Step 2: 跑测试确认失败**
|
||||
|
||||
```bash
|
||||
cd backend && python -m pytest tests/test_project_management.py::ProjectManagementTest::test_team_history_versions_on_create_update_delete -v
|
||||
```
|
||||
|
||||
Expected: FAIL(`ProjectTeamHistory` 不存在,或历史行数为 0)
|
||||
|
||||
- [ ] **Step 3: Commit(仅测试,待用户要求提交时执行;实现会话可与后续任务合并提交)**
|
||||
|
||||
---
|
||||
|
||||
### Task 2: DDL 迁移
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/sql/migrations/2026-11-05-project_team_history.sql`
|
||||
|
||||
- [ ] **Step 1: 写入迁移**
|
||||
|
||||
```sql
|
||||
-- Project team version history (append-only JSON snapshots). MySQL 5.7.
|
||||
-- DDL 隐式提交;不作为应用启动自动步骤。上线前备份并在目标副本验证。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_project_team_history (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
team_id VARCHAR(64) NOT NULL,
|
||||
version_no INT NOT NULL,
|
||||
action VARCHAR(16) NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
operator_user_id VARCHAR(64) NULL,
|
||||
operator_user_name VARCHAR(100) NULL,
|
||||
operate_time DATETIME 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),
|
||||
UNIQUE KEY uk_team_version (tenant_no, team_id, version_no),
|
||||
KEY ix_team_history (tenant_no, team_id, version_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 确认无应用启动自动执行该 SQL 的挂钩**(仅文档/运维手工应用,与 `2026-11-02-project_management.sql` 一致)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ORM 模型
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/models/project_management.py`
|
||||
- Modify: `backend/app/models/__init__.py`
|
||||
|
||||
- [ ] **Step 1: 在 `project_management.py` 增加模型**(需 `from sqlalchemy import Text` 若尚未导入)
|
||||
|
||||
```python
|
||||
class ProjectTeamHistory(ProjectFields, Base):
|
||||
"""项目组版本历史;每次成功写入记完整快照。"""
|
||||
__tablename__ = 'biz_project_team_history'
|
||||
__table_args__ = (UniqueConstraint('tenant_no', 'team_id', 'version_no', name='uk_team_version'),)
|
||||
team_id = Column(String(64), nullable=False, index=True)
|
||||
version_no = Column(Integer, nullable=False)
|
||||
action = Column(String(16), nullable=False)
|
||||
snapshot_json = Column(Text, nullable=False)
|
||||
operator_user_id = Column(String(64))
|
||||
operator_user_name = Column(String(100))
|
||||
operate_time = Column(DateTime, nullable=False, default=datetime.now)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 更新 `__init__.py` 导入与 `__all__`**
|
||||
|
||||
```python
|
||||
from app.models.project_management import (
|
||||
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
|
||||
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
|
||||
)
|
||||
# __all__ 中追加 "ProjectTeamHistory"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 服务层写入挂钩
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/services/project_management.py`
|
||||
|
||||
- [ ] **Step 1: 导入**
|
||||
|
||||
```python
|
||||
import json
|
||||
from app.models.project_management import (
|
||||
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
|
||||
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 增加助手函数**(放在 `CHILDREN` / `serialize_entity` 附近)
|
||||
|
||||
```python
|
||||
def _next_team_history_version(db, tenant, team_id):
|
||||
current = (db.query(ProjectTeamHistory.version_no)
|
||||
.filter(ProjectTeamHistory.tenant_no == tenant,
|
||||
ProjectTeamHistory.team_id == team_id,
|
||||
ProjectTeamHistory.del_flag == 0)
|
||||
.order_by(ProjectTeamHistory.version_no.desc())
|
||||
.with_for_update()
|
||||
.first())
|
||||
return (current[0] if current else 0) + 1
|
||||
|
||||
|
||||
def record_team_history(db, tenant, user, row, action):
|
||||
"""Append-only team snapshot; caller owns the surrounding transaction."""
|
||||
if not isinstance(row, ProjectTeam):
|
||||
return
|
||||
snapshot = serialize_entity(db, tenant, row)
|
||||
operator_id = user.get('user_id')
|
||||
leader = scoped(db, SysUser, tenant).filter(SysUser.id == operator_id).first() if operator_id else None
|
||||
# Prefer nick_name when operator is a tenant user; fall back to user_id.
|
||||
operator_name = (leader.nick_name if leader else None) or (operator_id or '')
|
||||
now = datetime.now()
|
||||
db.add(ProjectTeamHistory(
|
||||
id=generate_uuid(),
|
||||
tenant_no=tenant,
|
||||
team_id=row.id,
|
||||
version_no=_next_team_history_version(db, tenant, row.id),
|
||||
action=action,
|
||||
snapshot_json=json.dumps(snapshot, ensure_ascii=False, default=str, separators=(',', ':')),
|
||||
operator_user_id=operator_id,
|
||||
operator_user_name=operator_name,
|
||||
operate_time=now,
|
||||
create_by=operator_id,
|
||||
create_dept_id=user.get('dept_id') or '',
|
||||
update_by=operator_id,
|
||||
create_time=now,
|
||||
update_time=now,
|
||||
))
|
||||
```
|
||||
|
||||
修正:上面用 `leader` 变量名易混淆,实现时用 `operator = scoped(...).filter(SysUser.id == operator_id).first()`。
|
||||
|
||||
- [ ] **Step 3: 挂钩 `save_entity`**
|
||||
|
||||
在 `ProjectTeam` 分支完成子行 `sync_child_rows` 之后、最终 `db.add(row)` / `db.commit()` 之前(确保 `row.id` 已有),调用:
|
||||
|
||||
```python
|
||||
record_team_history(db, tenant, user, row, 'update' if row_id else 'create')
|
||||
```
|
||||
|
||||
注意:`save_entity` 对 Project 与 ProjectTeam 共用末尾 `db.commit()`。仅当 `model is ProjectTeam` 时记录。将调用放在 Team 分支末尾(子行同步成功后),Project 分支不调用。
|
||||
|
||||
- [ ] **Step 4: 挂钩 `delete_entity`**
|
||||
|
||||
```python
|
||||
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, '项目组已被项目引用,请停用')
|
||||
if model is ProjectTeam:
|
||||
record_team_history(db, tenant, user, row, 'delete') # 先快照
|
||||
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}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 跑目标测试**
|
||||
|
||||
```bash
|
||||
cd backend && python -m pytest tests/test_project_management.py::ProjectManagementTest::test_team_history_versions_on_create_update_delete -v
|
||||
```
|
||||
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 6: 回归相关测试**
|
||||
|
||||
```bash
|
||||
cd backend && python -m pytest tests/test_project_management.py tests/test_project_assignment_integration.py -v
|
||||
```
|
||||
|
||||
Expected: PASS(或仅记录与本改动无关的既有失败)
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 收尾核对
|
||||
|
||||
- [ ] **Step 1:** 确认未改 `web-admin`、未加 History API、未引入 Figma。
|
||||
- [ ] **Step 2:** 规格验收条目可映射到测试与代码路径。
|
||||
- [ ] **Step 3:** 按 personal-dev 交付:工单回写、推送/PR/合并仅在授权下执行;当前工程 `write_issue`/`push_task_branch`/`create_pr`/`merge` 默认 false,缺权时保留本地完成态并报告缺口。
|
||||
|
||||
---
|
||||
|
||||
## Spec coverage checklist
|
||||
|
||||
| 规格要求 | Task |
|
||||
| --- | --- |
|
||||
| `biz_project_team_history` + 唯一版本约束 | 2, 3 |
|
||||
| create/update/delete 记版 | 4 |
|
||||
| 完整 serialize 快照 | 4 + 测试 1 |
|
||||
| 删除先快照再软删 | 4 Step 4 |
|
||||
| 无 UI / 无查询 API | Task 5 |
|
||||
| 自动化测试 | 1, 4 |
|
||||
|
||||
## 执行说明
|
||||
|
||||
实现前按 personal-dev:创建/领取 Gitea 工单 → worktree(基线 `V2.0`)→ 在 worktree 内执行本计划。提交仅在用户要求或交付流程授权时进行。
|
||||
@@ -0,0 +1,107 @@
|
||||
# 项目组版本历史(仅落库)设计
|
||||
|
||||
日期:2026-11-05
|
||||
状态:待用户确认规格后进入实现计划
|
||||
范围:`backend`(Web 项目组管理经现有 API 写入;本期不改 `web-admin` UI,不画 Figma)
|
||||
|
||||
## 背景与目标
|
||||
|
||||
项目组管理(`/project/management/teams`)每次新增、编辑或软删除后,需要可追溯的版本历史,使 N 次修改后仍能还原当时完整信息:名称、负责人、成员、负责地区、负责医院、启用状态、备注及操作元数据。
|
||||
|
||||
本期只保证**数据正确落库**;不做历史列表/详情界面。
|
||||
|
||||
## 已确认决策
|
||||
|
||||
| 项 | 结论 |
|
||||
| --- | --- |
|
||||
| 交互 | 无界面;不生成 Figma |
|
||||
| 存储 | 方案 A:独立历史表 + 完整 JSON 快照 |
|
||||
| 业务范围 | 仅项目组(`ProjectTeam`);不含项目(`Project`) |
|
||||
| 修改端 | `backend`;Web 继续走现有保存/删除 API |
|
||||
| 查询 API | 本期不暴露只读接口;用自动化测试验证落库 |
|
||||
| 与现有审计关系 | 不复用 `biz_project_assignment_audit`(仅业务归属变更) |
|
||||
|
||||
## 架构
|
||||
|
||||
在 `save_entity` / `delete_entity` 中,当模型为 `ProjectTeam` 且事务即将成功提交前:
|
||||
|
||||
1. 用现有 `serialize_entity` 生成当前(删除场景为软删后)完整字典;
|
||||
2. 计算该 `team_id` 在租户内下一 `version_no`;
|
||||
3. 追加写入 `biz_project_team_history` 一行(append-only,软删除标记仅遵循表结构惯例,业务不改历史行)。
|
||||
|
||||
对齐协议操作日志(`biz_protocol_operation_log`)的「动作 + 快照 JSON」思路,但本表按项目组版本号递增,强调「第 N 版完整态」而非前后 diff。
|
||||
|
||||
```
|
||||
Web 保存/删除
|
||||
→ API project_management
|
||||
→ save_entity / delete_entity
|
||||
→ 同步子行 / 软删
|
||||
→ 写入 ProjectTeamHistory(version_no, action, snapshot_json, operator_*)
|
||||
→ commit
|
||||
```
|
||||
|
||||
失败时与主写操作同一事务回滚,避免「主数据已改、历史未记」。
|
||||
|
||||
## 数据模型
|
||||
|
||||
表名:`biz_project_team_history`
|
||||
迁移:`docs/sql/migrations/` 下新增脚本(命名对齐现有 `2026-11-02-project_management.sql` 风格)。
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `id` | UUID PK |
|
||||
| `team_id` | 项目组 ID(软删后仍可关联) |
|
||||
| `version_no` | 租户内同一 `team_id` 从 1 递增 |
|
||||
| `action` | `create` / `update` / `delete` |
|
||||
| `snapshot_json` | Text,UTF-8 JSON;结构等于当时 `serialize_entity` 输出(含 `teamName`、`leaderUserId`、`leaderName`、`members`、`regions`、`hospitals`、`enabled`、`remark` 等) |
|
||||
| `operator_user_id` / `operator_user_name` | 操作人 ID 与姓名快照 |
|
||||
| `operate_time` | 业务操作时间 |
|
||||
| `tenant_no` 及标准审计字段 | 与工程表字段顺序约定一致;`del_flag` 默认 0 |
|
||||
|
||||
唯一约束:`(tenant_no, team_id, version_no)`。
|
||||
索引:`(tenant_no, team_id, version_no DESC)` 便于日后按组查历史。
|
||||
|
||||
ORM:在 `backend/app/models/project_management.py` 增加 `ProjectTeamHistory`,并在 `models/__init__.py` 导出。
|
||||
|
||||
## 写入规则
|
||||
|
||||
| 场景 | `action` | 快照时机 |
|
||||
| --- | --- | --- |
|
||||
| 新增项目组 | `create` | 子行同步完成后 |
|
||||
| 编辑项目组 | `update` | 子行同步完成后(每次成功保存记一版,不要求字段 diff) |
|
||||
| 软删除项目组 | `delete` | **先**用 `serialize_entity` 打删除前快照,**再**对主表与子行置 `del_flag=1`,最后写入历史行并 `commit`(`scoped` 默认排除已删行,禁止先软删再序列化) |
|
||||
|
||||
`version_no`:同事务内对 `(tenant_no, team_id)` 取 `MAX(version_no)+1`,无历史则从 1 开始;配合行锁/唯一约束防止并发撞号。
|
||||
|
||||
不在「无字段变化的重复保存」上做跳过优化(YAGNI);每次成功保存都记一版,满足「N 次修改 N 条历史」的验收语义。
|
||||
|
||||
## 非目标(本期不做)
|
||||
|
||||
- Web/小程序/移动 OA 任何历史入口或展示
|
||||
- 历史恢复(rollback)到某版本
|
||||
- 字段级 diff 展示
|
||||
- 项目(非项目组)版本历史
|
||||
- 对外只读 History API(可后续单独开需求)
|
||||
|
||||
## 测试
|
||||
|
||||
在现有项目组相关测试附近增加用例(隔离库):
|
||||
|
||||
1. 创建项目组 → 存在 `version_no=1`、`action=create`,`snapshot_json` 含负责人与成员/医院等;
|
||||
2. 连续更新 N 次 → 存在版本 1…N+1,最新快照与当前 `serialize_entity` 一致;
|
||||
3. 软删除 → 新增 `action=delete` 版本,且主流程仍成功。
|
||||
|
||||
不要求启动 `web-admin` 或改前端。
|
||||
|
||||
## 验收条件
|
||||
|
||||
1. 对任一项目组完成创建与若干次编辑后,库中按 `version_no` 可还原每一次的完整快照(负责人、成员、地区、医院等)。
|
||||
2. 软删除同样产生历史行。
|
||||
3. Web 项目组管理界面行为与现网一致(无新增按钮/弹窗)。
|
||||
4. 相关自动化测试通过;字段/表结构有 `docs/sql` 迁移可查。
|
||||
|
||||
## 风险与备注
|
||||
|
||||
- `snapshot_json` 含展示名,人员改名后历史仍保留操作当时姓名(刻意为之)。
|
||||
- 工程 `permissions.write_issue` 当前为 false:实现阶段创建/回写 Gitea 工单需用户授权或代建;本地代码与测试仍可推进。
|
||||
- 集成分支:`V2.0`。
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -28,25 +28,27 @@
|
||||
class="market-form-fields"
|
||||
:class="{ 'market-form-fields--grid-2': section.fieldsLayout === 'grid-2' }"
|
||||
>
|
||||
<MarketFormField
|
||||
v-for="field in visibleFields"
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
:model-value="value?.[field.key]"
|
||||
:options="fieldOptions(field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(field)"
|
||||
:search-customers="searchCustomers"
|
||||
@update:model-value="setValue(field, $event)"
|
||||
@upload="$emit('upload', { section, field, file: $event })"
|
||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||
/>
|
||||
<template v-for="item in fieldsWithInsert" :key="item.field.key">
|
||||
<slot v-if="item.insertBefore" name="insert" />
|
||||
<MarketFormField
|
||||
:field="item.field"
|
||||
:model-value="value?.[item.field.key]"
|
||||
:options="fieldOptions(item.field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(item.field)"
|
||||
:search-customers="searchCustomers"
|
||||
@update:model-value="setValue(item.field, $event)"
|
||||
@upload="$emit('upload', { section, field: item.field, file: $event })"
|
||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||
/>
|
||||
</template>
|
||||
<slot v-if="appendInsert" name="insert" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import MarketFormField from './MarketFormField.vue'
|
||||
import { sampleCodeOptionsForRow } from '../reference-options.js'
|
||||
|
||||
@@ -58,9 +60,12 @@ const props = defineProps({
|
||||
mode: { type: String, default: 'create' },
|
||||
uploading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
searchCustomers: { type: Function, default: null }
|
||||
searchCustomers: { type: Function, default: null },
|
||||
/** 在首个匹配可见字段前插入 #insert 插槽(如协议状态下的项目归属) */
|
||||
insertBeforeKeys: { type: Array, default: () => [] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'upload', 'field-change', 'customer-select'])
|
||||
const slots = useSlots()
|
||||
const value = computed(() => props.modelValue || {})
|
||||
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
|
||||
const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
@@ -71,6 +76,19 @@ const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
!(typeof field.showWhen === 'function' && field.showWhen(value.value) === false) &&
|
||||
!(field.createOnly && props.mode !== 'create')
|
||||
))
|
||||
const fieldsWithInsert = computed(() => {
|
||||
const keys = props.insertBeforeKeys || []
|
||||
let inserted = false
|
||||
return visibleFields.value.map((field) => {
|
||||
const insertBefore = Boolean(slots.insert) && !inserted && keys.includes(field.key)
|
||||
if (insertBefore) inserted = true
|
||||
return { field, insertBefore }
|
||||
})
|
||||
})
|
||||
const appendInsert = computed(() => {
|
||||
if (!slots.insert || !(props.insertBeforeKeys || []).length) return false
|
||||
return !fieldsWithInsert.value.some((item) => item.insertBefore)
|
||||
})
|
||||
|
||||
function fieldOptions(field, rowIndex = -1) {
|
||||
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<component :is="embedded ? 'div' : 'section'" :class="rootClass">
|
||||
<h2 v-if="!embedded">{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
|
||||
<label v-else :id="labelId">所属项目<b v-if="required && !editing && !filter">*</b></label>
|
||||
<template v-if="editing">
|
||||
<template v-if="embedded">
|
||||
<div class="market-form-field__control market-form-field__control--text" :aria-labelledby="labelId">
|
||||
{{ projectLabel(record) }}
|
||||
</div>
|
||||
<p class="market-form-field__tip">项目组:{{ record.teamName || '—' }};归属调整请使用 Web 项目归属维护</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-cell title="所属项目" :value="projectLabel(record)" />
|
||||
<van-cell title="项目组" :value="record.teamName || '—'" />
|
||||
<van-cell label="归属调整请使用 Web 项目归属维护" />
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div :class="embedded ? 'market-form-field__control' : undefined">
|
||||
<PresignSelectField
|
||||
:model-value="modelValue || ''"
|
||||
:options="selectOptions"
|
||||
:disabled="disabled || loading || Boolean(error)"
|
||||
:loading="loading"
|
||||
searchable
|
||||
title="所属项目"
|
||||
:placeholder="filter ? '全部项目' : '请选择所属项目'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="embedded">
|
||||
<p v-if="loading" class="market-form-field__tip">正在加载项目…</p>
|
||||
<button v-else-if="error" type="button" class="market-form-field__tip market-form-field__tip--action" @click="load">
|
||||
项目加载失败,点击重试({{ error }})
|
||||
</button>
|
||||
<p v-else-if="!options.length" class="market-form-field__tip">
|
||||
{{ required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目' }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<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>
|
||||
</template>
|
||||
</component>
|
||||
</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 },
|
||||
embedded: { 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('')
|
||||
const labelId = `project-assignment-${Math.random().toString(36).slice(2, 9)}`
|
||||
let controller
|
||||
const rootClass = computed(() => (props.embedded ? 'market-form-field' : 'market-form-section'))
|
||||
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>
|
||||
@@ -40,7 +40,8 @@ export function createListFilters(module, source = '') {
|
||||
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
|
||||
return {
|
||||
source: result.source,
|
||||
protocolStatus: result.protocolStatus
|
||||
protocolStatus: result.protocolStatus,
|
||||
projectId: ''
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -48,6 +49,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') {
|
||||
|
||||
@@ -15,11 +15,13 @@ test('creates isolated defaults for every market module', () => {
|
||||
|
||||
assert.deepEqual(first, {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(second, {
|
||||
source: 'research_donation',
|
||||
protocolStatus: ''
|
||||
protocolStatus: '',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
|
||||
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
|
||||
@@ -66,8 +68,14 @@ test('drops incomplete ranges and counts only user-selectable filters', () => {
|
||||
}), { progressStatus: 'yjq' })
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
}), 1)
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'research_donation',
|
||||
protocolStatus: 'sxz',
|
||||
projectId: 'p1'
|
||||
}), 2)
|
||||
assert.equal(activeFilterCount('freight', {
|
||||
transportMode: '', sampleSource: 'research_donation'
|
||||
}), 1)
|
||||
|
||||
@@ -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 [
|
||||
'样本编码', '当月核量号', '实验室流转编号', '废弃原因', '样本类型',
|
||||
'采集地区', '采集医院', '分娩时间', '采集时间', '市场专员', '地区经理',
|
||||
|
||||
@@ -313,6 +313,7 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
|
||||
assert.match(protocol, /inlineDropdownFilters/)
|
||||
assert.match(protocol, /全部协议状态/)
|
||||
assert.match(protocol, /source:\s*'research_donation'/)
|
||||
assert.match(protocol, /filterFields:\s*\[\]/)
|
||||
assert.doesNotMatch(protocol, /createTimeRange/)
|
||||
assert.doesNotMatch(protocol, /sampleCollectTimeRange/)
|
||||
@@ -320,6 +321,25 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
assert.doesNotMatch(filters, /createTimeRange/)
|
||||
assert.match(genericList, /inlineDropdownFilters/)
|
||||
assert.match(genericList, /market-list__filter-row/)
|
||||
assert.match(genericList, /showProtocolProjectFilter/)
|
||||
assert.match(genericList, /全部项目/)
|
||||
assert.doesNotMatch(genericList, /ProjectAssignmentField/)
|
||||
})
|
||||
|
||||
test('protocol form embeds project assignment inside protocol info before status fields', () => {
|
||||
const form = source('../views/market/MarketModuleForm.vue')
|
||||
const formSection = source('./components/MarketFormSection.vue')
|
||||
const projectField = source('./components/ProjectAssignmentField.vue')
|
||||
|
||||
assert.match(form, /insert-before-keys="\['protocolStatus', 'donationStatus'\]"/)
|
||||
assert.match(form, /#insert/)
|
||||
assert.match(form, /embedded/)
|
||||
assert.match(form, /<ProjectAssignmentField[\s\S]*?embedded/)
|
||||
assert.doesNotMatch(form, /ProjectAssignmentField v-if="context\.module === 'protocol'"/)
|
||||
assert.match(formSection, /insertBeforeKeys/)
|
||||
assert.match(formSection, /name="insert"/)
|
||||
assert.match(projectField, /embedded/)
|
||||
assert.match(projectField, /market-form-field/)
|
||||
})
|
||||
|
||||
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
|
||||
@@ -570,7 +590,7 @@ test('protocol cards expose the same business fields as the web list', async ()
|
||||
})
|
||||
|
||||
assert.deepEqual(card.fields.map((field) => field.label), [
|
||||
'样本编码', '登记日期', '母亲电话', '母亲身份证号', '父亲姓名',
|
||||
'所属项目', '项目组', '样本编码', '登记日期', '母亲电话', '母亲身份证号', '父亲姓名',
|
||||
'新生儿性别', '分娩方式', '新生儿体重(克)', '采集地区', '采集医院',
|
||||
'市场专员', '样本采集时间', '创建时间'
|
||||
])
|
||||
|
||||
@@ -117,7 +117,8 @@ test('protocol adapters preserve nested detail sections', () => {
|
||||
'refundRecords',
|
||||
'audits',
|
||||
'attachments',
|
||||
'statusChange'
|
||||
'statusChange',
|
||||
'operationLog'
|
||||
])
|
||||
const protocolLabels = protocol.flatMap((section) => (section.fields || []).map((field) => field.label))
|
||||
assert.ok(!protocolLabels.includes('协议编号'))
|
||||
@@ -125,7 +126,7 @@ test('protocol adapters preserve nested detail sections', () => {
|
||||
assert.ok(protocolLabels.includes('父亲姓名'))
|
||||
assert.ok(protocolLabels.includes('母亲联系电话'))
|
||||
assert.ok(!protocolLabels.includes('新生儿评分(5分钟)'))
|
||||
assert.equal(protocol.at(-1).key, 'statusChange')
|
||||
assert.equal(protocol.at(-1).key, 'operationLog')
|
||||
})
|
||||
|
||||
test('attachment-bearing detail adapters expose typed values instead of path text', () => {
|
||||
|
||||
@@ -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)) },
|
||||
|
||||
@@ -127,6 +127,66 @@ function statusChangeDescText(row) {
|
||||
return desc || '—'
|
||||
}
|
||||
|
||||
function operationActionLabel(action) {
|
||||
const map = {
|
||||
create: '创建',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
void: '作废',
|
||||
activate: '生效',
|
||||
expire: '过期',
|
||||
to_research: '转科研',
|
||||
to_storage: '转细胞存储',
|
||||
audit: '审核'
|
||||
}
|
||||
return map[action] || action || '—'
|
||||
}
|
||||
|
||||
function operationChannelLabel(channel) {
|
||||
if (channel === 'mini') return '小程序'
|
||||
if (channel === 'web') return 'Web'
|
||||
return channel || '—'
|
||||
}
|
||||
|
||||
function buildOperationLogCards(rows) {
|
||||
return (Array.isArray(rows) ? rows : []).map((row, index) => {
|
||||
const action = String(row?.action || '')
|
||||
const before = row?.before || {}
|
||||
const after = row?.after || {}
|
||||
const beforeProto = before.protocol || before.donation || {}
|
||||
const afterProto = after.protocol || after.donation || {}
|
||||
const beforeParty = before.partyCustomer || before.party || {}
|
||||
const afterParty = after.partyCustomer || after.party || {}
|
||||
const beforeStatus = String(beforeProto.protocolStatus || beforeProto.donationStatus || '')
|
||||
const afterStatus = String(afterProto.protocolStatus || afterProto.donationStatus || '')
|
||||
let summary = ''
|
||||
if (beforeStatus && afterStatus && beforeStatus !== afterStatus) summary = `状态 ${beforeStatus}→${afterStatus}`
|
||||
else if (afterStatus) summary = `状态 ${afterStatus}`
|
||||
for (const key of ['motherName', 'customerName', 'donorName', 'name']) {
|
||||
const b = String(beforeParty[key] || '').trim()
|
||||
const a = String(afterParty[key] || '').trim()
|
||||
if (b || a) {
|
||||
const namePart = b && a && b !== a ? `姓名 ${b}→${a}` : a ? `姓名 ${a}` : ''
|
||||
summary = summary ? `${summary};${namePart}` : namePart
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!summary) summary = String(row?.remark || '').trim() || '见前后快照'
|
||||
return {
|
||||
key: `operation-log-${row?.id || index}`,
|
||||
title: `${index + 1}. ${operationActionLabel(action)}`,
|
||||
fields: [
|
||||
{ label: '来源端', value: operationChannelLabel(String(row?.clientChannel || '')) },
|
||||
{ label: '操作时间', value: row?.operateTime || '—' },
|
||||
{ label: '操作人', value: row?.operatorUserName || '—' },
|
||||
{ label: '变更摘要', value: summary }
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
function buildStatusChangeCards(rows, statusRegistry) {
|
||||
return (rows || []).map((row, index) => {
|
||||
const changeType = String(row?.changeType || '')
|
||||
@@ -418,6 +478,12 @@ export default {
|
||||
allText: '全部协议状态',
|
||||
source: 'cell_storage',
|
||||
options: CELL_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
},
|
||||
{
|
||||
key: 'protocolStatus',
|
||||
allText: '全部协议状态',
|
||||
source: 'research_donation',
|
||||
options: DONATION_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
}
|
||||
],
|
||||
statusLabel(detail) {
|
||||
@@ -452,6 +518,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 +527,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') },
|
||||
@@ -679,6 +749,8 @@ export default {
|
||||
? detail.statusChanges
|
||||
: synthesizeStatusChangesFromBasic(basic, source)
|
||||
const statusChangeCards = buildStatusChangeCards(statusChangeRows, statusRegistry)
|
||||
const operationLogRows = Array.isArray(detail?.operationLogs) ? detail.operationLogs : []
|
||||
const operationLogCards = buildOperationLogCards(operationLogRows)
|
||||
values.push({
|
||||
key: 'statusChange',
|
||||
title: '状态变更记录',
|
||||
@@ -686,9 +758,17 @@ export default {
|
||||
? []
|
||||
: [{ label: '记录', value: '暂无状态变更记录' }],
|
||||
cards: statusChangeCards
|
||||
},
|
||||
{
|
||||
key: 'operationLog',
|
||||
title: '操作日志',
|
||||
fields: operationLogCards.length
|
||||
? []
|
||||
: [{ label: '记录', value: '暂无操作日志' }],
|
||||
cards: operationLogCards
|
||||
})
|
||||
if (isDonation) {
|
||||
const order = ['protocol', 'collection', 'samples', 'customer', 'audits', 'attachments', 'statusChange']
|
||||
const order = ['protocol', 'collection', 'samples', 'customer', 'audits', 'attachments', 'statusChange', 'operationLog']
|
||||
const byKey = new Map(values.map((section) => [section.key, section]))
|
||||
return order.map((key) => byKey.get(key)).filter(Boolean)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -123,11 +123,11 @@ test('donation detail follows the web view and omits price and payment', () => {
|
||||
attachments: { protocolAttachments: 'a.pdf', healthSurveyFormUrls: 'h.pdf', consentFormUrls: 'c.pdf' }
|
||||
})
|
||||
assert.deepEqual(sections.map((section) => section.key), [
|
||||
'protocol', 'collection', 'samples', 'customer', 'audits', 'attachments', 'statusChange'
|
||||
'protocol', 'collection', 'samples', 'customer', 'audits', 'attachments', 'statusChange', 'operationLog'
|
||||
])
|
||||
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 ['优惠价', '原价', '存储年限']) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { attachmentFullUrl } from '../../utils/attachment.js'
|
||||
/**
|
||||
* OA 审批单移动端打印(S6)
|
||||
* 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;支持 html2canvas + jsPDF 导出。
|
||||
@@ -66,7 +67,6 @@ function escapeHtml(s) {
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
|
||||
function formatDateOnly(createTime) {
|
||||
const raw = String(createTime || '').trim()
|
||||
if (!raw) return '-'
|
||||
@@ -99,10 +99,12 @@ function formatScalar(value, sep = '、') {
|
||||
return String(value)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((x) => formatScalar(x, sep))
|
||||
.filter((x) => x && x !== '-')
|
||||
.join(sep) || '-'
|
||||
return (
|
||||
value
|
||||
.map((x) => formatScalar(x, sep))
|
||||
.filter((x) => x && x !== '-')
|
||||
.join(sep) || '-'
|
||||
)
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (value.name != null) return String(value.name)
|
||||
@@ -397,7 +399,13 @@ export function buildOaPrintModel(detail, opts = {}) {
|
||||
stampClass,
|
||||
stampText,
|
||||
rows: [...headerRows, ...buildFormRows(detail)],
|
||||
flowItems: buildFlowItems(detail),
|
||||
flowItems: buildFlowItems(detail, (url) => {
|
||||
try {
|
||||
return new URL(attachmentFullUrl(url), window.location.origin).href
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}),
|
||||
printTime,
|
||||
printerName,
|
||||
watermarkName: printerName === '-' ? '审批' : printerName
|
||||
@@ -435,6 +443,9 @@ function flowCellHtml(f) {
|
||||
} else if (f.comment) {
|
||||
html += `<div class="oa-print-flow__comment">意见:${escapeHtml(f.comment)}</div>`
|
||||
}
|
||||
for (const url of f.attachmentLinks || []) {
|
||||
html += `<div class="oa-print-url"><a data-oa-print-link href="${escapeHtml(url)}">${escapeHtml(url)}</a></div>`
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
@@ -472,9 +483,10 @@ function buildPrintStylesContent() {
|
||||
line-height: 1.5; word-break: break-all; }
|
||||
.oa-print-table th { width: 118px; font-weight: 400; text-align: left; background: #fff; }
|
||||
.oa-print-text { white-space: pre-wrap; }
|
||||
.oa-print-url a { color: inherit; }
|
||||
.oa-print-url { color: #3370ff; word-break: break-all; }
|
||||
.oa-print-flow__row { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.oa-print-flow__left { flex: 1; min-width: 0; }
|
||||
.oa-print-flow__left { white-space: pre-wrap; overflow-wrap: anywhere; flex: 1; min-width: 0; }
|
||||
.oa-print-flow__right { flex: 0 0 auto; color: #646464; white-space: nowrap; }
|
||||
.oa-print-flow__person-lines { margin-top: 4px; padding-left: 8px; }
|
||||
.oa-print-flow__person-line { margin-top: 2px; font-size: 12px; }
|
||||
@@ -602,7 +614,7 @@ function renderPageLabelCanvas(text) {
|
||||
return c
|
||||
}
|
||||
|
||||
async function canvasToPdfBlob(canvas) {
|
||||
async function canvasToPdfBlob(canvas, sheet) {
|
||||
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
|
||||
const pageWidth = pdf.internal.pageSize.getWidth()
|
||||
const pageHeight = pdf.internal.pageSize.getHeight()
|
||||
@@ -620,6 +632,23 @@ async function canvasToPdfBlob(canvas) {
|
||||
const position = -pageIndex * pageHeight
|
||||
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
|
||||
|
||||
// html2canvas 只保留像素,单独把附件链接写为 PDF 注解;跨页部分按页裁切。
|
||||
const sheetRect = sheet.getBoundingClientRect()
|
||||
const ratio = pageWidth / sheetRect.width
|
||||
for (const link of sheet.querySelectorAll('a[data-oa-print-link]')) {
|
||||
if (!/^https?:\/\//i.test(link.href)) continue
|
||||
for (const rect of link.getClientRects()) {
|
||||
const top = (rect.top - sheetRect.top) * ratio - pageIndex * pageHeight
|
||||
const bottom = Math.min(pageHeight, top + rect.height * ratio)
|
||||
const y = Math.max(0, top)
|
||||
if (bottom > y) {
|
||||
pdf.link((rect.left - sheetRect.left) * ratio, y, rect.width * ratio, bottom - y, {
|
||||
url: link.href
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const label = `第${pageIndex + 1}页`
|
||||
const labelCanvas = renderPageLabelCanvas(label)
|
||||
if (labelCanvas.width > 1) {
|
||||
@@ -664,7 +693,7 @@ export async function generateOaApprovalPdfBlob(detail, opts = {}) {
|
||||
windowWidth: el.scrollWidth,
|
||||
windowHeight: el.scrollHeight
|
||||
})
|
||||
const blob = await canvasToPdfBlob(canvas)
|
||||
const blob = await canvasToPdfBlob(canvas, el)
|
||||
return { blob, filename }
|
||||
} finally {
|
||||
host.remove()
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { buildFlowItems } from './oa-print-flow.js'
|
||||
|
||||
describe('打印讨论评论', () => {
|
||||
it('按时间展示正文、提及和附件,并排在待审节点前', () => {
|
||||
const items = buildFlowItems({
|
||||
progress: [
|
||||
{
|
||||
kind: 'submit',
|
||||
title: '发起人',
|
||||
status: 'submitted',
|
||||
timestamp: '2026-11-01 10:00:00'
|
||||
},
|
||||
{
|
||||
kind: 'pending_node',
|
||||
title: '审批人',
|
||||
status: 'pending',
|
||||
timestamp: '2026-11-01 10:00:00'
|
||||
}
|
||||
],
|
||||
comments: [
|
||||
{
|
||||
id: '2',
|
||||
createByName: '李四',
|
||||
createTime: '2026-11-01 12:00:00',
|
||||
content: '',
|
||||
attachmentRefs: [{ url: 'https://example.com/file.pdf', fileName: '附件.pdf' }]
|
||||
},
|
||||
{
|
||||
id: '1',
|
||||
createByName: '张三',
|
||||
createTime: '2026-11-01 11:00:00',
|
||||
content: '@李四 请处理\n第二行',
|
||||
mentions: [{ userId: 'u2', userName: '李四' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
assert.equal(items.length, 4)
|
||||
assert.equal(items[1].left, '【评论】 张三')
|
||||
assert.equal(items[1].right, '2026-11-01 11:00:00')
|
||||
assert.deepEqual(items[1].personLines, [
|
||||
{ left: '@李四 请处理\n第二行', right: '' },
|
||||
{ left: '提到:@李四', right: '' }
|
||||
])
|
||||
assert.deepEqual(items[2].attachmentLinks, ['https://example.com/file.pdf'])
|
||||
assert.match(items[3].left, /待处理/)
|
||||
})
|
||||
it('撤回评论不泄露正文、提及和附件', () => {
|
||||
const [item] = buildFlowItems({
|
||||
comments: [
|
||||
{
|
||||
id: '1',
|
||||
createBy: 'u1',
|
||||
status: 'revoked',
|
||||
content: 'secret',
|
||||
mentions: [{ userName: 'secret' }],
|
||||
attachmentRefs: [{ url: 'https://example.com/secret' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
assert.equal(item.left, '【评论】 u1 已撤回')
|
||||
assert.equal(JSON.stringify(item).includes('secret'), false)
|
||||
})
|
||||
it('安全过滤附件协议,保留纯附件与纯提及评论', () => {
|
||||
const items = buildFlowItems({
|
||||
comments: [
|
||||
{
|
||||
id: '1',
|
||||
mentions: [{ userId: 'u1' }],
|
||||
attachmentRefs: [{ url: 'javascript:alert(1)' }, { url: 'https://example.com/a?x=1&y=2' }]
|
||||
}
|
||||
]
|
||||
})
|
||||
assert.deepEqual(items[0].attachmentLinks, ['https://example.com/a?x=1&y=2'])
|
||||
assert.deepEqual(items[0].personLines, [{ left: '提到:@u1', right: '' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('同秒节点使用详情页的稳定排序键', () => {
|
||||
const progress = Array.from({ length: 12 }, (_, index) => ({
|
||||
kind: 'task',
|
||||
nodeKey: `node-${String(11 - index).padStart(2, '0')}`,
|
||||
title: `节点${11 - index}`,
|
||||
status: 'approved',
|
||||
timestamp: '2026-11-01 10:00:00'
|
||||
}))
|
||||
const items = buildFlowItems({
|
||||
progress,
|
||||
comments: [{ id: 'comment', content: '讨论', createTime: '2026-11-01 10:00:00' }]
|
||||
})
|
||||
assert.equal(items[0].left, '【节点0】 已同意')
|
||||
assert.equal(items[11].left, '【节点11】 已同意')
|
||||
assert.equal(items[12].left, '【评论】 评论')
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { mergeProgressWithComments } from '../oa-progress-merge-comments.js'
|
||||
/**
|
||||
* 移动端打印「审批流程」行组装(无 html2canvas/API 副作用,便于单测)
|
||||
*/
|
||||
@@ -37,16 +38,22 @@ function isCcProgressItem(p) {
|
||||
const taskType = String(p?.taskType || p?.task_type || '').trim()
|
||||
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
|
||||
const title = String(p?.title || p?.nodeName || '').trim()
|
||||
return (
|
||||
taskType === 'cc' ||
|
||||
signMode === 'cc' ||
|
||||
title === '抄送人' ||
|
||||
/^抄送/.test(title)
|
||||
)
|
||||
return taskType === 'cc' || signMode === 'cc' || title === '抄送人' || /^抄送/.test(title)
|
||||
}
|
||||
|
||||
export function buildFlowItems(detail) {
|
||||
const list = Array.isArray(detail?.progress) ? detail.progress : []
|
||||
export function buildFlowItems(detail, resolveUrl = (url) => url) {
|
||||
const progress = Array.isArray(detail?.progress) ? detail.progress : []
|
||||
// 沿用详情页的时间顺序;无讨论时保持原有审批节点顺序。
|
||||
const list =
|
||||
Array.isArray(detail?.comments) && detail.comments.length
|
||||
? mergeProgressWithComments(
|
||||
progress.map((p, index) => ({
|
||||
...p,
|
||||
key: `${p.kind}-${p.nodeKey || p.taskId || index}-${p.roundNo || 1}`
|
||||
})),
|
||||
detail.comments
|
||||
)
|
||||
: progress
|
||||
const items = []
|
||||
let ccBuf = null
|
||||
const flushCc = () => {
|
||||
@@ -70,6 +77,37 @@ export function buildFlowItems(detail) {
|
||||
const signMode = String(p.signMode || p.sign_mode || '')
|
||||
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
|
||||
|
||||
if (kind === 'comment') {
|
||||
flushCc()
|
||||
const revoked = p.status === 'revoked'
|
||||
const author = String(p.assignee || p.title || '评论').trim()
|
||||
const item = {
|
||||
left: `【评论】 ${author}${revoked ? ' 已撤回' : ''}`,
|
||||
right: ts
|
||||
}
|
||||
if (!revoked) {
|
||||
const lines = []
|
||||
if (comment) lines.push({ left: comment, right: '' })
|
||||
const mentions = (p.mentions || [])
|
||||
.map((m) => String(m.userName || m.userId || '').trim())
|
||||
.filter(Boolean)
|
||||
if (mentions.length)
|
||||
lines.push({
|
||||
left: `提到:${mentions.map((name) => `@${name}`).join('、')}`,
|
||||
right: ''
|
||||
})
|
||||
if (lines.length) item.personLines = lines
|
||||
const links = (p.attachmentRefs || [])
|
||||
.map((file) => String(file.url || '').trim())
|
||||
.filter((url) => url && !/^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(url))
|
||||
.map(resolveUrl)
|
||||
.filter((url) => /^https?:\/\//i.test(url))
|
||||
if (links.length) item.attachmentLinks = links
|
||||
}
|
||||
items.push(item)
|
||||
continue
|
||||
}
|
||||
|
||||
if (isCcProgressItem(p)) {
|
||||
if (!ccBuf) ccBuf = { names: [], ts }
|
||||
if (names.length) ccBuf.names.push(...names)
|
||||
|
||||
@@ -1247,6 +1247,25 @@ body.van-toast--unclickable .presign-order-page * {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.market-form-field > .market-form-field__tip,
|
||||
.market-form-field .market-form-field__control--text .market-form-field__tip {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.market-form-field > .market-form-field__tip--action {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0a9f6d;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-form-field > .van-field,
|
||||
.market-form-field__control {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -12,22 +12,52 @@
|
||||
</van-empty>
|
||||
</div>
|
||||
<template v-else>
|
||||
<MarketFormSection
|
||||
v-for="section in leadingSections"
|
||||
:key="section.key"
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
/>
|
||||
<template v-for="section in leadingSections" :key="section.key">
|
||||
<MarketFormSection
|
||||
v-if="!(context.module === 'protocol' && section.key === 'protocol')"
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
/>
|
||||
<MarketFormSection
|
||||
v-else
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
:insert-before-keys="['protocolStatus', 'donationStatus']"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
>
|
||||
<template #insert>
|
||||
<ProjectAssignmentField
|
||||
ref="projectAssignment"
|
||||
embedded
|
||||
v-model="formData.projectId"
|
||||
:biz-type="formData.source"
|
||||
:editing="mode === 'edit'"
|
||||
:record="formData"
|
||||
:disabled="fieldsLocked"
|
||||
@update:model-value="dirty = true"
|
||||
/>
|
||||
</template>
|
||||
</MarketFormSection>
|
||||
</template>
|
||||
|
||||
<section
|
||||
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
|
||||
@@ -186,6 +216,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 +1051,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 +1076,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
|
||||
|
||||
@@ -57,15 +57,24 @@
|
||||
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="inlineDropdownFilters.length" class="market-list__filter-row">
|
||||
<div
|
||||
v-if="inlineDropdownFilters.length || showProtocolProjectFilter"
|
||||
class="market-list__filter-row"
|
||||
>
|
||||
<van-dropdown-menu active-color="#0a9f6d">
|
||||
<van-dropdown-item
|
||||
v-for="field in inlineDropdownFilters"
|
||||
:key="field.key"
|
||||
:key="`${field.source || 'all'}:${field.key}`"
|
||||
:model-value="listFilters[field.key]"
|
||||
:options="inlineFilterOptionsByKey[field.key] || []"
|
||||
@change="(value) => onInlineFilterChange(field.key, value)"
|
||||
/>
|
||||
<van-dropdown-item
|
||||
v-if="showProtocolProjectFilter"
|
||||
:model-value="listFilters.projectId || ''"
|
||||
:options="protocolProjectFilterOptions"
|
||||
@change="(value) => onInlineFilterChange('projectId', value)"
|
||||
/>
|
||||
</van-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,7 +164,12 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchMarketFormOptions, fetchMarketList, performMarketAction } from '../../api/market.js'
|
||||
import {
|
||||
fetchMarketFormOptions,
|
||||
fetchMarketList,
|
||||
fetchProjectOptions,
|
||||
performMarketAction
|
||||
} from '../../api/market.js'
|
||||
import { intersectAllowedActions } from '../../market/action-policy.js'
|
||||
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
|
||||
import {
|
||||
@@ -166,6 +180,7 @@ import {
|
||||
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
|
||||
import { marketErrorMessage } from '../../market/market-error.js'
|
||||
import { getMarketModule } from '../../market/module-registry.js'
|
||||
import { projectOptions } from '../../market/project-assignment.js'
|
||||
import {
|
||||
getMarketRouteContext,
|
||||
MARKET_MODULE_TITLES,
|
||||
@@ -244,6 +259,12 @@ const inlineDropdownFilters = computed(() => {
|
||||
const source = listFilters.value.source || listStatus.value
|
||||
return rows.filter((field) => !field.source || field.source === source)
|
||||
})
|
||||
const showProtocolProjectFilter = computed(() => context.value.module === 'protocol')
|
||||
const protocolProjectOptions = ref([])
|
||||
const protocolProjectFilterOptions = computed(() => [
|
||||
{ text: '全部项目', value: '' },
|
||||
...protocolProjectOptions.value
|
||||
])
|
||||
const activeFilters = computed(() =>
|
||||
activeFilterCount(context.value.module, listFilters.value)
|
||||
)
|
||||
@@ -296,6 +317,7 @@ const allDeletableSelected = computed(() =>
|
||||
)
|
||||
let requestSequence = 0
|
||||
let abortController = null
|
||||
let projectOptionsController = null
|
||||
let searchTimer = null
|
||||
|
||||
function normalizeList(result) {
|
||||
@@ -357,7 +379,32 @@ function queueSearch() {
|
||||
searchTimer = window.setTimeout(search, 350)
|
||||
}
|
||||
|
||||
async function loadProtocolProjectOptions() {
|
||||
projectOptionsController?.abort()
|
||||
if (!showProtocolProjectFilter.value) {
|
||||
protocolProjectOptions.value = []
|
||||
return
|
||||
}
|
||||
const current = new AbortController()
|
||||
projectOptionsController = current
|
||||
const bizType = listFilters.value.source || listStatus.value || 'cell_storage'
|
||||
try {
|
||||
const data = await fetchProjectOptions(bizType, {
|
||||
signal: current.signal,
|
||||
filter: true,
|
||||
context: context.value
|
||||
})
|
||||
if (projectOptionsController !== current) return
|
||||
protocolProjectOptions.value = projectOptions(data)
|
||||
} catch (error) {
|
||||
if (projectOptionsController === current && error?.name !== 'AbortError') {
|
||||
protocolProjectOptions.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListFilterOptions() {
|
||||
await loadProtocolProjectOptions()
|
||||
const optionKeys = [
|
||||
...sheetFilterFields.value.map((field) => field.optionKey),
|
||||
...inlineDropdownFilters.value.map((field) => field.optionKey)
|
||||
@@ -593,6 +640,7 @@ function switchListStatus(value) {
|
||||
tab: next
|
||||
}
|
||||
router.replace({ query: nextQuery }).catch(() => {})
|
||||
loadProtocolProjectOptions()
|
||||
loadPage(true)
|
||||
}
|
||||
|
||||
@@ -641,6 +689,7 @@ onBeforeUnmount(() => {
|
||||
const requestId = ++requestSequence
|
||||
listState.value = invalidateMarketRequestState(listState.value, requestId)
|
||||
abortController?.abort()
|
||||
projectOptionsController?.abort()
|
||||
window.clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -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,77 @@
|
||||
# 实现设计与接口契约
|
||||
|
||||
## 预签约统计列(2026-11-04 用户明确调整)
|
||||
|
||||
将统计表左下角预签约计数移为独立“预签约”列,放在细胞存储前,复用现有数量列、图标按钮及明细弹窗。项目/项目组行、业务总量行、未归属行均返回 `presign` 数量;只有预签约的项目/组也显示。预签约仍按当前筛选下未删除的全状态主单计数(含已转正式),与四类正式业务分别统计,不相加。点击数量沿用预签约明细与汇总筛选。统计服务局部增加预签约聚合,保留 `presignTotal` 作为 `totals.presign` 的兼容字段;`COUNT_TYPES` 及历史归属四 tab 范围保持四类正式业务不变。无新界面、弹窗、样式或 SQL,继续用户自测。
|
||||
|
||||
## 历史归属维护 v2(2026-11-04 已确认)
|
||||
|
||||
统计明细后续反馈:用户明确要求业务统计四类明细弹窗复用历史归属维护的全部业务识别列。沿用原明细弹窗、P02 表格和已确认列配置,无新弹窗或视觉布局。新增 `GET /web/admin/project-management/statistics/records`,在路由及服务入口校验 `project:statistics:list`,与归属维护接口复用内部明细查询;两接口分别检查统计/归属维护权限,统计接口只读,不授予调整权限。保留认证租户、主单数据范围、软删除、项目/项目组、日期、未归属、状态条件及主单分页,展示与关键词匹配四业务字段。前端复用同一列配置与查询提示,不显示归属操作;预签约沿用旧通用接口和列。以下 v2 初始方案中“统计接口不返回新增联系信息”的约束仅继续适用于旧 `/records`,本次新统计专用接口按用户明确要求提供这些字段;汇总计数及移动接口不变。
|
||||
|
||||
后续用户明确调整:细胞存储、科研捐赠 tab 的单号列替换为“样本编码”,沿用已确认表格的列宽、位置及样式,不新增界面或视觉布局。专用接口新增只读 `sampleCodes`,按主单的同租户有效样本子行批量读取,多个编码稳定去重后用顿号连接,无样本时显示破折号;关键词增加样本编码 EXISTS 匹配。列表仍一张主单一行,归属操作仍作用于主单及关联链;主单编号保留在接口和原关联预览中供现有逻辑使用。此处仅读取本单样本子行,不涉及接收/质检等下游跨表匹配。
|
||||
|
||||
用户明确采用 [Figma 原型 v2](https://www.figma.com/design/wOQLDAhr1gdlQEXnVaFO9S?node-id=13-23)。范围为 Web 历史归属维护及其后端读取接口,沿用用户先合入 V2.0 再自测的安排。复用 P02 列表和 `pilot-service/pilot-application/index.vue` 的 `panel-pill-tabs`,操作列固定右侧,搜索右对齐。
|
||||
|
||||
`RecordList` 仅在 assignment 模式显示四类正式业务 tab 和业务识别列。细胞存储/科研捐赠展示母亲姓名、电话、采集医院、市场专员及采集/捐赠日期;中试展示申请名称、渠道、服务订单名称、患者姓名/电话;基础实验展示课题、客户名称/联系人/电话。公共列保留序号、主单号、状态、项目组、所属项目、创建时间。多个有效子记录以稳定顺序去重连接展示,缺失值显示破折号,采集日期来自采集信息的样本采集时间。
|
||||
|
||||
新增 `GET /web/admin/project-management/assignments/records`,严格要求 `project:assignment:edit`,仅接受四类正式业务。原 `/records` 继续服务统计明细,不扩展联系信息;移动接口不变。专用服务复用主单租户、软删除、数据权限和归属筛选。关键词支持单号、姓名/电话、医院/专员、渠道/服务订单名称、课题/客户信息(日期使用现有创建日期条件);子表使用同租户未删除记录 EXISTS 匹配,不增加主单行数。分页后批量补充项目、医院、人员、患者和服务订单资料,避免逐行查询。
|
||||
|
||||
切换 tab 清空关键词、状态、项目组/项目并回第一页,保留日期和仅未归属条件;重新挂载候选选择器避免短暂显示上一个业务的选项。列表请求按序号丢弃过时响应,提供加载、空态和失败重试。归属弹窗绑定选中单据的业务类型,沿用原项目选择权限、关联单据预览、并发快照及审计。预签约不单列 tab,但关联预签约仍随正式单预览和调整。不新增批量操作、数据库字段或迁移。
|
||||
|
||||
## 模块边界
|
||||
|
||||
新增 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,83 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 按业务识别历史归属单据
|
||||
历史归属维护 SHALL 按细胞存储、科研捐赠、中试服务、基础实验展示四个 tab,并提供对应业务识别字段和关键词搜索;列表保持主单分页,右侧固定归属与审计操作。
|
||||
#### Scenario: 存储和捐赠以样本编码识别
|
||||
- **WHEN** 查看细胞存储或科研捐赠 tab
|
||||
- **THEN** 展示样本编码列而非协议单号/捐赠单号列;多个有效编码在同一主单行去重展示,无编码显示破折号,支持按任一编码搜索;跨租户或已删除的样本不用于展示和匹配,分页和归属仍以主单为单位。
|
||||
#### Scenario: 按联系人或业务信息定位
|
||||
- **WHEN** 用户在某类业务输入姓名、电话、医院/专员、渠道/服务订单或课题/客户等该业务支持的关键词
|
||||
- **THEN** 仅返回当前租户及数据权限内匹配的有效主单,展示对应业务信息,多条子记录不重复计数;删除和跨租户关联数据不用于匹配或展示。
|
||||
#### Scenario: 切换业务及请求竞争
|
||||
- **WHEN** 用户切换 tab 或旧请求在新请求后返回
|
||||
- **THEN** 切换时清空关键词、状态、项目组/项目并回到第一页,保留日期与仅未归属条件;过时响应不得覆盖当前业务的列表或计数,失败可重试且不保留旧列表。
|
||||
#### Scenario: 业务联系信息权限
|
||||
- **WHEN** 仅有统计权限的用户读取统计钻取或尝试调用归属维护详情接口
|
||||
- **THEN** 统计专用详情接口按 `project:statistics:list` 返回其租户及主单数据权限内的业务识别和联系信息;归属维护详情接口仍拒绝无 `project:assignment:edit` 权限的访问,全部项目选择权限不扩大主单数据范围。
|
||||
#### Scenario: 归属调整仍按关联链执行
|
||||
- **WHEN** 用户从某个业务 tab 调整归属
|
||||
- **THEN** 目标候选按该单据业务类型及项目权限加载,仍先预览关联单据再确认提交;关联预签约纳入预览和审计,不新增独立预签约 tab。
|
||||
|
||||
### Requirement: 项目管理三级导航
|
||||
Web SHALL 使用“项目 → 项目管理 → 项目组管理 / 项目管理 / 业务统计 / 历史归属维护”层级,复用现有导航组件;原页面权限、名称和功能保持。
|
||||
#### Scenario: 现有角色升级
|
||||
- **WHEN** 执行项目管理分组迁移
|
||||
- **THEN** 保留子页面 ID 与原授权,仅为已授权子页面/编辑按钮的角色补齐目录祖先;全部项目选择权限仍独立挂在项目一级下,不授予管理页面权限。
|
||||
#### Scenario: 旧地址访问
|
||||
- **WHEN** 访问 `/project/{页面}` 或 `/market/project-management/{页面}`
|
||||
- **THEN** 携带原查询参数及 hash 跳转至 `/project/management/{页面}`,目标仍按原页面权限检查。
|
||||
|
||||
### Requirement: 组织信息与业务地点独立
|
||||
系统 SHALL 维护项目组负责人、成员及区域医院资料,区域医院仅为信息记录。
|
||||
#### Scenario: 修改负责医院
|
||||
- **WHEN** 项目组医院发生修改
|
||||
- **THEN** 已有订单、业务地区医院候选与权限均保持原有行为。
|
||||
|
||||
### Requirement: 稳定且受控的项目归属
|
||||
系统 SHALL 在业务主单保存项目归属,限制同租户授权选择,人员调整不得迁移历史订单。
|
||||
#### Scenario: 转换单据
|
||||
- **WHEN** 预签约转协议或存储捐赠互转
|
||||
- **THEN** 目标继承来源项目,记录来源关联。
|
||||
#### Scenario: 历史单据编辑
|
||||
- **WHEN** 已停用项目的历史单据修改其他字段
|
||||
- **THEN** 保留其项目归属,不因项目停用拒绝无关编辑。
|
||||
#### Scenario: 归属纠正
|
||||
- **WHEN** 有授权的管理员预览并确认调整关联单据归属
|
||||
- **THEN** 服务端核对权限、关联集合及旧值,统一事务更新并留审计;不一致时拒绝。
|
||||
|
||||
### Requirement: 权限内统计
|
||||
系统 SHALL 按认证租户和现有数据权限统计四类业务,预签约另计,未归属单据单列。
|
||||
#### Scenario: 预签约独立统计列
|
||||
- **WHEN** 按项目或项目组查看业务统计
|
||||
- **THEN** 表格以“预签约”独立列展示每组、业务总量、未归属的全状态有效预签约单数,包含只有预签约的组;移除左下角独立计数文字,点击数量可查看同筛选明细,不将预签约数量加入四类正式业务数量。
|
||||
#### Scenario: 汇总钻取
|
||||
- **WHEN** 用户打开某项目的某类业务明细
|
||||
- **THEN** 明细总数与相同筛选条件汇总一致,不因子行联接重复计数。
|
||||
#### Scenario: 四类统计明细展示业务字段
|
||||
- **WHEN** 点击细胞存储、科研捐赠、中试服务、基础实验的统计数量
|
||||
- **THEN** 原明细弹窗复用历史归属维护对应 tab 的业务列与关键词能力,存储/捐赠显示样本编码,保留汇总传入的筛选条件和只读权限,不显示归属维护操作;预签约仍沿用原行为。
|
||||
|
||||
### 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,61 @@
|
||||
# 项目业务归属实施计划
|
||||
|
||||
**目标:** 实现工单 #13 已批准的项目归属与数量/状态统计。
|
||||
**架构:** 新管理模块统一校验与统计;现有业务显式接入;前端复用已有样式。
|
||||
**技术栈:** FastAPI/SQLAlchemy/Pydantic、Vue3/Element Plus、Vue3/Vant。
|
||||
|
||||
## 2026-11-04 历史归属维护四业务 tab
|
||||
|
||||
用户已明确“采用原型 v2,继续开发”,继续按用户自测与先合入 V2.0 再测试的指令交付;本轮不运行测试、构建或浏览器业务验收,不关闭工单。
|
||||
- [x] 核对四类业务真实字段、现有样式和权限,完成 Figma v2 及用户确认。
|
||||
- [x] 新增归属维护专用读取接口:四类主单、业务关键词、同租户有效子表匹配和分页后批量资料读取;统计及移动契约不变。
|
||||
- [x] 实现四 tab、各业务识别列、关键词首位、状态语义、固定操作、筛选重置及过时响应防护。
|
||||
- [x] 编写 5 个后端回归用例供后续运行;独立源码审查未发现新增 P1/P2,Git 差异核对通过。本轮未执行测试/构建或浏览器验收。
|
||||
- [ ] 提交推送、PR 合入 V2.0 并同步主目录,回写待用户测试的提交、入口和覆盖点,保留工单及任务环境。
|
||||
|
||||
## 2026-11-04 菜单增加项目管理分组
|
||||
|
||||
用户明确指定层级:项目 → 项目管理 → 项目组管理 / 项目管理 / 业务统计 / 历史归属维护。仅重组现有菜单配置,复用市场三级导航,不新增业务页面或修改页面样式。沿用用户自测及先合入 V2.0 再测试的安排,工单保持开放。
|
||||
- [x] 同步 Web `sqxp.ts` 与后端 `menu.py`,新增 `menu:project:management` 目录,保留四个页面名称、组件和权限码。
|
||||
- [x] `staticRoutes.ts` 兼容两代旧地址,保留 query 与 hash。
|
||||
- [x] 新增 `2026-11-04-project_management_group.sql`,保留叶子 ID 与角色授权,仅补新祖先;全部项目权限仍独立挂在项目一级下,更新 README 顺序。
|
||||
- [x] 独立源码审查未发现 P1/P2,Git 差异核对通过;用户测试模式未运行测试或构建。
|
||||
- [ ] 提交续接 PR、合入 V2.0、同步主目录;备份并更新已确认测试库菜单,交付用户验收。
|
||||
|
||||
以下为此前阶段历史记录;其中待合并等状态已由 2026-11-04 用户明确先合并再测试的指令取代。
|
||||
|
||||
用户追加所属项目权限要求:将跨组选择从归属维护权限分离为 `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
|
||||
@@ -57,6 +59,12 @@ export function fetchCollectionProtocolStatusChanges(protocolId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCollectionProtocolOperationLogs(protocolId: string) {
|
||||
return request.get<{ list: Array<Record<string, unknown>> }>({
|
||||
url: `/web/admin/collection-protocols/${protocolId}/operation-logs`
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCollectionProtocolAuditList(params?: {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
|
||||
@@ -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,104 @@
|
||||
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>
|
||||
export type AssignmentBizType = Exclude<BizType, 'presign'>
|
||||
export interface AssignmentRecord {
|
||||
id: string
|
||||
bizType: AssignmentBizType
|
||||
bizNo: string | null
|
||||
status: string
|
||||
projectId: string | null
|
||||
projectName: string | null
|
||||
teamId: string | null
|
||||
teamName: string | null
|
||||
createTime: string | null
|
||||
sampleCodes?: string
|
||||
motherName?: string
|
||||
motherPhone?: string
|
||||
hospitalName?: string
|
||||
salesUserName?: string
|
||||
collectionDate?: string
|
||||
donationDate?: string
|
||||
applicationName?: string | null
|
||||
channelName?: string | null
|
||||
serviceOrderName?: string | null
|
||||
patientName?: string
|
||||
patientPhone?: string
|
||||
projectTitle?: string | null
|
||||
customerName?: string | null
|
||||
customerContact?: string | null
|
||||
customerPhone?: string | null
|
||||
}
|
||||
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 fetchAssignmentRecords = (params: Record<string, unknown>) =>
|
||||
request.get<{ list: AssignmentRecord[]; total: number; statusCounts: Record<string, number> }>({
|
||||
url: `${base}/assignments/records`,
|
||||
params
|
||||
})
|
||||
export const fetchStatisticsRecords = (params: Record<string, unknown>) =>
|
||||
request.get<{ list: AssignmentRecord[]; total: number; statusCounts: Record<string, number> }>({
|
||||
url: `${base}/statistics/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
|
||||
@@ -116,6 +118,12 @@ export function fetchDonationStatusChanges(donationId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDonationOperationLogs(donationId: string) {
|
||||
return request.get<{ list: Array<Record<string, unknown>> }>({
|
||||
url: `/web/admin/research-donations/${donationId}/operation-logs`
|
||||
})
|
||||
}
|
||||
|
||||
// 状态变更
|
||||
export function fetchDonationStatusChange(
|
||||
donationId: string,
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
<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
|
||||
: teamId
|
||||
? option.projectName
|
||||
: `${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
|
||||
/** When selecting projects, keep options under this team. */
|
||||
teamId?: string
|
||||
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(() => {
|
||||
if (props.team) {
|
||||
return [
|
||||
...new Map(
|
||||
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
|
||||
).values()
|
||||
]
|
||||
}
|
||||
return props.teamId
|
||||
? options.value.filter((option) => option.teamId === props.teamId)
|
||||
: 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>
|
||||
@@ -86,6 +86,9 @@
|
||||
<div v-else-if="f.comment" class="oa-print-flow__comment">
|
||||
意见:{{ f.comment }}
|
||||
</div>
|
||||
<div v-for="url in f.attachmentLinks" :key="url" class="oa-print-url">
|
||||
<a :href="url" data-oa-print-link>{{ url }}</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -221,6 +224,10 @@
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.oa-print-url a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.oa-print-url {
|
||||
color: #3370ff;
|
||||
word-break: break-all;
|
||||
@@ -248,6 +255,8 @@
|
||||
}
|
||||
|
||||
.oa-print-flow__left {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,63 @@
|
||||
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: 'ProjectManagement',
|
||||
path: 'management',
|
||||
component: '',
|
||||
meta: {
|
||||
title: '项目管理',
|
||||
icon: 'ri:folder-chart-line',
|
||||
keepAlive: false,
|
||||
menuCode: 'menu:project:management'
|
||||
},
|
||||
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)
|
||||
// 二级菜单:存储服务、科研捐赠、市场基础信息、中试服务、基础实验
|
||||
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user