Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
f1b4a5ce26 | ||
|
|
01fef051da |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"plugins": {
|
||||
"figma": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,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,
|
||||
@@ -442,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,
|
||||
@@ -510,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,
|
||||
@@ -540,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,
|
||||
@@ -568,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))
|
||||
@@ -627,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,
|
||||
@@ -635,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="审核已提交")
|
||||
@@ -659,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})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 收款确认
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -45,6 +45,7 @@ class TeamBody(Body):
|
||||
|
||||
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]
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 (
|
||||
@@ -272,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,
|
||||
@@ -312,6 +323,9 @@ async def update_research_donation(
|
||||
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,
|
||||
@@ -320,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,
|
||||
@@ -353,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))
|
||||
@@ -403,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,
|
||||
@@ -411,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)
|
||||
@@ -437,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,
|
||||
@@ -447,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="状态变更成功",
|
||||
@@ -481,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. 细胞存储 → 科研捐赠
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -496,12 +589,35 @@ async def convert_from_storage(
|
||||
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="转换成功",
|
||||
@@ -509,7 +625,6 @@ async def convert_from_storage(
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11. 科研捐赠 → 细胞存储
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -53,6 +53,7 @@ _DONATION_STATUS_LABELS = {
|
||||
_VOID_TYPE_FALLBACK_LABELS = {
|
||||
"khhc": "客户毁存",
|
||||
"jcbhg": "检测不合格",
|
||||
"sysfq": "实验室废弃",
|
||||
}
|
||||
|
||||
# 非法来源状态视为终态,不允许再转出
|
||||
|
||||
@@ -2642,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
|
||||
@@ -2650,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 ""):
|
||||
@@ -2660,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("请选择作废类型")
|
||||
@@ -2749,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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1686,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
|
||||
|
||||
@@ -1033,6 +1033,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 +1157,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
|
||||
|
||||
@@ -75,12 +75,12 @@ ApiRequestLog = SysApiRequestLog
|
||||
|
||||
from app.models.project_management import (
|
||||
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
|
||||
ProjectTeamHospital, ProjectAssignmentAudit,
|
||||
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Project", "ProjectTeam", "ProjectTeamMember", "ProjectTeamRegion",
|
||||
"ProjectTeamHospital", "ProjectAssignmentAudit",
|
||||
"ProjectTeamHospital", "ProjectAssignmentAudit", "ProjectTeamHistory",
|
||||
"BaseModel",
|
||||
"SoftDeleteModel",
|
||||
"SysUser",
|
||||
|
||||
@@ -1149,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"""
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""租户项目及归属审计;区域医院关联只记录组织资料。"""
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, UniqueConstraint
|
||||
from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, Text, UniqueConstraint
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
@@ -65,3 +65,16 @@ class ProjectAssignmentAudit(ProjectFields, Base):
|
||||
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)
|
||||
@@ -22,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
|
||||
@@ -1531,6 +1532,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 '',
|
||||
@@ -1571,6 +1578,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 '',
|
||||
@@ -1841,17 +1854,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('不支持的协议来源')
|
||||
|
||||
@@ -1958,6 +1999,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,
|
||||
@@ -2013,6 +2065,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,
|
||||
@@ -2050,6 +2113,9 @@ def update_protocol(
|
||||
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
|
||||
)
|
||||
@@ -2178,6 +2244,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,
|
||||
@@ -2195,6 +2272,9 @@ def update_protocol(
|
||||
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
|
||||
)
|
||||
@@ -2278,6 +2358,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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""项目资料、权限内统计和关联归属事务。"""
|
||||
import json
|
||||
from datetime import date, datetime, timedelta
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import or_, and_, func
|
||||
@@ -6,7 +7,7 @@ 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)
|
||||
ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory)
|
||||
from app.models.models import (SysUser, BizCollectionRegion, BizCollectionHospital,
|
||||
BizPresignOrder, BizCollectionProtocol, BizPilotApplication)
|
||||
from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert
|
||||
@@ -250,6 +251,44 @@ CHILDREN = (
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
@@ -322,6 +361,7 @@ def save_entity(db, tenant, user, model, body, row_id=None):
|
||||
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):
|
||||
@@ -333,8 +373,17 @@ def save_entity(db, tenant, user, model, body, row_id=None):
|
||||
if status not in ('pending', 'active', 'completed', 'disabled'):
|
||||
raise HTTPException(400, '项目状态无效')
|
||||
row.project_name, row.team_id, row.biz_types, row.status = body[name_key].strip(), team.id, ','.join(dict.fromkeys(kinds)), status
|
||||
if not row_id:
|
||||
row.project_no = 'XM' + datetime.now().strftime('%Y%m%d') + generate_uuid().replace('-', '')[:16].upper()
|
||||
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
|
||||
@@ -353,6 +402,8 @@ def delete_entity(db, tenant, user, model, 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:
|
||||
|
||||
@@ -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
|
||||
@@ -10,9 +10,10 @@ class ProjectManagementTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.assertIsNotNone(importlib.util.find_spec('app.services.project_management'), 'project service missing')
|
||||
from app.services import project_management as svc
|
||||
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember
|
||||
from app.models.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)
|
||||
@@ -148,8 +149,8 @@ class ProjectManagementTest(unittest.TestCase):
|
||||
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
|
||||
self.db.commit()
|
||||
result = self.svc.save_entity(self.db, 't', self.user, self.Project,
|
||||
dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
|
||||
self.assertTrue(result['projectNo'].startswith('XM'))
|
||||
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):
|
||||
@@ -235,6 +236,58 @@ class ProjectManagementTest(unittest.TestCase):
|
||||
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,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()
|
||||
@@ -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,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`。
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 || '')
|
||||
@@ -683,6 +743,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: '状态变更记录',
|
||||
@@ -690,9 +752,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)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ 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), [
|
||||
|
||||
@@ -59,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
|
||||
|
||||
@@ -118,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,
|
||||
|
||||
@@ -22,13 +22,14 @@ export function assignmentSnapshot(records: ProjectRecord[]) {
|
||||
}))
|
||||
}
|
||||
|
||||
/** Write bodies intentionally exclude response-only labels, timestamps and generated numbers. */
|
||||
/** Write bodies intentionally exclude response-only labels and timestamps. */
|
||||
export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecord): ProjectRecord {
|
||||
const fields =
|
||||
kind === 'teams'
|
||||
? ['teamName', 'leaderUserId', 'enabled', 'remark']
|
||||
: [
|
||||
'projectName',
|
||||
'projectNo',
|
||||
'teamId',
|
||||
'leaderUserId',
|
||||
'bizTypes',
|
||||
@@ -58,3 +59,11 @@ export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecor
|
||||
export function projectOptionsParams(bizType: BizType, filter = false) {
|
||||
return filter ? { bizType, usage: 'filter' as const } : { bizType }
|
||||
}
|
||||
|
||||
/** List cells join child display names; empty entries are dropped. */
|
||||
export function joinChildNames(items?: ProjectRecord[]) {
|
||||
return (items || [])
|
||||
.map((item) => item?.name)
|
||||
.filter(Boolean)
|
||||
.join('、')
|
||||
}
|
||||
@@ -26,6 +26,8 @@ describe('flattenProtocolAuditRows', () => {
|
||||
id: 'p1',
|
||||
protocolNo: 'ZKZX1',
|
||||
motherName: '郭梦朵',
|
||||
projectName: '测试项目',
|
||||
teamName: '陈星项目组111',
|
||||
sampleCollectTime: '2025-09-15 08:51:00',
|
||||
sampleItems: [
|
||||
{ sampleTypeName: '脐带血', sampleCode: 'A1', labStorageCode: 'B0000004' },
|
||||
@@ -40,6 +42,25 @@ describe('flattenProtocolAuditRows', () => {
|
||||
assert.equal(rows[0].labStorageCode, 'B0000004')
|
||||
assert.equal(rows[1].labStorageCode, 'B0000004A')
|
||||
assert.equal(rows[0].protocolId, 'p1')
|
||||
assert.equal(rows[0].projectName, '测试项目')
|
||||
assert.equal(rows[0].teamName, '陈星项目组111')
|
||||
assert.equal(rows[1].projectName, '测试项目')
|
||||
assert.equal(rows[1].teamName, '陈星项目组111')
|
||||
})
|
||||
|
||||
it('copies protocol project attribution onto each flat row for table props', () => {
|
||||
const rows = flattenProtocolAuditRows([
|
||||
{
|
||||
id: 'p-attr',
|
||||
projectName: '归属项目',
|
||||
teamName: '归属项目组',
|
||||
sampleItems: [{ sampleCode: 'ZKZX34000199', sampleTypeName: '围产期造血干细胞' }]
|
||||
}
|
||||
])
|
||||
assert.equal(rows.length, 1)
|
||||
assert.equal(rows[0].projectName, '归属项目')
|
||||
assert.equal(rows[0].teamName, '归属项目组')
|
||||
assert.equal(rows[0].sampleCode, 'ZKZX34000199')
|
||||
})
|
||||
|
||||
it('zero samples → one placeholder row, not co-stored', () => {
|
||||
|
||||
@@ -20,6 +20,9 @@ export type ProtocolAuditFlatRow = {
|
||||
sampleCount: number
|
||||
sampleIndex: number
|
||||
isCoStored: boolean
|
||||
/** 协议级项目归属,供扁平行表格 prop 绑定 */
|
||||
projectName: string
|
||||
teamName: string
|
||||
collectDate: string
|
||||
sampleCollectTime: string
|
||||
motherName: string
|
||||
@@ -95,6 +98,8 @@ export function flattenProtocolAuditRows(
|
||||
sampleCount,
|
||||
sampleIndex,
|
||||
isCoStored,
|
||||
projectName: str(protocol.projectName),
|
||||
teamName: str(protocol.teamName),
|
||||
collectDate: extractCollectDate(str(protocol.sampleCollectTime)),
|
||||
sampleCollectTime: str(protocol.sampleCollectTime),
|
||||
motherName: str(protocol.motherName),
|
||||
|
||||
@@ -363,7 +363,7 @@ export const taskStatusTagType = createTagTypeGetter(taskStatus)
|
||||
|
||||
export const acceptConclusion: StatusRegistry = {
|
||||
js: { label: '接收', tagType: 'success' },
|
||||
bj: { label: '拒收', tagType: 'danger' }
|
||||
bj: { label: '废弃', tagType: 'danger' }
|
||||
}
|
||||
export const acceptConclusionLabel = createLabelGetter(acceptConclusion)
|
||||
export const acceptConclusionTagType = createTagTypeGetter(acceptConclusion)
|
||||
|
||||
@@ -6,7 +6,8 @@ export const VOID_TYPE_DICT = 'xyzflx'
|
||||
|
||||
const FALLBACK_LABELS: Record<string, string> = {
|
||||
khhc: '客户毁存',
|
||||
jcbhg: '检测不合格'
|
||||
jcbhg: '检测不合格',
|
||||
sysfq: '实验室废弃'
|
||||
}
|
||||
|
||||
function buildFallbackOptions(): Api.Dict.DictDataOption[] {
|
||||
|
||||
@@ -69,8 +69,7 @@
|
||||
@selection-change="(rows: any[]) => (selectedRows = rows)"
|
||||
@row-dblclick="handleEdit"
|
||||
>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
@@ -79,6 +78,8 @@
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160" />
|
||||
<el-table-column prop="teamName" label="项目组" min-width="150" />
|
||||
<el-table-column
|
||||
prop="orderDate"
|
||||
label="业务日期"
|
||||
|
||||
@@ -132,11 +132,12 @@
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleRowDblClick"
|
||||
>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
|
||||
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
|
||||
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
|
||||
<el-table-column label="序号" min-width="64" align="center" header-align="center">
|
||||
<template #default="{ $index }">{{ $index + 1 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160" />
|
||||
<el-table-column prop="teamName" label="项目组" min-width="150" />
|
||||
<el-table-column
|
||||
prop="collectDate"
|
||||
label="采集日期"
|
||||
|
||||
@@ -144,11 +144,12 @@
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleRowDblClick"
|
||||
>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
|
||||
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
|
||||
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
|
||||
<el-table-column label="序号" min-width="64" align="center" header-align="center">
|
||||
<template #default="{ $index }">{{ $index + 1 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160" />
|
||||
<el-table-column prop="teamName" label="项目组" min-width="150" />
|
||||
<el-table-column
|
||||
prop="collectDate"
|
||||
label="采集日期"
|
||||
|
||||
@@ -1174,6 +1174,41 @@
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志(Issue #24) -->
|
||||
<div id="view-section-operation-log" class="section-card section-card--white">
|
||||
<div class="section-title section-title--banner"><h3>操作日志</h3></div>
|
||||
<div class="list-body list-wrap view-inner-table-wrap">
|
||||
<el-table
|
||||
v-loading="operationLogLoading"
|
||||
class="list-table inner-table"
|
||||
:data="operationLogRecords"
|
||||
stripe
|
||||
empty-text="暂无操作日志"
|
||||
>
|
||||
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
|
||||
<el-table-column label="动作" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="operationActionTagType(String(row.action || ''))" size="small">
|
||||
{{ operationActionLabel(String(row.action || '')) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源端" min-width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">{{ operationChannelLabel(String(row.clientChannel || '')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作时间" min-width="170" align="center" header-align="center">
|
||||
<template #default="{ row }">{{ dash(row.operateTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" min-width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">{{ dash(row.operatorUserName) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变更摘要" min-width="260" align="left" header-align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ operationSummary(row) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1226,6 +1261,7 @@
|
||||
fetchCollectionProtocolAuditList,
|
||||
fetchCollectionProtocolDetail,
|
||||
fetchCollectionProtocolStatusChanges,
|
||||
fetchCollectionProtocolOperationLogs,
|
||||
type CollectionProtocolRow
|
||||
} from '@/api/collection-protocol'
|
||||
import {
|
||||
@@ -1317,6 +1353,7 @@
|
||||
}
|
||||
tabs.push({ key: 'attachment', label: '附件信息' })
|
||||
tabs.push({ key: 'status-change', label: '状态变更记录' })
|
||||
tabs.push({ key: 'operation-log', label: '操作日志' })
|
||||
return tabs
|
||||
})
|
||||
|
||||
@@ -1327,6 +1364,90 @@
|
||||
const bundle = ref<Record<string, any> | null>(null)
|
||||
const auditRecords = ref<Array<Record<string, unknown>>>([])
|
||||
const statusChangeRecords = ref<Array<Record<string, unknown>>>([])
|
||||
const operationLogLoading = ref(false)
|
||||
const operationLogRecords = ref<Array<Record<string, unknown>>>([])
|
||||
|
||||
const OPERATION_ACTION_LABEL: Record<string, string> = {
|
||||
create: '创建',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
void: '作废',
|
||||
activate: '生效',
|
||||
expire: '过期',
|
||||
to_research: '转科研',
|
||||
to_storage: '转细胞存储',
|
||||
audit: '审核'
|
||||
}
|
||||
const OPERATION_ACTION_TAG: Record<string, 'success' | 'warning' | 'danger' | 'primary' | 'info'> = {
|
||||
create: 'success',
|
||||
update: 'primary',
|
||||
delete: 'danger',
|
||||
void: 'danger',
|
||||
activate: 'success',
|
||||
expire: 'warning',
|
||||
to_research: 'primary',
|
||||
to_storage: 'primary',
|
||||
audit: 'info'
|
||||
}
|
||||
|
||||
function operationActionLabel(action: string) {
|
||||
return OPERATION_ACTION_LABEL[action] || action || '—'
|
||||
}
|
||||
function operationActionTagType(action: string) {
|
||||
return OPERATION_ACTION_TAG[action] || 'info'
|
||||
}
|
||||
function operationChannelLabel(channel: string) {
|
||||
if (channel === 'mini') return '小程序'
|
||||
if (channel === 'web') return 'Web'
|
||||
return channel || '—'
|
||||
}
|
||||
function operationSummary(row: Record<string, unknown>) {
|
||||
const before = (row.before || {}) as Record<string, any>
|
||||
const after = (row.after || {}) as Record<string, any>
|
||||
const beforeProto = (before.protocol || before.donation || {}) as Record<string, any>
|
||||
const afterProto = (after.protocol || after.donation || {}) as Record<string, any>
|
||||
const beforeParty = (before.partyCustomer || before.party || {}) as Record<string, any>
|
||||
const afterParty = (after.partyCustomer || after.party || {}) as Record<string, any>
|
||||
const parts: string[] = []
|
||||
const beforeStatus = String(beforeProto.protocolStatus || beforeProto.donationStatus || '')
|
||||
const afterStatus = String(afterProto.protocolStatus || afterProto.donationStatus || '')
|
||||
if (beforeStatus || afterStatus) {
|
||||
if (beforeStatus && afterStatus && beforeStatus !== afterStatus) {
|
||||
parts.push(`状态 ${beforeStatus}→${afterStatus}`)
|
||||
} else if (afterStatus) {
|
||||
parts.push(`状态 ${afterStatus}`)
|
||||
}
|
||||
}
|
||||
for (const key of ['motherName', 'customerName', 'donorName', 'name']) {
|
||||
const b = String(beforeParty[key] || '').trim()
|
||||
const a = String(afterParty[key] || '').trim()
|
||||
if (b || a) {
|
||||
if (b && a && b !== a) parts.push(`姓名 ${b}→${a}`)
|
||||
else if (a && !b) parts.push(`姓名 ${a}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
const beforeSamples = Array.isArray(before.samples) ? before.samples : []
|
||||
const afterSamples = Array.isArray(after.samples) ? after.samples : []
|
||||
if (beforeSamples.length || afterSamples.length) {
|
||||
const pick = (list: any[]) =>
|
||||
list
|
||||
.map((s) => `${s?.sampleCode || ''}:${s?.sampleStatus || s?.status || ''}`)
|
||||
.filter((x) => x !== ':')
|
||||
.join(',')
|
||||
const bs = pick(beforeSamples)
|
||||
const as = pick(afterSamples)
|
||||
if (bs || as) {
|
||||
if (bs && as && bs !== as) parts.push(`样本 ${bs}→${as}`)
|
||||
else if (as) parts.push(`样本 ${as}`)
|
||||
}
|
||||
}
|
||||
if (!parts.length) {
|
||||
const remark = String(row.remark || '').trim()
|
||||
return remark || '见前后快照'
|
||||
}
|
||||
return parts.join(';')
|
||||
}
|
||||
|
||||
const STATUS_CHANGE_TYPE_LABEL: Record<string, string> = {
|
||||
activate: '生效',
|
||||
@@ -2088,6 +2209,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOperationLogRecords() {
|
||||
if (!props.protocolId) {
|
||||
operationLogRecords.value = []
|
||||
return
|
||||
}
|
||||
operationLogLoading.value = true
|
||||
try {
|
||||
const res = await fetchCollectionProtocolOperationLogs(props.protocolId)
|
||||
operationLogRecords.value = res.list || []
|
||||
} catch {
|
||||
operationLogRecords.value = []
|
||||
} finally {
|
||||
operationLogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuditRecords() {
|
||||
if (!props.showAuditHistory || !props.protocolId) {
|
||||
auditRecords.value = []
|
||||
@@ -2115,10 +2252,12 @@
|
||||
bundle.value = null
|
||||
auditRecords.value = []
|
||||
statusChangeRecords.value = []
|
||||
operationLogRecords.value = []
|
||||
|
||||
const baseLookupsPromise = loadBaseLookups().catch(() => {})
|
||||
const auditPromise = props.showAuditHistory ? loadAuditRecords() : null
|
||||
const statusChangePromise = loadStatusChangeRecords()
|
||||
const operationLogPromise = loadOperationLogRecords()
|
||||
|
||||
let data: Record<string, any> | null = null
|
||||
try {
|
||||
@@ -2136,6 +2275,7 @@
|
||||
void finishLookups(data, baseLookupsPromise)
|
||||
void auditPromise
|
||||
void statusChangePromise
|
||||
void operationLogPromise
|
||||
if (props.highlightSampleCode) {
|
||||
nextTick(() => {
|
||||
document
|
||||
@@ -2159,6 +2299,7 @@
|
||||
bundle.value = null
|
||||
auditRecords.value = []
|
||||
statusChangeRecords.value = []
|
||||
operationLogRecords.value = []
|
||||
activeTab.value = 'basic'
|
||||
detailLoading.value = false
|
||||
lookupLoading.value = false
|
||||
|
||||
@@ -105,8 +105,7 @@
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
@@ -115,6 +114,8 @@
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160" />
|
||||
<el-table-column prop="teamName" label="项目组" min-width="150" />
|
||||
<el-table-column
|
||||
prop="protocolStatus"
|
||||
label="状态"
|
||||
|
||||
@@ -116,9 +116,7 @@
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
|
||||
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
@@ -127,6 +125,8 @@
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
|
||||
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
|
||||
<el-table-column
|
||||
prop="applicationNo"
|
||||
label="申请单号"
|
||||
|
||||
@@ -74,6 +74,16 @@
|
||||
<el-table-column v-if="!isTeam" prop="projectNo" label="项目编号" min-width="160" />
|
||||
<el-table-column v-if="!isTeam" prop="teamName" label="所属项目组" min-width="160" />
|
||||
<el-table-column prop="leaderName" label="负责人" min-width="120" />
|
||||
<el-table-column v-if="isTeam" label="成员" min-width="180" show-overflow-tooltip
|
||||
><template #default="{ row }">{{
|
||||
joinChildNames(row.members)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column v-if="isTeam" label="负责医院" min-width="200" show-overflow-tooltip
|
||||
><template #default="{ row }">{{
|
||||
joinChildNames(row.hospitals)
|
||||
}}</template></el-table-column
|
||||
>
|
||||
<el-table-column label="状态" min-width="110"
|
||||
><template #default="{ row }"
|
||||
><el-tag
|
||||
@@ -118,7 +128,7 @@
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
:title="`${readonly ? '查看' : editingId ? '编辑' : '新增'}${isTeam ? '项目组' : '项目'}`"
|
||||
width="720px"
|
||||
:width="isTeam && !readonly ? '960px' : '720px'"
|
||||
class="dialog-form-layout"
|
||||
append-to-body
|
||||
modal-class="protocol-dialog-mask"
|
||||
@@ -139,12 +149,19 @@
|
||||
v-else
|
||||
v-model="form.projectName"
|
||||
/></el-form-item>
|
||||
<el-form-item
|
||||
v-if="!isTeam"
|
||||
label="项目编号"
|
||||
prop="projectNo"
|
||||
:rules="[{ required: true, message: '请输入项目编号' }]"
|
||||
><el-input v-model="form.projectNo" maxlength="64" show-word-limit
|
||||
/></el-form-item>
|
||||
<el-form-item
|
||||
v-if="!isTeam"
|
||||
label="所属项目组"
|
||||
prop="teamId"
|
||||
:rules="[{ required: true, message: '请选择项目组' }]"
|
||||
><el-select v-model="form.teamId" filterable
|
||||
><el-select v-model="form.teamId" filterable @change="onTeamChange"
|
||||
><el-option
|
||||
v-for="team in teams"
|
||||
:key="team.id"
|
||||
@@ -163,7 +180,7 @@
|
||||
:label="user.name" /></el-select
|
||||
></el-form-item>
|
||||
<template v-if="isTeam">
|
||||
<el-form-item label="成员"
|
||||
<el-form-item label="成员" :class="{ 'form-col-span-2': !readonly }"
|
||||
><el-select v-model="memberIds" multiple filterable
|
||||
><el-option
|
||||
v-for="user in refs.users"
|
||||
@@ -171,7 +188,7 @@
|
||||
:value="user.id"
|
||||
:label="user.name" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="负责地区(信息记录)"
|
||||
<el-form-item label="负责地区(信息记录)" :class="{ 'form-col-span-2': !readonly }"
|
||||
><el-select v-model="regionIds" multiple filterable
|
||||
><el-option
|
||||
v-for="region in refs.regions"
|
||||
@@ -179,7 +196,7 @@
|
||||
:value="region.id"
|
||||
:label="region.name" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="负责医院(信息记录)"
|
||||
<el-form-item label="负责医院(信息记录)" :class="{ 'form-col-span-2': !readonly }"
|
||||
><el-select v-model="hospitalIds" multiple filterable
|
||||
><el-option
|
||||
v-for="hospital in refs.hospitals"
|
||||
@@ -187,7 +204,9 @@
|
||||
:value="hospital.id"
|
||||
:label="hospital.name" /></el-select
|
||||
></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
|
||||
<el-form-item label="启用" :class="{ 'form-col-span-2': !readonly }"
|
||||
><el-switch v-model="form.enabled"
|
||||
/></el-form-item>
|
||||
<div class="form-tip form-col-span-2"
|
||||
>地区、医院仅记录项目组职责,不改变业务地点候选或数据权限。</div
|
||||
>
|
||||
@@ -241,7 +260,7 @@
|
||||
>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { managementPayload } from '@/utils/project-management'
|
||||
import { joinChildNames, managementPayload } from '@/utils/project-management'
|
||||
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
|
||||
import {
|
||||
Plus,
|
||||
@@ -331,6 +350,11 @@
|
||||
})
|
||||
search()
|
||||
}
|
||||
function onTeamChange(teamId: string) {
|
||||
if (readonly.value || isTeam.value) return
|
||||
const team = teams.value.find((item) => item.id === teamId)
|
||||
if (team?.leaderUserId) form.value.leaderUserId = team.leaderUserId
|
||||
}
|
||||
async function edit(row?: ProjectRecord, view = false) {
|
||||
readonly.value = view
|
||||
editingId.value = row?.id || ''
|
||||
@@ -339,6 +363,7 @@
|
||||
: {
|
||||
teamName: '',
|
||||
projectName: '',
|
||||
projectNo: '',
|
||||
leaderUserId: '',
|
||||
enabled: true,
|
||||
status: 'pending',
|
||||
|
||||
@@ -576,6 +576,10 @@
|
||||
ElMessage.warning(`${label}:请指定负责人`)
|
||||
return false
|
||||
}
|
||||
if (!(b.labItemIds?.length || 0) && !(b.thirdPartyItemIds?.length || 0)) {
|
||||
ElMessage.warning(`${label}:请至少选择一个检测项目`)
|
||||
return false
|
||||
}
|
||||
const labSet = new Set(b.labItemIds)
|
||||
if (labSet.size !== b.labItemIds.length) {
|
||||
ElMessage.warning(`${label}:实验室项目不能重复`)
|
||||
|
||||
@@ -290,7 +290,7 @@
|
||||
@change="onReceiveAcceptConclusionChange"
|
||||
>
|
||||
<el-radio value="js">接收</el-radio>
|
||||
<el-radio value="bj">拒收</el-radio>
|
||||
<el-radio value="bj">废弃</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="实验室入库编码" prop="labStorageCode">
|
||||
@@ -643,7 +643,7 @@
|
||||
@change="() => onBatchAcceptConclusionChange(entry)"
|
||||
>
|
||||
<el-radio value="js">接收</el-radio>
|
||||
<el-radio value="bj">拒收</el-radio>
|
||||
<el-radio value="bj">废弃</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
|
||||
@@ -482,6 +482,41 @@
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作日志(Issue #24) -->
|
||||
<div id="view-section-operation-log" class="section-card section-card--white">
|
||||
<div class="section-title section-title--banner"><h3>操作日志</h3></div>
|
||||
<div class="list-body list-wrap view-inner-table-wrap">
|
||||
<el-table
|
||||
v-loading="operationLogLoading"
|
||||
class="list-table inner-table"
|
||||
:data="operationLogRecords"
|
||||
stripe
|
||||
empty-text="暂无操作日志"
|
||||
>
|
||||
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
|
||||
<el-table-column label="动作" min-width="100" align="center" header-align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="operationActionTagType(String(row.action || ''))" size="small">
|
||||
{{ operationActionLabel(String(row.action || '')) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源端" min-width="90" align="center" header-align="center">
|
||||
<template #default="{ row }">{{ operationChannelLabel(String(row.clientChannel || '')) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作时间" min-width="170" align="center" header-align="center">
|
||||
<template #default="{ row }">{{ dash(row.operateTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作人" min-width="120" align="center" header-align="center">
|
||||
<template #default="{ row }">{{ dash(row.operatorUserName) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="变更摘要" min-width="260" align="left" header-align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ operationSummary(row) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -532,6 +567,7 @@
|
||||
import SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue'
|
||||
import {
|
||||
fetchDonationStatusChanges,
|
||||
fetchDonationOperationLogs,
|
||||
fetchResearchDonationAuditList,
|
||||
fetchResearchDonationDetail,
|
||||
type ResearchDonationRow
|
||||
@@ -593,16 +629,79 @@
|
||||
tabs.push({ key: 'audit', label: '审核记录' })
|
||||
}
|
||||
tabs.push({ key: 'attachment', label: '附件信息' })
|
||||
tabs.push({ key: 'status-change', label: '状态变更记录' })
|
||||
tabs.push({ key: 'status-change', label: '状态变更记录' }, { key: 'operation-log', label: '操作日志' })
|
||||
return tabs
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const statusChangeLoading = ref(false)
|
||||
const operationLogLoading = ref(false)
|
||||
const bundle = ref<Record<string, any> | null>(null)
|
||||
const auditRecords = ref<Array<Record<string, unknown>>>([])
|
||||
const statusChangeRecords = ref<Array<Record<string, unknown>>>([])
|
||||
const operationLogRecords = ref<Array<Record<string, unknown>>>([])
|
||||
|
||||
const OPERATION_ACTION_LABEL: Record<string, string> = {
|
||||
create: '创建',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
void: '作废',
|
||||
activate: '生效',
|
||||
expire: '过期',
|
||||
to_research: '转科研',
|
||||
to_storage: '转细胞存储',
|
||||
audit: '审核'
|
||||
}
|
||||
const OPERATION_ACTION_TAG: Record<
|
||||
string,
|
||||
'success' | 'warning' | 'danger' | 'primary' | 'info'
|
||||
> = {
|
||||
create: 'success',
|
||||
update: 'primary',
|
||||
delete: 'danger',
|
||||
void: 'danger',
|
||||
activate: 'success',
|
||||
expire: 'warning',
|
||||
to_research: 'primary',
|
||||
to_storage: 'primary',
|
||||
audit: 'info'
|
||||
}
|
||||
|
||||
function operationActionLabel(action: string) {
|
||||
return OPERATION_ACTION_LABEL[action] || action || '—'
|
||||
}
|
||||
function operationActionTagType(action: string) {
|
||||
return OPERATION_ACTION_TAG[action] || 'info'
|
||||
}
|
||||
function operationChannelLabel(channel: string) {
|
||||
if (channel === 'mini') return '小程序'
|
||||
if (channel === 'web') return 'Web'
|
||||
return channel || '—'
|
||||
}
|
||||
function operationSummary(row: Record<string, unknown>) {
|
||||
const before = (row.before || {}) as Record<string, any>
|
||||
const after = (row.after || {}) as Record<string, any>
|
||||
const beforeProto = (before.donation || before.protocol || {}) as Record<string, any>
|
||||
const afterProto = (after.donation || after.protocol || {}) as Record<string, any>
|
||||
const beforeParty = (before.party || before.partyCustomer || {}) as Record<string, any>
|
||||
const afterParty = (after.party || after.partyCustomer || {}) as Record<string, any>
|
||||
const parts: string[] = []
|
||||
const bs = String(beforeProto.donationStatus || beforeProto.protocolStatus || '')
|
||||
const as = String(afterProto.donationStatus || afterProto.protocolStatus || '')
|
||||
if (bs && as && bs !== as) parts.push(`状态 ${bs}→${as}`)
|
||||
else if (as) parts.push(`状态 ${as}`)
|
||||
for (const key of ['motherName', 'customerName', 'donorName', 'name']) {
|
||||
const b = String(beforeParty[key] || '').trim()
|
||||
const a = String(afterParty[key] || '').trim()
|
||||
if (b || a) {
|
||||
if (b && a && b !== a) parts.push(`姓名 ${b}→${a}`)
|
||||
else if (a && !b) parts.push(`姓名 ${a}`)
|
||||
break
|
||||
}
|
||||
}
|
||||
return parts.join(';') || String(row.remark || '').trim() || '见前后快照'
|
||||
}
|
||||
|
||||
const STATUS_CHANGE_TYPE_LABEL: Record<string, string> = {
|
||||
activate: '生效',
|
||||
@@ -959,6 +1058,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOperationLogRecords() {
|
||||
if (!props.donationId) {
|
||||
operationLogRecords.value = []
|
||||
return
|
||||
}
|
||||
operationLogLoading.value = true
|
||||
try {
|
||||
const res = await fetchDonationOperationLogs(props.donationId)
|
||||
operationLogRecords.value = res.list || []
|
||||
} catch {
|
||||
operationLogRecords.value = []
|
||||
} finally {
|
||||
operationLogLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAuditRecords() {
|
||||
if (!props.showAuditHistory || !props.donationId) {
|
||||
auditRecords.value = []
|
||||
@@ -985,12 +1100,17 @@
|
||||
bundle.value = null
|
||||
auditRecords.value = []
|
||||
statusChangeRecords.value = []
|
||||
operationLogRecords.value = []
|
||||
void loadVoidTypeDict()
|
||||
try {
|
||||
const data = await fetchResearchDonationDetail(props.donationId)
|
||||
bundle.value = data as Record<string, any>
|
||||
await loadLookups(data as Record<string, any>)
|
||||
await Promise.all([loadAuditRecords(), loadStatusChangeRecords()])
|
||||
await Promise.all([
|
||||
loadAuditRecords(),
|
||||
loadStatusChangeRecords(),
|
||||
loadOperationLogRecords()
|
||||
])
|
||||
if (props.highlightSampleCode) {
|
||||
nextTick(() => {
|
||||
document
|
||||
@@ -1017,10 +1137,12 @@
|
||||
bundle.value = null
|
||||
auditRecords.value = []
|
||||
statusChangeRecords.value = []
|
||||
operationLogRecords.value = []
|
||||
activeTab.value = 'basic'
|
||||
loading.value = false
|
||||
auditLoading.value = false
|
||||
statusChangeLoading.value = false
|
||||
operationLogLoading.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -93,8 +93,7 @@
|
||||
@selection-change="handleSelectionChange"
|
||||
@row-dblclick="handleView"
|
||||
>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
@@ -103,6 +102,8 @@
|
||||
header-align="center"
|
||||
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
|
||||
/>
|
||||
<el-table-column prop="projectName" label="所属项目" min-width="160" />
|
||||
<el-table-column prop="teamName" label="项目组" min-width="150" />
|
||||
<el-table-column
|
||||
prop="donationStatus"
|
||||
label="状态"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { buildDrillFilters, assignmentSnapshot } from '../src/utils/project-management'
|
||||
import {
|
||||
buildDrillFilters,
|
||||
assignmentSnapshot,
|
||||
joinChildNames
|
||||
} from '../src/utils/project-management'
|
||||
|
||||
test('drill preserves applied dates and selected project when a team is opened', () => {
|
||||
assert.deepEqual(
|
||||
@@ -65,6 +69,12 @@ test('management payload excludes readonly response fields and preserves child I
|
||||
)
|
||||
})
|
||||
|
||||
test('joinChildNames joins display names and skips blanks', () => {
|
||||
assert.equal(joinChildNames([{ name: '甲' }, { name: '' }, { name: '乙' }, {}]), '甲、乙')
|
||||
assert.equal(joinChildNames([]), '')
|
||||
assert.equal(joinChildNames(undefined), '')
|
||||
})
|
||||
|
||||
test('filter options use visible historical scope; create and reassignment remain strict', async () => {
|
||||
const { projectOptionsParams } = await import('../src/utils/project-management')
|
||||
assert.deepEqual(projectOptionsParams('cell_storage', true), {
|
||||
|
||||
Reference in new issue
Block a user