feat(protocol): 协议单操作日志埋点与查看入口 #41

Merged
cykj merged 2 commits from codex/issue-24-protocol-op-log-ce880af00b97 into V2.0 2026-11-05 04:16:36 +00:00
14 changed files with 1185 additions and 7 deletions

No files matched your search

+70
View File
@@ -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})
# ---------------------------------------------------------------------------
# 收款确认
# ---------------------------------------------------------------------------
+116 -1
View File
@@ -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. 科研捐赠 → 细胞存储
# ---------------------------------------------------------------------------
+352
View File
@@ -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]
+27
View File
@@ -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"""
+91
View File
@@ -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,
@@ -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,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;
@@ -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', () => {
+71 -1
View File
@@ -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), [
+6
View File
@@ -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
+6
View File
@@ -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,
@@ -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
@@ -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(