From 4a12d926313e3943ed89895647dc27be97216aad Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 5 Nov 2026 12:11:34 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(protocol):=20=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E5=8D=95=E6=93=8D=E4=BD=9C=E5=89=8D=E5=90=8E?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E4=BE=BF=E4=BA=8E=E8=B4=A3=E4=BB=BB=E8=BF=BD?= =?UTF-8?q?=E6=BA=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为细胞存储与科研捐赠协议增加 append-only 操作日志,覆盖 Web/小程序 create/update/delete/状态类动作,并在查看弹窗与移动详情提供操作日志入口(Issue #24)。 Co-authored-by: Cursor --- backend/app/api/collection_protocol.py | 70 ++++ backend/app/api/research_donation.py | 117 +++++- backend/app/crud/protocol_operation_log.py | 349 ++++++++++++++++++ backend/app/models/models.py | 27 ++ backend/app/services/market_protocol.py | 91 +++++ backend/tests/test_protocol_operation_log.py | 130 +++++++ .../2026-11-05-protocol-operation-log.sql | 29 ++ .../src/market/module-contracts.test.js | 5 +- mobile-oa-h5/src/market/modules/protocol.js | 72 +++- .../src/market/protocol-mobile-parity.test.js | 2 +- web-admin/src/api/collection-protocol.ts | 6 + web-admin/src/api/research-donation.ts | 6 + .../protocol/ProtocolViewDialog.vue | 143 +++++++ .../list/DonationViewDialog.vue | 58 ++- 14 files changed, 1099 insertions(+), 6 deletions(-) create mode 100644 backend/app/crud/protocol_operation_log.py create mode 100644 backend/tests/test_protocol_operation_log.py create mode 100644 docs/sql/migrations/2026-11-05-protocol-operation-log.sql diff --git a/backend/app/api/collection_protocol.py b/backend/app/api/collection_protocol.py index 32d4748d..4e8f04d0 100644 --- a/backend/app/api/collection_protocol.py +++ b/backend/app/api/collection_protocol.py @@ -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}) + + # --------------------------------------------------------------------------- # 收款确认 # --------------------------------------------------------------------------- diff --git a/backend/app/api/research_donation.py b/backend/app/api/research_donation.py index 016f79a5..a05e6dec 100644 --- a/backend/app/api/research_donation.py +++ b/backend/app/api/research_donation.py @@ -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. 科研捐赠 → 细胞存储 # --------------------------------------------------------------------------- diff --git a/backend/app/crud/protocol_operation_log.py b/backend/app/crud/protocol_operation_log.py new file mode 100644 index 00000000..4bc51ca8 --- /dev/null +++ b/backend/app/crud/protocol_operation_log.py @@ -0,0 +1,349 @@ +"""协议单操作过程日志(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: + """状态流转优先映射为更具体的动作;否则沿用 default_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 default_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] diff --git a/backend/app/models/models.py b/backend/app/models/models.py index b38898bb..7bce008d 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -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""" diff --git a/backend/app/services/market_protocol.py b/backend/app/services/market_protocol.py index 3b1503cf..1174f8d8 100644 --- a/backend/app/services/market_protocol.py +++ b/backend/app/services/market_protocol.py @@ -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, diff --git a/backend/tests/test_protocol_operation_log.py b/backend/tests/test_protocol_operation_log.py new file mode 100644 index 00000000..163c0cb5 --- /dev/null +++ b/backend/tests/test_protocol_operation_log.py @@ -0,0 +1,130 @@ +"""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_UPDATE, + resolve_action_from_status, + ) + + assert ( + resolve_action_from_status( + default_action=ACTION_UPDATE, + before_status="sxz", + after_status="sxz", + ) + == ACTION_UPDATE + ) diff --git a/docs/sql/migrations/2026-11-05-protocol-operation-log.sql b/docs/sql/migrations/2026-11-05-protocol-operation-log.sql new file mode 100644 index 00000000..e1b9bada --- /dev/null +++ b/docs/sql/migrations/2026-11-05-protocol-operation-log.sql @@ -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; diff --git a/mobile-oa-h5/src/market/module-contracts.test.js b/mobile-oa-h5/src/market/module-contracts.test.js index e253e58f..c63097a3 100644 --- a/mobile-oa-h5/src/market/module-contracts.test.js +++ b/mobile-oa-h5/src/market/module-contracts.test.js @@ -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', () => { diff --git a/mobile-oa-h5/src/market/modules/protocol.js b/mobile-oa-h5/src/market/modules/protocol.js index a0ebbaf7..94956c46 100644 --- a/mobile-oa-h5/src/market/modules/protocol.js +++ b/mobile-oa-h5/src/market/modules/protocol.js @@ -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) } diff --git a/mobile-oa-h5/src/market/protocol-mobile-parity.test.js b/mobile-oa-h5/src/market/protocol-mobile-parity.test.js index 3dd15b56..fdbb5e9e 100644 --- a/mobile-oa-h5/src/market/protocol-mobile-parity.test.js +++ b/mobile-oa-h5/src/market/protocol-mobile-parity.test.js @@ -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), [ diff --git a/web-admin/src/api/collection-protocol.ts b/web-admin/src/api/collection-protocol.ts index 3a870e51..5e673de8 100644 --- a/web-admin/src/api/collection-protocol.ts +++ b/web-admin/src/api/collection-protocol.ts @@ -59,6 +59,12 @@ export function fetchCollectionProtocolStatusChanges(protocolId: string) { }) } +export function fetchCollectionProtocolOperationLogs(protocolId: string) { + return request.get<{ list: Array> }>({ + url: `/web/admin/collection-protocols/${protocolId}/operation-logs` + }) +} + export function fetchCollectionProtocolAuditList(params?: { page?: number pageSize?: number diff --git a/web-admin/src/api/research-donation.ts b/web-admin/src/api/research-donation.ts index 043736d6..c3244a2d 100644 --- a/web-admin/src/api/research-donation.ts +++ b/web-admin/src/api/research-donation.ts @@ -118,6 +118,12 @@ export function fetchDonationStatusChanges(donationId: string) { }) } +export function fetchDonationOperationLogs(donationId: string) { + return request.get<{ list: Array> }>({ + url: `/web/admin/research-donations/${donationId}/operation-logs` + }) +} + // 状态变更 export function fetchDonationStatusChange( donationId: string, diff --git a/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue b/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue index de11f6de..acfea86f 100644 --- a/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue +++ b/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue @@ -1174,6 +1174,41 @@ + + +
+

操作日志

+
+ + + + + + + + + + + + + + + + + + +
+
@@ -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 | null>(null) const auditRecords = ref>>([]) const statusChangeRecords = ref>>([]) + const operationLogLoading = ref(false) + const operationLogRecords = ref>>([]) + + const OPERATION_ACTION_LABEL: Record = { + create: '创建', + update: '修改', + delete: '删除', + void: '作废', + activate: '生效', + expire: '过期', + to_research: '转科研', + to_storage: '转细胞存储', + audit: '审核' + } + const OPERATION_ACTION_TAG: Record = { + 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) { + const before = (row.before || {}) as Record + const after = (row.after || {}) as Record + const beforeProto = (before.protocol || before.donation || {}) as Record + const afterProto = (after.protocol || after.donation || {}) as Record + const beforeParty = (before.partyCustomer || before.party || {}) as Record + const afterParty = (after.partyCustomer || after.party || {}) as Record + 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 = { activate: '生效', @@ -2075,6 +2196,7 @@ async function loadStatusChangeRecords() { if (!props.protocolId) { statusChangeRecords.value = [] + operationLogRecords.value = [] return } statusChangeLoading.value = true @@ -2083,11 +2205,28 @@ statusChangeRecords.value = res.list || [] } catch { statusChangeRecords.value = [] + operationLogRecords.value = [] } finally { statusChangeLoading.value = false } } + 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 +2254,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 | null = null try { @@ -2136,6 +2277,7 @@ void finishLookups(data, baseLookupsPromise) void auditPromise void statusChangePromise + void operationLogPromise if (props.highlightSampleCode) { nextTick(() => { document @@ -2159,6 +2301,7 @@ bundle.value = null auditRecords.value = [] statusChangeRecords.value = [] + operationLogRecords.value = [] activeTab.value = 'basic' detailLoading.value = false lookupLoading.value = false diff --git a/web-admin/src/views/research-donation/list/DonationViewDialog.vue b/web-admin/src/views/research-donation/list/DonationViewDialog.vue index 850365a2..79f5d827 100644 --- a/web-admin/src/views/research-donation/list/DonationViewDialog.vue +++ b/web-admin/src/views/research-donation/list/DonationViewDialog.vue @@ -482,6 +482,41 @@ + + +
+

操作日志

+
+ + + + + + + + + + + + + + + + + + +
+
@@ -532,6 +567,7 @@ import SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue' import { fetchDonationStatusChanges, + fetchDonationOperationLogs, fetchResearchDonationAuditList, fetchResearchDonationDetail, type ResearchDonationRow @@ -593,7 +629,7 @@ 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 }) @@ -946,6 +982,7 @@ async function loadStatusChangeRecords() { if (!props.donationId) { statusChangeRecords.value = [] + operationLogRecords.value = [] return } statusChangeLoading.value = true @@ -954,11 +991,28 @@ statusChangeRecords.value = res.list || [] } catch { statusChangeRecords.value = [] + operationLogRecords.value = [] } finally { statusChangeLoading.value = false } } + 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,6 +1039,7 @@ bundle.value = null auditRecords.value = [] statusChangeRecords.value = [] + operationLogRecords.value = [] void loadVoidTypeDict() try { const data = await fetchResearchDonationDetail(props.donationId) @@ -1017,6 +1072,7 @@ bundle.value = null auditRecords.value = [] statusChangeRecords.value = [] + operationLogRecords.value = [] activeTab.value = 'basic' loading.value = false auditLoading.value = false From ac9db561a42ec2301f1ffdb6419f4054d32aa557 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Thu, 5 Nov 2026 12:15:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(web-admin):=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=8D=90=E8=B5=A0=E6=9F=A5=E7=9C=8B=E6=93=8D=E4=BD=9C=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=8E=A5=E7=BA=BF=E5=B9=B6=E4=BF=9D=E7=95=99=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=8A=A8=E4=BD=9C=E8=AF=AD=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 DonationViewDialog 缺失的操作日志状态与加载调用,清理误插入的重置语句,并避免显式 audit 动作被状态映射覆盖。 Co-authored-by: Cursor --- backend/app/crud/protocol_operation_log.py | 7 +- backend/tests/test_protocol_operation_log.py | 18 +++++ .../protocol/ProtocolViewDialog.vue | 2 - .../list/DonationViewDialog.vue | 72 ++++++++++++++++++- 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/backend/app/crud/protocol_operation_log.py b/backend/app/crud/protocol_operation_log.py index 4bc51ca8..bb18edf4 100644 --- a/backend/app/crud/protocol_operation_log.py +++ b/backend/app/crud/protocol_operation_log.py @@ -61,12 +61,15 @@ def resolve_action_from_status( before_status: Optional[str] = None, after_status: Optional[str] = None, ) -> str: - """状态流转优先映射为更具体的动作;否则沿用 default_action。""" + """仅对 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 default_action + return action def _operator_name(db: Session, user_id: Optional[str]) -> str: diff --git a/backend/tests/test_protocol_operation_log.py b/backend/tests/test_protocol_operation_log.py index 163c0cb5..9f3e214a 100644 --- a/backend/tests/test_protocol_operation_log.py +++ b/backend/tests/test_protocol_operation_log.py @@ -116,7 +116,9 @@ def test_record_cell_update_appends_before_after_and_maps_void_action(): 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, ) @@ -128,3 +130,19 @@ def test_resolve_action_from_status_keeps_default_when_unchanged(): ) == 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 + ) diff --git a/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue b/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue index acfea86f..afd97889 100644 --- a/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue +++ b/web-admin/src/views/cell-storage/protocol/ProtocolViewDialog.vue @@ -2196,7 +2196,6 @@ async function loadStatusChangeRecords() { if (!props.protocolId) { statusChangeRecords.value = [] - operationLogRecords.value = [] return } statusChangeLoading.value = true @@ -2205,7 +2204,6 @@ statusChangeRecords.value = res.list || [] } catch { statusChangeRecords.value = [] - operationLogRecords.value = [] } finally { statusChangeLoading.value = false } diff --git a/web-admin/src/views/research-donation/list/DonationViewDialog.vue b/web-admin/src/views/research-donation/list/DonationViewDialog.vue index 79f5d827..c1ba55b6 100644 --- a/web-admin/src/views/research-donation/list/DonationViewDialog.vue +++ b/web-admin/src/views/research-donation/list/DonationViewDialog.vue @@ -636,9 +636,72 @@ const loading = ref(false) const auditLoading = ref(false) const statusChangeLoading = ref(false) + const operationLogLoading = ref(false) const bundle = ref | null>(null) const auditRecords = ref>>([]) const statusChangeRecords = ref>>([]) + const operationLogRecords = ref>>([]) + + const OPERATION_ACTION_LABEL: Record = { + 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) { + const before = (row.before || {}) as Record + const after = (row.after || {}) as Record + const beforeProto = (before.donation || before.protocol || {}) as Record + const afterProto = (after.donation || after.protocol || {}) as Record + const beforeParty = (before.party || before.partyCustomer || {}) as Record + const afterParty = (after.party || after.partyCustomer || {}) as Record + 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 = { activate: '生效', @@ -982,7 +1045,6 @@ async function loadStatusChangeRecords() { if (!props.donationId) { statusChangeRecords.value = [] - operationLogRecords.value = [] return } statusChangeLoading.value = true @@ -991,7 +1053,6 @@ statusChangeRecords.value = res.list || [] } catch { statusChangeRecords.value = [] - operationLogRecords.value = [] } finally { statusChangeLoading.value = false } @@ -1045,7 +1106,11 @@ const data = await fetchResearchDonationDetail(props.donationId) bundle.value = data as Record await loadLookups(data as Record) - await Promise.all([loadAuditRecords(), loadStatusChangeRecords()]) + await Promise.all([ + loadAuditRecords(), + loadStatusChangeRecords(), + loadOperationLogRecords() + ]) if (props.highlightSampleCode) { nextTick(() => { document @@ -1077,6 +1142,7 @@ loading.value = false auditLoading.value = false statusChangeLoading.value = false + operationLogLoading.value = false } watch(