Compare commits

...
Author SHA1 Message Date
zlwuandCursor a4defb2c94 fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选
将项目筛选并入搜索框下方的 van-dropdown 行,与协议状态样式一致;科研捐赠 Tab 同步展示两项筛选,并修复捐赠列表按 protocolStatus 过滤 donation_status。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:13:15 +08:00
cykj 8717702b58 fix(personal-dev): 结束 Gitea 短页评论翻页 (#57)
Merge pull request #58 from cursor/issue-57-gitea-pagination

#57
2026-11-05 09:00:54 +00:00
zlwuandCursor d30dbad788 fix(personal-dev): 结束 Gitea 短页评论翻页 (#57)
避免评论列表短于 PAGE_SIZE 时因忽略 page 参数反复同页而触发分页上限。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:00:16 +08:00
cykj e92112e9fd fix(web-admin): 简化项目归属维护调整弹窗 (#52)
Merge pull request #56 from cursor/issue-52-assignment-dialog

关联 #52
2026-11-05 08:56:30 +00:00
zlwuandCursor d6dc768efb fix(web-admin): 加宽归属审计记录弹窗 (#52)
历史归属维护页审计表格列较多,800px 偏挤,调整为 1100px。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:54:22 +08:00
zlwuandCursor 96518c08fb fix(web-admin): 简化项目归属维护调整弹窗 (#52)
展示原项目组/项目,录入新项目组/项目与原因;确认时静默预览并仍级联关联单据。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:54:22 +08:00
cykj 54a34b24ba fix(web-admin): 协议单扁平行列表展示所属项目与项目组 (#55)
fix(web-admin): 协议单扁平行列表展示所属项目与项目组 (#55)

Refs: #54
2026-11-05 08:47:38 +00:00
zlwuandCursor 5dc695a138 fix(web-admin): 协议单扁平行列表展示所属项目与项目组
采集协议单管理(细胞存储/科研捐赠)展平样本行时未拷贝项目归属字段,导致表格列空白;补齐展平字段并增加回归单测。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 16:45:33 +08:00
cykj 406ed0ee6d feat(project): 项目组变更写入版本历史快照 (#50)
feat(project): 项目组变更写入版本历史快照 (#50)

Merged from PR #51 / issue #50.
2026-11-05 07:09:37 +00:00
zlwuandCursor 6cba55f5c8 feat(project): 项目组变更写入版本历史快照
每次创建/编辑/软删除项目组时追加 biz_project_team_history,
用完整 serialize 快照保留负责人、成员、地区与医院等信息,便于追溯。

Refs: #50
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 15:05:44 +08:00
cykj 352a4b7402 Merge pull request 'feat(project): 项目编号支持新增编辑自由填写' (#49) from codex/issue-45-project-leader-autofill-a9912a48015b into V2.0 2026-11-05 06:47:23 +00:00
zlwuandCursor e895daf8fc feat(project): 项目编号支持新增编辑自由填写
项目管理弹窗增加项目编号输入;后端保存改为接收客户端编号并校验租户内唯一,不再自动生成。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:41:41 +08:00
cykj 3bdbefb56e Merge pull request 'feat(project): 选定所属项目组后自动带出负责人' (#46) from codex/issue-45-project-leader-autofill-a9912a48015b into V2.0 2026-11-05 06:39:14 +00:00
cykj ecfdf65d4f feat(web-admin): 项目组列表展示成员与负责医院 (#47)
feat(web-admin): 项目组列表展示成员与负责医院

合入 #44 / PR #47
2026-11-05 06:39:08 +00:00
zlwuandCursor 1fd1100b8f feat(web-admin): 项目组列表展示成员与负责医院
在负责人列后追加成员、负责医院,复用列表接口已有子行名称并用顿号拼接。
关联 #44

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:33:02 +08:00
zlwuandCursor 2b2ddc8dd5 feat(project): 选定所属项目组后自动带出负责人
项目管理新增/编辑弹窗选择或更换项目组时,用该组负责人填充表单负责人字段,仍可手工修改。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 14:32:13 +08:00
cykj 9af53e81f4 Merge pull request 'fix(qc): 调整检测清空重配不再误标工单已完成' (#42) from codex/issue-32-qc-adjust-status-84b58d9e903e into V2.0
fix(qc): 调整检测清空重配不再误标工单已完成 (#42)

关联 #32
2026-11-05 04:28:16 +00:00
zlwuandCursor 9e3a67a8e4 fix(qc): 调整检测清空重配不再误标工单已完成
try_complete 在无活跃任务时也会把已配置打成已完成;清空全部后再添加会踩中。
现要求至少有一条活跃任务且全部完成后才自动完成,并强制全量重建时清理旧批次,前后端均校验批次须含检测项目。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 12:18:30 +08:00
cykj ebaab7d210 feat(protocol): 协议单操作日志埋点与查看入口 (#41)
feat(protocol): 协议单操作日志埋点与查看入口

Closes related work for issue #24 (manual close after sync).
2026-11-05 04:16:36 +00:00
zlwuandCursor ac9db561a4 fix(web-admin): 补齐捐赠查看操作日志接线并保留审核动作语义
修复 DonationViewDialog 缺失的操作日志状态与加载调用,清理误插入的重置语句,并避免显式 audit 动作被状态映射覆盖。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 12:15:10 +08:00
zlwuandCursor 4a12d92631 feat(protocol): 记录协议单操作前后快照便于责任追溯
为细胞存储与科研捐赠协议增加 append-only 操作日志,覆盖 Web/小程序 create/update/delete/状态类动作,并在查看弹窗与移动详情提供操作日志入口(Issue #24)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 12:11:34 +08:00
cykj 1fb25f4fc2 Merge pull request 'fix(backend): 协议作废与样本接收废弃双向联动' (#40) from codex/issue-31-discard-sync-d7063ae6ff97 into V2.0
fix(backend): 协议作废与样本接收废弃双向联动

Merge PR #40 for issue #31.
2026-11-05 03:44:15 +00:00
zlwuandCursor e2e9059c17 fix(backend): sync protocol void with sample reception discard
When a cell-storage or research-donation protocol is voided, mark all
related sample receptions as discarded; when every sample is discarded,
auto-void the protocol as 实验室废弃 (sysfq). Covers issue #31.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:37:58 +08:00
cykj 65aaaae885 Merge pull request 'fix(web-admin): 样本接收是否接收选项拒收改为废弃' (#39) from codex/issue-27-reject-to-discard-f4470ba481b0 into V2.0
Merge pull request 'fix(web-admin): 样本接收是否接收选项拒收改为废弃' (#39) from codex/issue-27-reject-to-discard-f4470ba481b0 into V2.0
2026-11-05 03:14:35 +00:00
zlwuandCursor 71051461a2 fix(web-admin): 样本接收「是否接收」选项拒收改为废弃
质检样本接收登记弹窗与结论标签文案对齐工单 #27,取值 bj 不变。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:08:18 +08:00
zlwuandCursor 25c12ad4ff chore(agents): point Figma config to web and mobile design systems
Replace the demo Figma reference with the confirmed web/mobile authority files and status notes so agent UI work follows the current design systems.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 11:00:53 +08:00
zlwu 5b440df17e Merge origin/V2.0: include list column order fix from #37 2026-11-05 10:55:34 +08:00
cykj 67846493fa Merge pull request 'fix(web-admin): 列表所属项目/项目组列移到序号之后' (#37) from codex/issue-34-list-col-order-20662a540e4e into V2.0
fix(web-admin): 列表所属项目/项目组列移到序号之后 (#37)

Refs: #34
2026-11-05 02:55:07 +00:00
zlwu 81154f4c94 Merge remote-tracking branch 'origin/V2.0' into V2.0 2026-11-05 10:50:41 +08:00
cykj 125398a3f5 Merge pull request 'fix(web): 加宽项目组新增编辑弹窗并调整字段布局' (#36) from codex/issue-33-team-dialog into V2.0 2026-11-05 02:49:53 +00:00
zlwu 2e21884d65 Merge remote-tracking branch 'origin/V2.0' into V2.0 2026-11-05 10:40:34 +08:00
zlwu f1b4a5ce26 fix(web-admin): place project columns after index in lists
Move 所属项目/项目组 after selection and serial-number columns so multi-select and index stay leftmost across affected business lists.
2026-11-05 10:26:59 +08:00
zlwuandCursor 01fef051da chore(cursor): enable the Figma plugin
The screen baseline is drawn in the shared Figma file, so the workspace plugin needs to stay enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 06:45:27 +08:00
58 changed files with 3048 additions and 149 deletions

No files matched your search

@@ -240,6 +240,10 @@ class Tracker:
if not value:
return records
records.extend(value)
# Gitea comment pages may ignore page and keep returning the same short
# list; a short page is the conventional last-page signal.
if len(value) < PAGE_SIZE:
return records
raise InstallError('issue tracker pagination limit exceeded')
def issues(self):
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}
+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})
# ---------------------------------------------------------------------------
# 收款确认
# ---------------------------------------------------------------------------
+1
View File
@@ -45,6 +45,7 @@ class TeamBody(Body):
class ProjectBody(Body):
projectName: str = Field(min_length=1, max_length=200)
projectNo: str = Field(min_length=1, max_length=64)
teamId: str
leaderUserId: str
bizTypes: List[str]
+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. 科研捐赠 → 细胞存储
# ---------------------------------------------------------------------------
@@ -53,6 +53,7 @@ _DONATION_STATUS_LABELS = {
_VOID_TYPE_FALLBACK_LABELS = {
"khhc": "客户毁存",
"jcbhg": "检测不合格",
"sysfq": "实验室废弃",
}
# 非法来源状态视为终态,不允许再转出
+14
View File
@@ -2642,6 +2642,7 @@ def update_collection_protocol(
source_presign_order_id: Optional[str] = None,
remark: Optional[str] = None,
commit: bool = True,
sync_reception_discard: bool = True,
) -> BizCollectionProtocol:
obj = get_collection_protocol(
db, protocol_id, tenant_no, scope_collector_user_id=scope_collector_user_id
@@ -2650,6 +2651,7 @@ def update_collection_protocol(
raise ValueError("协议单不存在或无权操作")
from_status = (obj.protocol_status or "").strip()
status_log_payload: Optional[Dict[str, Any]] = None
voided_now = False
if protocol_no is not None:
pn = protocol_no.strip()
if pn != (obj.protocol_no or ""):
@@ -2660,6 +2662,8 @@ def update_collection_protocol(
next_status = protocol_status.strip()
assert_protocol_status_transition(obj.protocol_status or "", next_status, kind="protocol")
obj.protocol_status = next_status
if next_status == "yzf" and from_status != "yzf":
voided_now = True
if next_status == "yzf" and void_time is not None:
if void_type is None or not str(void_type).strip():
raise ValueError("请选择作废类型")
@@ -2749,6 +2753,16 @@ def update_collection_protocol(
tenant_no=tenant_no,
user_id=user_id,
)
if voided_now and sync_reception_discard:
from app.services.protocol_reception_discard_sync import after_protocol_voided
after_protocol_voided(
db,
source="cell_storage",
protocol_id=obj.id,
tenant_no=tenant_no,
user_id=user_id,
)
if commit:
db.commit()
db.refresh(obj)
+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]
+11 -4
View File
@@ -56,12 +56,14 @@ def _validate_batch_payload(batch: Dict[str, Any], index: int) -> None:
assignees = batch.get("assigneeUserIds") or batch.get("assignee_user_ids") or []
if not batch_name:
raise ValueError(f"{label}:请填写批次名称")
if not assignees:
raise ValueError(f"{label}:请指定负责人")
if not lab_ids and not third_ids:
raise ValueError(f"{label}:请至少选择一个检测项目")
if len(lab_ids) != len(set(lab_ids)):
raise ValueError(f"{label}:实验室项目不能重复")
if len(third_ids) != len(set(third_ids)):
raise ValueError(f"{label}:第三方项目不能重复")
if not assignees:
raise ValueError(f"{label}:请指定负责人")
def _order_source_for_wo(wo: BizQcWorkOrder) -> str:
@@ -834,8 +836,13 @@ def configure_work_order_batches(
}
keep_batch_ids = {((b.get("id") or "").strip()) for b in batches if (b.get("id") or "").strip()}
# 提交空 batches = 显式清空:强制软删批次及关联任务(含进行中/已完成)
# 提交空 batches = 显式清空;提交批次与库中无交集 = 前端「清空全部后再添加」全量重建
# 两种情况都强制软删旧批次及关联任务(含进行中/已完成)
force_clear_all = not batches
full_replace = bool(batches) and bool(existing_batches) and keep_batch_ids.isdisjoint(
existing_batches.keys()
)
force_remove = is_voided or force_clear_all or full_replace
for bid, batch_row in existing_batches.items():
if bid not in keep_batch_ids:
_soft_delete_batch(
@@ -843,7 +850,7 @@ def configure_work_order_batches(
batch_row,
tenant_no=tenant_no,
user_id=user_id,
force=is_voided or force_clear_all,
force=force_remove,
)
fee_total = Decimal("0")
+56 -27
View File
@@ -3050,6 +3050,17 @@ def configure_work_order(
def try_complete_work_order(db: Session, work_order_id: str, tenant_no: str) -> None:
from app.services.qc_work_order_complete import can_auto_complete_work_order
active = (
db.query(BizQcTask)
.filter(
BizQcTask.work_order_id == work_order_id,
BizQcTask.tenant_no == tenant_no,
BizQcTask.del_flag == 0,
)
.count()
)
pending = (
db.query(BizQcTask)
.filter(
@@ -3060,28 +3071,31 @@ def try_complete_work_order(db: Session, work_order_id: str, tenant_no: str) ->
)
.count()
)
if pending:
return
wo = get_work_order(db, work_order_id, tenant_no=tenant_no)
if wo and wo.work_order_status == "ypz":
wo.work_order_status = "ywc"
task_conclusions = [
c
for (c,) in db.query(BizQcTask.conclusion)
.filter(
BizQcTask.work_order_id == work_order_id,
BizQcTask.tenant_no == tenant_no,
BizQcTask.del_flag == 0,
)
.all()
]
derived = build_qc_conclusion_fields(task_conclusions)["qcConclusion"]
wo.conclusion = derived or None
wo.update_time = datetime.now()
db.add(wo)
from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
if not wo or not can_auto_complete_work_order(
status=wo.work_order_status or "",
active_task_count=active,
pending_task_count=pending,
):
return
wo.work_order_status = "ywc"
task_conclusions = [
c
for (c,) in db.query(BizQcTask.conclusion)
.filter(
BizQcTask.work_order_id == work_order_id,
BizQcTask.tenant_no == tenant_no,
BizQcTask.del_flag == 0,
)
.all()
]
derived = build_qc_conclusion_fields(task_conclusions)["qcConclusion"]
wo.conclusion = derived or None
wo.update_time = datetime.now()
db.add(wo)
from app.crud.pilot_outbound_status_sync import sync_schedule_from_qc_work_order
sync_schedule_from_qc_work_order(db, wo)
sync_schedule_from_qc_work_order(db, wo)
db.commit()
@@ -3418,6 +3432,18 @@ def configure_temp_qc_work_order(
def try_complete_temp_qc_work_order(
db: Session, work_order_id: str, tenant_no: str
) -> None:
from app.services.qc_work_order_complete import can_auto_complete_work_order
active = (
db.query(BizQcTask)
.filter(
BizQcTask.work_order_id == work_order_id,
BizQcTask.order_source == "temporary",
BizQcTask.tenant_no == tenant_no,
BizQcTask.del_flag == 0,
)
.count()
)
pending = (
db.query(BizQcTask)
.filter(
@@ -3429,14 +3455,17 @@ def try_complete_temp_qc_work_order(
)
.count()
)
if pending:
return
wo = get_temp_qc_work_order(db, work_order_id, tenant_no)
if wo and wo.work_order_status == "ypz":
wo.work_order_status = "ywc"
wo.update_time = datetime.now()
db.add(wo)
db.commit()
if not wo or not can_auto_complete_work_order(
status=wo.work_order_status or "",
active_task_count=active,
pending_task_count=pending,
):
return
wo.work_order_status = "ywc"
wo.update_time = datetime.now()
db.add(wo)
db.commit()
def delete_temp_qc_work_order(
+10
View File
@@ -1686,6 +1686,16 @@ def change_status(
)
db.add(obj)
if ns == "yzf" and (current or "").strip() != "yzf":
from app.services.protocol_reception_discard_sync import after_protocol_voided
after_protocol_voided(
db,
source="research_donation",
protocol_id=obj.id,
tenant_no=tenant_no,
user_id=user_id,
)
db.commit()
db.refresh(obj)
return obj
+12
View File
@@ -1033,6 +1033,12 @@ def create_sample_reception(
user_id=user_id,
create_dept_id=create_dept_id or "",
)
if conclusion == "bj":
from app.services.protocol_reception_discard_sync import after_sample_reception_saved
after_sample_reception_saved(
db, reception=obj, tenant_no=tenant_no, user_id=user_id
)
db.commit()
db.refresh(obj)
return obj
@@ -1151,6 +1157,12 @@ def update_sample_reception(
)
check_result_svc.sync_legacy_columns_from_results(db, rec, saved_rows)
db.add(rec)
if conclusion == "bj":
from app.services.protocol_reception_discard_sync import after_sample_reception_saved
after_sample_reception_saved(
db, reception=rec, tenant_no=tenant_no, user_id=user_id
)
db.commit()
db.refresh(rec)
return rec
+2 -2
View File
@@ -75,12 +75,12 @@ ApiRequestLog = SysApiRequestLog
from app.models.project_management import (
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
ProjectTeamHospital, ProjectAssignmentAudit,
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
)
__all__ = [
"Project", "ProjectTeam", "ProjectTeamMember", "ProjectTeamRegion",
"ProjectTeamHospital", "ProjectAssignmentAudit",
"ProjectTeamHospital", "ProjectAssignmentAudit", "ProjectTeamHistory",
"BaseModel",
"SoftDeleteModel",
"SysUser",
+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"""
+14 -1
View File
@@ -1,6 +1,6 @@
"""租户项目及归属审计;区域医院关联只记录组织资料。"""
from datetime import datetime
from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, UniqueConstraint
from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, Text, UniqueConstraint
from app.models.base import Base
@@ -65,3 +65,16 @@ class ProjectAssignmentAudit(ProjectFields, Base):
new_project_name = Column(String(200))
reason = Column(String(512), nullable=False)
operator_name = Column(String(100))
class ProjectTeamHistory(ProjectFields, Base):
"""项目组版本历史;每次成功写入记完整快照。"""
__tablename__ = 'biz_project_team_history'
__table_args__ = (UniqueConstraint('tenant_no', 'team_id', 'version_no', name='uk_team_version'),)
team_id = Column(String(64), nullable=False, index=True)
version_no = Column(Integer, nullable=False)
action = Column(String(16), nullable=False)
snapshot_json = Column(Text, nullable=False)
operator_user_id = Column(String(64))
operator_user_name = Column(String(100))
operate_time = Column(DateTime, nullable=False, default=datetime.now)
+101 -5
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
@@ -983,11 +984,14 @@ def list_protocols(
for row in cell_rows
)
if source in (None, SOURCE_DONATION):
if status or collected_from or collected_until:
donation_enabled = False
else:
donation_enabled = True
if not donation_enabled:
# 混合列表(source=None)下状态/采集时间只覆盖细胞存储,捐赠侧跳过。
# 科研捐赠单源列表支持 protocolStatus→donation_status;采集时间筛仍不支持捐赠。
skip_donation = False
if source is None and (status or collected_from or collected_until):
skip_donation = True
elif source == SOURCE_DONATION and (collected_from or collected_until):
skip_donation = True
if skip_donation:
query = None
else:
query = apply_donation_scope(
@@ -1000,6 +1004,8 @@ def list_protocols(
if query is None:
donation_rows = []
else:
if status and source == SOURCE_DONATION:
query = query.filter(BizResearchDonation.donation_status == status)
if created_from:
query = query.filter(BizResearchDonation.create_time >= created_from)
if created_until:
@@ -1531,6 +1537,12 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic
collection_protocol_id=row.id,
tenant_no=tenant_no,
),
'operationLogs': op_log.list_protocol_operation_logs(
db,
biz_type=op_log.BIZ_CELL,
biz_id=row.id,
tenant_no=tenant_no,
),
'attachments': {
'protocolAttachments': row.protocol_attachments or '',
'consentFormUrls': row.consent_form_urls or '',
@@ -1571,6 +1583,12 @@ def _donation_detail(
research_donation_id=row.id,
tenant_no=tenant_no,
),
'operationLogs': op_log.list_protocol_operation_logs(
db,
biz_type=op_log.BIZ_DONATION,
biz_id=row.id,
tenant_no=tenant_no,
),
'attachments': {
'consentFormUrls': basic.get('consentFormUrls') or '',
'attachmentUrls': basic.get('attachmentUrls') or '',
@@ -1841,17 +1859,45 @@ def delete_protocol(
_lock_cell_protocol_access(
db, protocol_id, ctx, user_info=user_info
)
before_snap = op_log.snapshot_cell_protocol(
db, protocol_id=protocol_id, tenant_no=ctx.tenant_no
)
cell_crud.soft_delete_collection_protocol(
db, protocol_id, ctx.tenant_no, user_id=ctx.user_id
)
op_log.record_cell_operation(
db,
protocol_id=protocol_id,
action=op_log.ACTION_DELETE,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
client_channel=op_log.CHANNEL_MINI,
before=before_snap,
after=None,
)
db.commit()
return
if source == SOURCE_DONATION:
_lock_donation_access(
db, protocol_id, ctx, user_info=user_info
)
before_snap = op_log.snapshot_donation(
db, donation_id=protocol_id, tenant_no=ctx.tenant_no
)
donation_crud.delete_donation(
db, protocol_id, ctx.tenant_no, user_id=ctx.user_id
)
op_log.record_donation_operation(
db,
donation_id=protocol_id,
action=op_log.ACTION_DELETE,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
client_channel=op_log.CHANNEL_MINI,
before=before_snap,
after=None,
)
db.commit()
return
raise ValueError('不支持的协议来源')
@@ -1958,6 +2004,17 @@ def create_protocol(
assert_cell_protocol_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
op_log.record_cell_operation(
db,
protocol_id=row.id,
action=op_log.ACTION_CREATE,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
client_channel=op_log.CHANNEL_MINI,
before=None,
create_dept_id=dept_id,
)
db.commit()
schedule_sample_collect_notify(
tenant_no=ctx.tenant_no,
protocol_id=row.id,
@@ -2013,6 +2070,17 @@ def create_protocol(
assert_donation_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
op_log.record_donation_operation(
db,
donation_id=row.id,
action=op_log.ACTION_CREATE,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
client_channel=op_log.CHANNEL_MINI,
before=None,
create_dept_id=dept_id,
)
db.commit()
schedule_sample_collect_notify(
tenant_no=ctx.tenant_no,
protocol_id=row.id,
@@ -2050,6 +2118,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 +2249,17 @@ def update_protocol(
)
db.commit()
db.refresh(row)
op_log.record_cell_operation(
db,
protocol_id=row.id,
action=op_log.ACTION_UPDATE,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
client_channel=op_log.CHANNEL_MINI,
before=before_snap,
create_dept_id=dept_id,
)
db.commit()
schedule_sample_collect_notify(
tenant_no=ctx.tenant_no,
protocol_id=row.id,
@@ -2195,6 +2277,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 +2363,17 @@ def update_protocol(
row = assert_donation_access(db, row.id, ctx, user_info=user_info)
db.commit()
db.refresh(row)
op_log.record_donation_operation(
db,
donation_id=row.id,
action=op_log.ACTION_UPDATE,
tenant_no=ctx.tenant_no,
user_id=ctx.user_id,
client_channel=op_log.CHANNEL_MINI,
before=before_snap,
create_dept_id=dept_id,
)
db.commit()
schedule_sample_collect_notify(
tenant_no=ctx.tenant_no,
protocol_id=row.id,
+54 -3
View File
@@ -1,4 +1,5 @@
"""项目资料、权限内统计和关联归属事务。"""
import json
from datetime import date, datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import or_, and_, func
@@ -6,7 +7,7 @@ from app.core.data_scope import apply_data_scope
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows
from app.models.project_management import (Project, ProjectTeam, ProjectTeamMember,
ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit)
ProjectTeamRegion, ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory)
from app.models.models import (SysUser, BizCollectionRegion, BizCollectionHospital,
BizPresignOrder, BizCollectionProtocol, BizPilotApplication)
from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert
@@ -250,6 +251,44 @@ CHILDREN = (
)
def _next_team_history_version(db, tenant, team_id):
current = (db.query(ProjectTeamHistory.version_no)
.filter(ProjectTeamHistory.tenant_no == tenant,
ProjectTeamHistory.team_id == team_id,
ProjectTeamHistory.del_flag == 0)
.order_by(ProjectTeamHistory.version_no.desc())
.with_for_update()
.first())
return (current[0] if current else 0) + 1
def record_team_history(db, tenant, user, row, action):
"""Append-only team snapshot; caller owns the surrounding transaction."""
if not isinstance(row, ProjectTeam):
return
snapshot = serialize_entity(db, tenant, row)
operator_id = user.get('user_id')
operator = scoped(db, SysUser, tenant).filter(SysUser.id == operator_id).first() if operator_id else None
operator_name = (operator.nick_name if operator else None) or (operator_id or '')
now = datetime.now()
db.add(ProjectTeamHistory(
id=generate_uuid(),
tenant_no=tenant,
team_id=row.id,
version_no=_next_team_history_version(db, tenant, row.id),
action=action,
snapshot_json=json.dumps(snapshot, ensure_ascii=False, default=str, separators=(',', ':')),
operator_user_id=operator_id,
operator_user_name=operator_name,
operate_time=now,
create_by=operator_id,
create_dept_id=user.get('dept_id') or '',
update_by=operator_id,
create_time=now,
update_time=now,
))
def serialize_entity(db, tenant, row):
leader = scoped(db, SysUser, tenant).filter(SysUser.id == row.leader_user_id).first()
result = dict(id=row.id, leaderUserId=row.leader_user_id, leaderName=leader.nick_name if leader else '', remark=row.remark, createTime=row.create_time)
@@ -322,6 +361,7 @@ def save_entity(db, tenant, user, model, body, row_id=None):
natural_key=lambda item, p=public: item[p], row_natural_key=lambda child, a=attr: getattr(child, a))
except ValueError as exc:
raise HTTPException(400, str(exc))
record_team_history(db, tenant, user, row, 'update' if row_id else 'create')
else:
team = get_row(db, ProjectTeam, tenant, body.get('teamId'), lock=True)
if row_id and row.team_id != team.id and has_business(db, tenant, row.id):
@@ -333,8 +373,17 @@ def save_entity(db, tenant, user, model, body, row_id=None):
if status not in ('pending', 'active', 'completed', 'disabled'):
raise HTTPException(400, '项目状态无效')
row.project_name, row.team_id, row.biz_types, row.status = body[name_key].strip(), team.id, ','.join(dict.fromkeys(kinds)), status
if not row_id:
row.project_no = 'XM' + datetime.now().strftime('%Y%m%d') + generate_uuid().replace('-', '')[:16].upper()
project_no = str(body.get('projectNo') or '').strip()
if not project_no:
raise HTTPException(400, '项目编号不能为空')
if len(project_no) > 64:
raise HTTPException(400, '项目编号不能超过 64 个字符')
conflict = scoped(db, Project, tenant).filter(Project.project_no == project_no)
if row_id:
conflict = conflict.filter(Project.id != row.id)
if conflict.with_for_update().first():
raise HTTPException(400, '项目编号已存在')
row.project_no = project_no
try:
row.start_date = date.fromisoformat(body['startDate']) if body.get('startDate') else None
row.end_date = date.fromisoformat(body['endDate']) if body.get('endDate') else None
@@ -353,6 +402,8 @@ def delete_entity(db, tenant, user, model, row_id):
raise HTTPException(400, '项目已被业务引用,请停用')
if model is ProjectTeam and db.query(Project.id).filter(Project.tenant_no == tenant, Project.team_id == row.id).with_for_update().first():
raise HTTPException(400, '项目组已被项目引用,请停用')
if model is ProjectTeam:
record_team_history(db, tenant, user, row, 'delete')
row.del_flag, row.update_by = 1, user.get('user_id')
if model is ProjectTeam:
for _, child_model, *_ in CHILDREN:
@@ -0,0 +1,421 @@
"""市场协议作废 ↔ 质检样本接收废弃双向联动。
规则(细胞存储 / 科研捐赠):
1. 协议变为已作废(yzf)时,协议下全部样本接收结论自动为废弃(bj)。
2. 协议下全部有效样本均已废弃/拒收(bj)时,协议自动已作废,作废类型为「实验室废弃」。
"""
from __future__ import annotations
from datetime import datetime
from typing import List, Optional
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import (
BizCollectionProtocol,
BizProtocolSample,
BizSampleReception,
SysDictData,
)
from app.models.research_donation import BizResearchDonation, BizResearchDonationSample
LAB_VOID_TYPE_CODE = "sysfq"
LAB_VOID_TYPE_LABEL = "实验室废弃"
VOID_TYPE_DICT = "xyzflx"
_AUTO_DISCARD_REMARK = "协议作废自动废弃"
_AUTO_VOID_DESC = "全部样本接收废弃/拒收,系统自动作废"
def ensure_lab_void_type(db: Session, *, tenant_no: str) -> str:
"""确保租户字典 xyzflx 存在「实验室废弃」,返回 dict_value。"""
tenant = (tenant_no or "").strip()
if not tenant:
raise ValueError("租户编号不能为空")
by_value = (
db.query(SysDictData)
.filter(
SysDictData.dict_type == VOID_TYPE_DICT,
SysDictData.dict_value == LAB_VOID_TYPE_CODE,
SysDictData.tenant_no == tenant,
SysDictData.del_flag == 0,
)
.first()
)
if by_value:
return LAB_VOID_TYPE_CODE
by_label = (
db.query(SysDictData)
.filter(
SysDictData.dict_type == VOID_TYPE_DICT,
SysDictData.dict_label == LAB_VOID_TYPE_LABEL,
SysDictData.tenant_no == tenant,
SysDictData.del_flag == 0,
)
.first()
)
if by_label and (by_label.dict_value or "").strip():
return (by_label.dict_value or "").strip()
now = datetime.now()
db.add(
SysDictData(
id=generate_uuid(),
dict_label=LAB_VOID_TYPE_LABEL,
dict_value=LAB_VOID_TYPE_CODE,
dict_type=VOID_TYPE_DICT,
tenant_no=tenant,
order_num=90,
remark="issue#31 实验室废弃自动作废类型",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.flush()
return LAB_VOID_TYPE_CODE
def _list_cell_samples(db: Session, *, protocol_id: str, tenant_no: str) -> List[BizProtocolSample]:
return (
db.query(BizProtocolSample)
.filter(
BizProtocolSample.collection_protocol_id == protocol_id,
BizProtocolSample.tenant_no == tenant_no,
BizProtocolSample.del_flag == 0,
)
.order_by(BizProtocolSample.order_num.asc(), BizProtocolSample.create_time.asc())
.all()
)
def _list_donation_samples(
db: Session, *, donation_id: str, tenant_no: str
) -> List[BizResearchDonationSample]:
return (
db.query(BizResearchDonationSample)
.filter(
BizResearchDonationSample.research_donation_id == donation_id,
BizResearchDonationSample.tenant_no == tenant_no,
BizResearchDonationSample.del_flag == 0,
)
.order_by(
BizResearchDonationSample.order_num.asc(),
BizResearchDonationSample.create_time.asc(),
)
.all()
)
def _find_reception(
db: Session,
*,
tenant_no: str,
source: str,
sample_code: str,
) -> Optional[BizSampleReception]:
code = (sample_code or "").strip()
if not code:
return None
return (
db.query(BizSampleReception)
.filter(
BizSampleReception.tenant_no == tenant_no,
BizSampleReception.del_flag == 0,
BizSampleReception.reception_source == source,
BizSampleReception.sample_code == code,
)
.first()
)
def _upsert_discard_reception(
db: Session,
*,
source: str,
tenant_no: str,
user_id: Optional[str],
sample_code: str,
protocol_sample_id: str = "",
collection_protocol_id: str = "",
donation_sample_id: Optional[str] = None,
research_donation_id: Optional[str] = None,
now: Optional[datetime] = None,
) -> None:
code = (sample_code or "").strip()
if not code:
return
ts = now or datetime.now()
rec = _find_reception(db, tenant_no=tenant_no, source=source, sample_code=code)
if rec:
if (rec.accept_conclusion or "").strip() == "bj":
return
rec.accept_conclusion = "bj"
rec.update_by = user_id
rec.update_time = ts
if not (rec.remark or "").strip():
rec.remark = _AUTO_DISCARD_REMARK
db.add(rec)
return
obj = BizSampleReception(
id=generate_uuid(),
protocol_sample_id=protocol_sample_id or "",
collection_protocol_id=collection_protocol_id or "",
reception_time=ts,
handler_user_id=user_id,
handler_name=None,
receive_temperature=None,
accept_conclusion="bj",
receive_attachments=None,
reception_source=source,
donation_sample_id=donation_sample_id,
research_donation_id=research_donation_id,
sample_code=code,
lab_storage_code=None,
tenant_no=tenant_no,
remark=_AUTO_DISCARD_REMARK,
create_by=user_id,
create_dept_id="",
update_by=user_id,
create_time=ts,
update_time=ts,
del_flag=0,
check_packaging=0,
check_sealing=0,
check_no_damage=0,
check_no_leakage=0,
check_color=0,
check_tying_rope=0,
check_no_stratification=0,
)
db.add(obj)
def discard_receptions_for_voided_protocol(
db: Session,
*,
source: str,
protocol_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> int:
"""协议已作废后,将其下样本接收全部置为废弃。返回处理样本数。"""
src = (source or "").strip()
pid = (protocol_id or "").strip()
tenant = (tenant_no or "").strip()
if src not in ("cell_storage", "research_donation") or not pid or not tenant:
return 0
now = datetime.now()
if src == "cell_storage":
samples = _list_cell_samples(db, protocol_id=pid, tenant_no=tenant)
for sample in samples:
_upsert_discard_reception(
db,
source=src,
tenant_no=tenant,
user_id=user_id,
sample_code=sample.sample_code or "",
protocol_sample_id=sample.id,
collection_protocol_id=pid,
now=now,
)
else:
samples = _list_donation_samples(db, donation_id=pid, tenant_no=tenant)
for sample in samples:
_upsert_discard_reception(
db,
source=src,
tenant_no=tenant,
user_id=user_id,
sample_code=sample.sample_code or "",
donation_sample_id=sample.id,
research_donation_id=pid,
now=now,
)
db.flush()
return len(samples)
def _all_samples_discarded(
db: Session,
*,
source: str,
protocol_id: str,
tenant_no: str,
) -> bool:
src = (source or "").strip()
if src == "cell_storage":
samples = _list_cell_samples(db, protocol_id=protocol_id, tenant_no=tenant_no)
else:
samples = _list_donation_samples(db, donation_id=protocol_id, tenant_no=tenant_no)
if not samples:
return False
for sample in samples:
code = (sample.sample_code or "").strip()
if not code:
return False
rec = _find_reception(db, tenant_no=tenant_no, source=src, sample_code=code)
if not rec or (rec.accept_conclusion or "").strip() != "bj":
return False
return True
def maybe_void_protocol_when_all_samples_discarded(
db: Session,
*,
source: str,
protocol_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> bool:
"""若协议下样本全部废弃,则自动作废协议(作废类型=实验室废弃)。返回是否执行了作废。"""
src = (source or "").strip()
pid = (protocol_id or "").strip()
tenant = (tenant_no or "").strip()
if src not in ("cell_storage", "research_donation") or not pid or not tenant:
return False
if not _all_samples_discarded(db, source=src, protocol_id=pid, tenant_no=tenant):
return False
void_type = ensure_lab_void_type(db, tenant_no=tenant)
now = datetime.now()
if src == "cell_storage":
obj = (
db.query(BizCollectionProtocol)
.filter(
BizCollectionProtocol.id == pid,
BizCollectionProtocol.tenant_no == tenant,
BizCollectionProtocol.del_flag == 0,
)
.first()
)
if not obj:
return False
status = (obj.protocol_status or "").strip()
if status in ("yzf", "sdfq"):
return False
from app.core.protocol_status_display import assert_protocol_status_transition
from app.crud.collection_protocol import update_collection_protocol
try:
assert_protocol_status_transition(status, "yzf", kind="protocol")
except ValueError:
return False
update_collection_protocol(
db,
pid,
tenant_no=tenant,
user_id=user_id,
protocol_status="yzf",
void_time=now,
void_desc=_AUTO_VOID_DESC,
void_type=void_type,
commit=False,
sync_reception_discard=False,
)
return True
obj = (
db.query(BizResearchDonation)
.filter(
BizResearchDonation.id == pid,
BizResearchDonation.tenant_no == tenant,
BizResearchDonation.del_flag == 0,
)
.first()
)
if not obj:
return False
status = (obj.donation_status or "").strip()
if status in ("yzf", "sdfq"):
return False
# 捐赠 change_status 默认 commit;此处内联等价逻辑避免提前提交。
from app.core.protocol_status_display import assert_protocol_status_transition
from app.crud.dict_data import normalize_void_type
from app.crud.research_donation import (
_DONATION_STATUS_CHANGE_TYPE,
append_donation_status_change,
)
current = obj.donation_status or ""
try:
assert_protocol_status_transition(current, "yzf", kind="donation")
except ValueError:
return False
normalized = normalize_void_type(db, value=void_type, tenant_no=tenant)
obj.donation_status = "yzf"
obj.void_time = now
obj.void_desc = _AUTO_VOID_DESC
obj.void_type = normalized
obj.update_by = user_id
obj.update_time = now
change_type = _DONATION_STATUS_CHANGE_TYPE.get("yzf")
if change_type:
append_donation_status_change(
db,
research_donation_id=obj.id,
change_type=change_type,
from_status=current,
to_status="yzf",
change_time=now,
change_desc=_AUTO_VOID_DESC,
void_type=normalized,
tenant_no=tenant,
user_id=user_id,
)
db.add(obj)
db.flush()
return True
def after_sample_reception_saved(
db: Session,
*,
reception: BizSampleReception,
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
"""样本接收保存后:若结论为废弃,尝试自动作废所属协议。"""
if (reception.accept_conclusion or "").strip() != "bj":
return
source = (reception.reception_source or "cell_storage").strip() or "cell_storage"
if source == "cell_storage":
protocol_id = (reception.collection_protocol_id or "").strip()
else:
protocol_id = (reception.research_donation_id or "").strip()
if not protocol_id:
return
maybe_void_protocol_when_all_samples_discarded(
db,
source=source,
protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
)
def after_protocol_voided(
db: Session,
*,
source: str,
protocol_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
"""协议作废后:同步废弃其下全部样本接收。"""
discard_receptions_for_voided_protocol(
db,
source=source,
protocol_id=protocol_id,
tenant_no=tenant_no,
user_id=user_id,
)
@@ -0,0 +1,18 @@
"""质检工单自动完成判定(无 DB 依赖,供 CRUD 与单测共用)。"""
def can_auto_complete_work_order(
*, status: str, active_task_count: int, pending_task_count: int
) -> bool:
"""仅当工单为已配置、存在活跃任务且全部已完成时,才可标为已完成。
活跃任务数为 0 时不得完成:调整检测清空后重配若尚未产生任务,
否则会把「已配置」误标为「已完成」。
"""
if (status or "").strip() != "ypz":
return False
if active_task_count <= 0:
return False
if pending_task_count > 0:
return False
return True
+23
View File
@@ -3733,6 +3733,29 @@ def test_protocol_list_filters_status_create_time_and_sample_collect_time():
assert [item['id'] for item in items] == ['hit']
def test_donation_list_filters_by_protocol_status_alias():
from app.services.market_protocol import list_protocols
db = _session()
_masters(db)
hit = _donation(db, 'don-hit', created=datetime(2026, 10, 8, 9, 0))
hit.donation_status = 'sxz'
miss = _donation(db, 'don-miss', created=datetime(2026, 10, 8, 9, 0))
miss.donation_status = 'dsx'
db.commit()
items, total = list_protocols(
db,
ctx=_ctx('specialist'),
user_info=_user(),
source='research_donation',
protocol_status='sxz',
)
assert total == 1
assert [item['id'] for item in items] == ['don-hit']
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
from app.services.market_protocol import create_protocol
+56 -3
View File
@@ -10,9 +10,10 @@ class ProjectManagementTest(unittest.TestCase):
def setUp(self):
self.assertIsNotNone(importlib.util.find_spec('app.services.project_management'), 'project service missing')
from app.services import project_management as svc
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember, ProjectTeamHistory
from app.models.base import Base
self.svc, self.Project, self.Team = svc, Project, ProjectTeam
self.History = ProjectTeamHistory
self.engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
@@ -148,8 +149,8 @@ class ProjectManagementTest(unittest.TestCase):
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
self.db.commit()
result = self.svc.save_entity(self.db, 't', self.user, self.Project,
dict(projectName='New', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
self.assertTrue(result['projectNo'].startswith('XM'))
dict(projectName='New', projectNo='CUSTOM-001', leaderUserId='u', teamId='g', bizTypes=['basic_experiment'], startDate='', endDate=''))
self.assertEqual(result['projectNo'], 'CUSTOM-001')
self.assertIsNone(result['startDate'])
def test_conversion_table_reverse_graph_and_audit(self):
@@ -235,6 +236,58 @@ class ProjectManagementTest(unittest.TestCase):
self.assertIn("parent.perms='project:project:list'", menus)
self.assertNotIn('INSERT INTO sys_role_menu', menus)
def test_team_history_versions_on_create_update_delete(self):
import json
from app.models.models import SysUser, BizCollectionHospital, BizCollectionRegion
from app.models.project_management import ProjectTeamHistory
self.db.add_all([
SysUser(id='u', tenant_no='t', user_no='u', nick_name='User', status='1'),
SysUser(id='m', tenant_no='t', user_no='m', nick_name='Member', status='1'),
BizCollectionRegion(id='r1', tenant_no='t', region_name='华东'),
BizCollectionHospital(id='h1', tenant_no='t', hospital_name='一院', region_id='r1'),
])
self.db.commit()
created = self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
teamName='Alpha', leaderUserId='u', enabled=True,
members=[{'userId': 'm'}], regions=[{'regionId': 'r1'}], hospitals=[{'hospitalId': 'h1'}],
remark='v1',
))
team_id = created['id']
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].action, 'create')
self.assertEqual(rows[0].version_no, 1)
snap = json.loads(rows[0].snapshot_json)
self.assertEqual(snap['teamName'], 'Alpha')
self.assertEqual(snap['leaderName'], 'User')
self.assertEqual([m['userId'] for m in snap['members']], ['m'])
self.assertEqual([h['hospitalId'] for h in snap['hospitals']], ['h1'])
self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
teamName='Alpha2', leaderUserId='u', enabled=True,
members=[{'userId': 'm'}, {'userId': 'u'}], regions=[{'regionId': 'r1'}],
hospitals=[{'hospitalId': 'h1'}], remark='v2',
), team_id)
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 2)
self.assertEqual(rows[1].action, 'update')
self.assertEqual(rows[1].version_no, 2)
self.assertEqual(json.loads(rows[1].snapshot_json)['teamName'], 'Alpha2')
self.assertEqual(len(json.loads(rows[1].snapshot_json)['members']), 2)
self.svc.delete_entity(self.db, 't', self.user, self.Team, team_id)
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 3)
self.assertEqual(rows[2].action, 'delete')
self.assertEqual(rows[2].version_no, 3)
self.assertEqual(json.loads(rows[2].snapshot_json)['teamName'], 'Alpha2')
if __name__ == '__main__':
unittest.main()
@@ -0,0 +1,148 @@
"""Issue #24:协议操作过程日志 append-only 与动作映射。"""
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
def _session():
from app.models.models import (
BizCollectionProtocol,
BizProtocolCollectionInfo,
BizProtocolOperationLog,
BizProtocolPartyCustomer,
BizProtocolSample,
SysUser,
)
engine = create_engine("sqlite:///:memory:")
BizCollectionProtocol.__table__.create(engine)
BizProtocolPartyCustomer.__table__.create(engine)
BizProtocolCollectionInfo.__table__.create(engine)
BizProtocolSample.__table__.create(engine)
BizProtocolOperationLog.__table__.create(engine)
SysUser.__table__.create(engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)(), engine
def test_record_cell_update_appends_before_after_and_maps_void_action():
from app.crud import protocol_operation_log as op_log
from app.models.models import BizCollectionProtocol, BizProtocolPartyCustomer, SysUser
db, engine = _session()
try:
now = datetime(2026, 11, 5, 10, 0, 0)
db.add(
SysUser(
id="u1",
user_no="u1",
nick_name="采集员甲",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizCollectionProtocol(
id="p1",
protocol_no="XY2401",
protocol_status="sxz",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProtocolPartyCustomer(
id="party1",
collection_protocol_id="p1",
mother_name="旧姓名",
tenant_no="zkxw",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.commit()
before = op_log.snapshot_cell_protocol(db, protocol_id="p1", tenant_no="zkxw")
assert before is not None
assert before["protocol"]["protocolStatus"] == "sxz"
assert before["partyCustomer"]["motherName"] == "旧姓名"
row = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == "p1").one()
row.protocol_status = "yzf"
party = (
db.query(BizProtocolPartyCustomer)
.filter(BizProtocolPartyCustomer.collection_protocol_id == "p1")
.one()
)
party.mother_name = "新姓名"
db.add(row)
db.add(party)
db.flush()
op_log.record_cell_operation(
db,
protocol_id="p1",
action=op_log.ACTION_UPDATE,
tenant_no="zkxw",
user_id="u1",
client_channel=op_log.CHANNEL_MINI,
before=before,
)
db.commit()
items = op_log.list_protocol_operation_logs(
db, biz_type=op_log.BIZ_CELL, biz_id="p1", tenant_no="zkxw"
)
assert len(items) == 1
item = items[0]
assert item["action"] == "void"
assert item["clientChannel"] == "mini"
assert item["operatorUserName"] == "采集员甲"
assert item["before"]["partyCustomer"]["motherName"] == "旧姓名"
assert item["after"]["partyCustomer"]["motherName"] == "新姓名"
assert item["after"]["protocol"]["protocolStatus"] == "yzf"
finally:
db.close()
engine.dispose()
def test_resolve_action_from_status_keeps_default_when_unchanged():
from app.crud.protocol_operation_log import (
ACTION_AUDIT,
ACTION_UPDATE,
ACTION_VOID,
resolve_action_from_status,
)
assert (
resolve_action_from_status(
default_action=ACTION_UPDATE,
before_status="sxz",
after_status="sxz",
)
== ACTION_UPDATE
)
assert (
resolve_action_from_status(
default_action=ACTION_UPDATE,
before_status="sxz",
after_status="yzf",
)
== ACTION_VOID
)
assert (
resolve_action_from_status(
default_action=ACTION_AUDIT,
before_status="dsx",
after_status="sxz",
)
== ACTION_AUDIT
)
@@ -0,0 +1,328 @@
"""协议作废 ↔ 样本接收废弃双向联动。"""
from __future__ import annotations
import unittest
from datetime import datetime
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.database import Base
from app.models.models import (
BizCollectionProtocol,
BizProtocolSample,
BizProtocolStatusChange,
BizSampleReception,
SysDictData,
)
from app.models.research_donation import (
BizDonationStatusChange,
BizResearchDonation,
BizResearchDonationSample,
)
from app.services.protocol_reception_discard_sync import (
LAB_VOID_TYPE_CODE,
after_protocol_voided,
after_sample_reception_saved,
ensure_lab_void_type,
)
def _session():
engine = create_engine("sqlite://")
Base.metadata.create_all(
bind=engine,
tables=[
SysDictData.__table__,
BizCollectionProtocol.__table__,
BizProtocolSample.__table__,
BizProtocolStatusChange.__table__,
BizSampleReception.__table__,
BizResearchDonation.__table__,
BizResearchDonationSample.__table__,
BizDonationStatusChange.__table__,
],
)
return sessionmaker(bind=engine)()
def _now():
return datetime(2026, 11, 5, 12, 0, 0)
class TestProtocolReceptionDiscardSync(unittest.TestCase):
def test_ensure_lab_void_type_creates_dict_row(self):
db = _session()
code = ensure_lab_void_type(db, tenant_no="tenant-a")
self.assertEqual(code, LAB_VOID_TYPE_CODE)
row = (
db.query(SysDictData)
.filter(
SysDictData.tenant_no == "tenant-a",
SysDictData.dict_type == "xyzflx",
SysDictData.dict_value == LAB_VOID_TYPE_CODE,
)
.one()
)
self.assertEqual(row.dict_label, "实验室废弃")
# idempotent
self.assertEqual(ensure_lab_void_type(db, tenant_no="tenant-a"), LAB_VOID_TYPE_CODE)
def test_protocol_void_marks_all_sample_receptions_discarded(self):
db = _session()
now = _now()
db.add(
BizCollectionProtocol(
id="p1",
protocol_no="CP-1",
protocol_status="sxz",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProtocolSample(
id="s1",
collection_protocol_id="p1",
sample_code="ZK001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProtocolSample(
id="s2",
collection_protocol_id="p1",
sample_code="ZK002",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizSampleReception(
id="r1",
protocol_sample_id="s1",
collection_protocol_id="p1",
reception_time=now,
accept_conclusion="js",
reception_source="cell_storage",
sample_code="ZK001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
check_packaging=0,
check_sealing=0,
check_no_damage=0,
check_no_leakage=0,
check_color=0,
check_tying_rope=0,
check_no_stratification=0,
)
)
db.commit()
after_protocol_voided(
db,
source="cell_storage",
protocol_id="p1",
tenant_no="tenant-a",
user_id="u1",
)
db.commit()
rows = (
db.query(BizSampleReception)
.filter(BizSampleReception.tenant_no == "tenant-a", BizSampleReception.del_flag == 0)
.all()
)
self.assertEqual(len(rows), 2)
by_code = {r.sample_code: r.accept_conclusion for r in rows}
self.assertEqual(by_code["ZK001"], "bj")
self.assertEqual(by_code["ZK002"], "bj")
def test_all_samples_discarded_auto_voids_protocol_with_lab_type(self):
db = _session()
now = _now()
db.add(
BizCollectionProtocol(
id="p2",
protocol_no="CP-2",
protocol_status="sxz",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizProtocolSample(
id="s3",
collection_protocol_id="p2",
sample_code="ZK003",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
rec = BizSampleReception(
id="r2",
protocol_sample_id="s3",
collection_protocol_id="p2",
reception_time=now,
accept_conclusion="bj",
reception_source="cell_storage",
sample_code="ZK003",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
check_packaging=0,
check_sealing=0,
check_no_damage=0,
check_no_leakage=0,
check_color=0,
check_tying_rope=0,
check_no_stratification=0,
)
db.add(rec)
db.commit()
after_sample_reception_saved(
db, reception=rec, tenant_no="tenant-a", user_id="u1"
)
db.commit()
proto = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == "p2").one()
self.assertEqual(proto.protocol_status, "yzf")
self.assertEqual(proto.void_type, LAB_VOID_TYPE_CODE)
self.assertIn("全部样本接收", proto.void_desc or "")
def test_partial_discard_does_not_void_protocol(self):
db = _session()
now = _now()
db.add(
BizCollectionProtocol(
id="p3",
protocol_no="CP-3",
protocol_status="sxz",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
for sid, code in (("s4", "ZK004"), ("s5", "ZK005")):
db.add(
BizProtocolSample(
id=sid,
collection_protocol_id="p3",
sample_code=code,
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
rec = BizSampleReception(
id="r3",
protocol_sample_id="s4",
collection_protocol_id="p3",
reception_time=now,
accept_conclusion="bj",
reception_source="cell_storage",
sample_code="ZK004",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
check_packaging=0,
check_sealing=0,
check_no_damage=0,
check_no_leakage=0,
check_color=0,
check_tying_rope=0,
check_no_stratification=0,
)
db.add(rec)
db.commit()
after_sample_reception_saved(
db, reception=rec, tenant_no="tenant-a", user_id="u1"
)
db.commit()
proto = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == "p3").one()
self.assertEqual(proto.protocol_status, "sxz")
self.assertFalse(proto.void_type)
def test_donation_void_creates_discard_receptions(self):
db = _session()
now = _now()
db.add(
BizResearchDonation(
id="d1",
donation_no="RD-1",
donation_status="sxz",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.add(
BizResearchDonationSample(
id="ds1",
research_donation_id="d1",
sample_code="RD001",
tenant_no="tenant-a",
create_dept_id="",
create_time=now,
update_time=now,
del_flag=0,
)
)
db.commit()
after_protocol_voided(
db,
source="research_donation",
protocol_id="d1",
tenant_no="tenant-a",
user_id="u1",
)
db.commit()
rec = (
db.query(BizSampleReception)
.filter(
BizSampleReception.sample_code == "RD001",
BizSampleReception.reception_source == "research_donation",
)
.one()
)
self.assertEqual(rec.accept_conclusion, "bj")
self.assertEqual(rec.research_donation_id, "d1")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,81 @@
"""质检工单自动完成条件:无活跃任务时不得标为已完成(issue #32)。"""
import unittest
class TestCanAutoCompleteWorkOrder(unittest.TestCase):
def test_zero_active_tasks_must_not_complete(self):
"""调整检测清空后重配若尚未产生任务,不能把已配置打成已完成。"""
from app.services.qc_work_order_complete import can_auto_complete_work_order
self.assertFalse(
can_auto_complete_work_order(
status="ypz", active_task_count=0, pending_task_count=0
)
)
def test_all_tasks_completed_can_complete(self):
from app.services.qc_work_order_complete import can_auto_complete_work_order
self.assertTrue(
can_auto_complete_work_order(
status="ypz", active_task_count=3, pending_task_count=0
)
)
def test_pending_tasks_block_complete(self):
from app.services.qc_work_order_complete import can_auto_complete_work_order
self.assertFalse(
can_auto_complete_work_order(
status="ypz", active_task_count=3, pending_task_count=1
)
)
def test_non_configured_status_not_completed(self):
from app.services.qc_work_order_complete import can_auto_complete_work_order
self.assertFalse(
can_auto_complete_work_order(
status="dpz", active_task_count=2, pending_task_count=0
)
)
self.assertFalse(
can_auto_complete_work_order(
status="ywc", active_task_count=2, pending_task_count=0
)
)
class TestValidateBatchRequiresItems(unittest.TestCase):
def test_batch_without_items_rejected(self):
from app.crud.qc_inspection_batch import _validate_batch_payload
with self.assertRaises(ValueError) as ctx:
_validate_batch_payload(
{
"batchName": "B1",
"assigneeUserIds": ["u1"],
"labItemIds": [],
"thirdPartyItemIds": [],
},
0,
)
self.assertIn("检测项目", str(ctx.exception))
def test_batch_with_lab_item_ok(self):
from app.crud.qc_inspection_batch import _validate_batch_payload
_validate_batch_payload(
{
"batchName": "B1",
"assigneeUserIds": ["u1"],
"labItemIds": ["lab-1"],
"thirdPartyItemIds": [],
},
0,
)
if __name__ == "__main__":
unittest.main()
+10 -2
View File
@@ -60,9 +60,17 @@
},
"figma": {
"references": [
"https://www.figma.com/design/jc7D8Fe7lKNTVj4jM3AoGW?node-id=2-2"
"https://www.figma.com/design/M9XtXqXRCAKccMWD7BivIL",
"https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB"
],
"status": "user-confirmed-demo-only"
"status": "web-and-mobile-design-systems-v1",
"authority": {
"web-admin": "https://www.figma.com/design/M9XtXqXRCAKccMWD7BivIL",
"mobile-h5": "https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB",
"mobile-oa-h5": "https://www.figma.com/design/W3yXdlX4TbQP0PVhFG8cBB"
},
"archive": null,
"notes": "Web 用 Fresh Mint(#10b981);移动端用独立 Mobile Design System(规范品牌绿 #0a9f6d),禁止混色。旧整页镜像文件已删除。Figma 色板按规范,不以 styles/tokens.scss 的 #22c55e 覆盖。已补高频状态:Web StateBlock/FormAlert(P01 旁展示);Mobile Empty/ConfirmDialog/WebViewState。"
},
"delivery": {
"provider": "drone",
@@ -0,0 +1,25 @@
-- Issue #50: 项目组版本历史(append-only JSON 快照)。MySQL 5.7.
-- DDL 隐式提交;该脚本不作为自动应用启动步骤。上线前仍须备份并在目标副本验证。
CREATE TABLE IF NOT EXISTS biz_project_team_history (
id VARCHAR(64) NOT NULL,
team_id VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
action VARCHAR(16) NOT NULL,
snapshot_json TEXT NOT NULL,
operator_user_id VARCHAR(64) NULL,
operator_user_name VARCHAR(100) NULL,
operate_time DATETIME NOT NULL,
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_team_version (tenant_no, team_id, version_no),
KEY ix_team_history (tenant_no, team_id, version_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,29 @@
-- Issue #24:协议单操作过程日志(before / action / after)
-- MySQL 5.7;可重复执行(CREATE IF NOT EXISTS)。上线前须备份并在目标副本验证。
-- DDL 隐式提交;该脚本不作为自动应用启动步骤。
CREATE TABLE IF NOT EXISTS biz_protocol_operation_log (
id VARCHAR(64) NOT NULL,
biz_type VARCHAR(32) NOT NULL COMMENT '业务类型 cell_storage/research_donation',
biz_id VARCHAR(64) NOT NULL COMMENT '协议单或捐赠单ID',
protocol_no VARCHAR(64) NULL COMMENT '协议/捐赠编号快照',
action VARCHAR(32) NOT NULL COMMENT '动作 create/update/delete/void/activate/expire/to_research/to_storage/audit',
client_channel VARCHAR(16) NOT NULL DEFAULT 'web' COMMENT '来源端 web/mini',
before_json MEDIUMTEXT NULL COMMENT '操作前协议信息JSON',
after_json MEDIUMTEXT NULL COMMENT '操作后协议信息JSON',
operator_user_id VARCHAR(64) NULL COMMENT '操作人用户ID',
operator_user_name VARCHAR(64) NULL COMMENT '操作人姓名快照',
operate_time DATETIME NOT NULL COMMENT '业务操作时间',
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
KEY ix_pol_biz (tenant_no, biz_type, biz_id, del_flag),
KEY ix_pol_time (tenant_no, operate_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,32 @@
-- Issue #31:作废类型字典补充「实验室废弃」(sysfq)
-- 为已有 xyzflx 字典的租户补齐;可重复执行。
INSERT INTO sys_dict_data (
id, dict_label, dict_value, dict_type, tenant_no, order_num, remark,
create_dept_id, create_time, update_time, del_flag
)
SELECT
MD5(CONCAT(t.tenant_no, ':xyzflx:sysfq')),
'实验室废弃',
'sysfq',
'xyzflx',
t.tenant_no,
90,
'issue#31 实验室废弃',
'',
NOW(),
NOW(),
0
FROM (
SELECT DISTINCT tenant_no
FROM sys_dict_data
WHERE dict_type = 'xyzflx' AND del_flag = 0
) t
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data existing
WHERE existing.tenant_no = t.tenant_no
AND existing.dict_type = 'xyzflx'
AND existing.dict_value = 'sysfq'
AND existing.del_flag = 0
);
@@ -0,0 +1,309 @@
# 项目组版本历史(仅落库)Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 项目组每次创建/编辑/软删除成功后,向 `biz_project_team_history` 追加一条带递增 `version_no` 的完整 JSON 快照,无需 UI。
**Architecture:** 在 `backend/app/services/project_management.py` 的 `save_entity` / `delete_entity` 中,对 `ProjectTeam` 在 `commit` 前调用历史写入助手;快照复用 `serialize_entity`;表结构与协议操作日志同类,但按版本号语义建模。
**Tech Stack:** FastAPI / SQLAlchemy / MySQL 5.7 DDL / unittest + SQLite in-memory(沿用 `test_project_management.py`)
**Spec:** `docs/superpowers/specs/2026-11-05-project-team-history-design.md`
**修改端:** 仅 `backend`(不改 `web-admin`)
---
## File map
| 文件 | 职责 |
| --- | --- |
| `docs/sql/migrations/2026-11-05-project_team_history.sql` | MySQL 建表 |
| `backend/app/models/project_management.py` | `ProjectTeamHistory` ORM |
| `backend/app/models/__init__.py` | 导出模型 |
| `backend/app/services/project_management.py` | `record_team_history` + 挂钩 save/delete |
| `backend/tests/test_project_management.py` | 创建/多次更新/删除历史断言 |
---
### Task 1: 失败测试(TDD 红)
**Files:**
- Modify: `backend/tests/test_project_management.py`
- (实现尚未存在时导入 `ProjectTeamHistory` 会失败,或断言历史行数为 0→期望 ≥1)
- [ ] **Step 1: 在 `ProjectManagementTest` 增加测试方法**
在 `backend/tests/test_project_management.py` 末尾(同类测试旁)加入:
```python
def test_team_history_versions_on_create_update_delete(self):
import json
from app.models.models import SysUser, BizCollectionHospital, BizCollectionRegion
from app.models.project_management import ProjectTeamHistory
self.db.add_all([
SysUser(id='u', tenant_no='t', user_no='u', nick_name='User', status='1'),
SysUser(id='m', tenant_no='t', user_no='m', nick_name='Member', status='1'),
BizCollectionRegion(id='r1', tenant_no='t', region_name='华东'),
BizCollectionHospital(id='h1', tenant_no='t', hospital_name='一院', region_id='r1'),
])
self.db.commit()
created = self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
teamName='Alpha', leaderUserId='u', enabled=True,
members=[{'userId': 'm'}], regions=[{'regionId': 'r1'}], hospitals=[{'hospitalId': 'h1'}],
remark='v1',
))
team_id = created['id']
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 1)
self.assertEqual(rows[0].action, 'create')
self.assertEqual(rows[0].version_no, 1)
snap = json.loads(rows[0].snapshot_json)
self.assertEqual(snap['teamName'], 'Alpha')
self.assertEqual(snap['leaderName'], 'User')
self.assertEqual([m['userId'] for m in snap['members']], ['m'])
self.assertEqual([h['hospitalId'] for h in snap['hospitals']], ['h1'])
self.svc.save_entity(self.db, 't', self.user, self.Team, dict(
teamName='Alpha2', leaderUserId='u', enabled=True,
members=[{'userId': 'm'}, {'userId': 'u'}], regions=[{'regionId': 'r1'}],
hospitals=[{'hospitalId': 'h1'}], remark='v2',
), team_id)
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 2)
self.assertEqual(rows[1].action, 'update')
self.assertEqual(rows[1].version_no, 2)
self.assertEqual(json.loads(rows[1].snapshot_json)['teamName'], 'Alpha2')
self.assertEqual(len(json.loads(rows[1].snapshot_json)['members']), 2)
# 解除「已被项目引用」阻挡:setUp 里的项目挂在 'g',新建组可删
self.svc.delete_entity(self.db, 't', self.user, self.Team, team_id)
rows = (self.db.query(ProjectTeamHistory)
.filter_by(tenant_no='t', team_id=team_id, del_flag=0)
.order_by(ProjectTeamHistory.version_no).all())
self.assertEqual(len(rows), 3)
self.assertEqual(rows[2].action, 'delete')
self.assertEqual(rows[2].version_no, 3)
self.assertEqual(json.loads(rows[2].snapshot_json)['teamName'], 'Alpha2')
```
注意:若 `BizCollectionRegion` / `BizCollectionHospital` 构造字段与模型不一致,按 `backend/app/models/models.py` 实际必填列补齐(与 `references()` 所用表一致)。`SysUser.status` 须为可被 `save_entity` 接受的启用态(现有 `'1'`)。
- [ ] **Step 2: 跑测试确认失败**
```bash
cd backend && python -m pytest tests/test_project_management.py::ProjectManagementTest::test_team_history_versions_on_create_update_delete -v
```
Expected: FAIL(`ProjectTeamHistory` 不存在,或历史行数为 0)
- [ ] **Step 3: Commit(仅测试,待用户要求提交时执行;实现会话可与后续任务合并提交)**
---
### Task 2: DDL 迁移
**Files:**
- Create: `docs/sql/migrations/2026-11-05-project_team_history.sql`
- [ ] **Step 1: 写入迁移**
```sql
-- Project team version history (append-only JSON snapshots). MySQL 5.7.
-- DDL 隐式提交;不作为应用启动自动步骤。上线前备份并在目标副本验证。
CREATE TABLE IF NOT EXISTS biz_project_team_history (
id VARCHAR(64) NOT NULL,
team_id VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
action VARCHAR(16) NOT NULL,
snapshot_json TEXT NOT NULL,
operator_user_id VARCHAR(64) NULL,
operator_user_name VARCHAR(100) NULL,
operate_time DATETIME NOT NULL,
tenant_no VARCHAR(64) NOT NULL,
order_num INT NULL,
remark VARCHAR(512) NULL,
create_by VARCHAR(64) NULL,
create_dept_id VARCHAR(64) NOT NULL DEFAULT '',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_by VARCHAR(64) NULL,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
del_flag SMALLINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_team_version (tenant_no, team_id, version_no),
KEY ix_team_history (tenant_no, team_id, version_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
- [ ] **Step 2: 确认无应用启动自动执行该 SQL 的挂钩**(仅文档/运维手工应用,与 `2026-11-02-project_management.sql` 一致)
---
### Task 3: ORM 模型
**Files:**
- Modify: `backend/app/models/project_management.py`
- Modify: `backend/app/models/__init__.py`
- [ ] **Step 1: 在 `project_management.py` 增加模型**(需 `from sqlalchemy import Text` 若尚未导入)
```python
class ProjectTeamHistory(ProjectFields, Base):
"""项目组版本历史;每次成功写入记完整快照。"""
__tablename__ = 'biz_project_team_history'
__table_args__ = (UniqueConstraint('tenant_no', 'team_id', 'version_no', name='uk_team_version'),)
team_id = Column(String(64), nullable=False, index=True)
version_no = Column(Integer, nullable=False)
action = Column(String(16), nullable=False)
snapshot_json = Column(Text, nullable=False)
operator_user_id = Column(String(64))
operator_user_name = Column(String(100))
operate_time = Column(DateTime, nullable=False, default=datetime.now)
```
- [ ] **Step 2: 更新 `__init__.py` 导入与 `__all__`**
```python
from app.models.project_management import (
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
)
# __all__ 中追加 "ProjectTeamHistory"
```
---
### Task 4: 服务层写入挂钩
**Files:**
- Modify: `backend/app/services/project_management.py`
- [ ] **Step 1: 导入**
```python
import json
from app.models.project_management import (
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
)
```
- [ ] **Step 2: 增加助手函数**(放在 `CHILDREN` / `serialize_entity` 附近)
```python
def _next_team_history_version(db, tenant, team_id):
current = (db.query(ProjectTeamHistory.version_no)
.filter(ProjectTeamHistory.tenant_no == tenant,
ProjectTeamHistory.team_id == team_id,
ProjectTeamHistory.del_flag == 0)
.order_by(ProjectTeamHistory.version_no.desc())
.with_for_update()
.first())
return (current[0] if current else 0) + 1
def record_team_history(db, tenant, user, row, action):
"""Append-only team snapshot; caller owns the surrounding transaction."""
if not isinstance(row, ProjectTeam):
return
snapshot = serialize_entity(db, tenant, row)
operator_id = user.get('user_id')
leader = scoped(db, SysUser, tenant).filter(SysUser.id == operator_id).first() if operator_id else None
# Prefer nick_name when operator is a tenant user; fall back to user_id.
operator_name = (leader.nick_name if leader else None) or (operator_id or '')
now = datetime.now()
db.add(ProjectTeamHistory(
id=generate_uuid(),
tenant_no=tenant,
team_id=row.id,
version_no=_next_team_history_version(db, tenant, row.id),
action=action,
snapshot_json=json.dumps(snapshot, ensure_ascii=False, default=str, separators=(',', ':')),
operator_user_id=operator_id,
operator_user_name=operator_name,
operate_time=now,
create_by=operator_id,
create_dept_id=user.get('dept_id') or '',
update_by=operator_id,
create_time=now,
update_time=now,
))
```
修正:上面用 `leader` 变量名易混淆,实现时用 `operator = scoped(...).filter(SysUser.id == operator_id).first()`。
- [ ] **Step 3: 挂钩 `save_entity`**
在 `ProjectTeam` 分支完成子行 `sync_child_rows` 之后、最终 `db.add(row)` / `db.commit()` 之前(确保 `row.id` 已有),调用:
```python
record_team_history(db, tenant, user, row, 'update' if row_id else 'create')
```
注意:`save_entity` 对 Project 与 ProjectTeam 共用末尾 `db.commit()`。仅当 `model is ProjectTeam` 时记录。将调用放在 Team 分支末尾(子行同步成功后),Project 分支不调用。
- [ ] **Step 4: 挂钩 `delete_entity`**
```python
def delete_entity(db, tenant, user, model, row_id):
row = get_row(db, model, tenant, row_id, lock=True)
if model is Project and has_business(db, tenant, row.id):
raise HTTPException(400, '项目已被业务引用,请停用')
if model is ProjectTeam and db.query(Project.id).filter(Project.tenant_no == tenant, Project.team_id == row.id).with_for_update().first():
raise HTTPException(400, '项目组已被项目引用,请停用')
if model is ProjectTeam:
record_team_history(db, tenant, user, row, 'delete') # 先快照
row.del_flag, row.update_by = 1, user.get('user_id')
if model is ProjectTeam:
for _, child_model, *_ in CHILDREN:
for child in scoped(db, child_model, tenant).filter(child_model.team_id == row.id).all():
child.del_flag, child.update_by = 1, user.get('user_id')
db.commit()
return {'id': row.id}
```
- [ ] **Step 5: 跑目标测试**
```bash
cd backend && python -m pytest tests/test_project_management.py::ProjectManagementTest::test_team_history_versions_on_create_update_delete -v
```
Expected: PASS
- [ ] **Step 6: 回归相关测试**
```bash
cd backend && python -m pytest tests/test_project_management.py tests/test_project_assignment_integration.py -v
```
Expected: PASS(或仅记录与本改动无关的既有失败)
---
### Task 5: 收尾核对
- [ ] **Step 1:** 确认未改 `web-admin`、未加 History API、未引入 Figma。
- [ ] **Step 2:** 规格验收条目可映射到测试与代码路径。
- [ ] **Step 3:** 按 personal-dev 交付:工单回写、推送/PR/合并仅在授权下执行;当前工程 `write_issue`/`push_task_branch`/`create_pr`/`merge` 默认 false,缺权时保留本地完成态并报告缺口。
---
## Spec coverage checklist
| 规格要求 | Task |
| --- | --- |
| `biz_project_team_history` + 唯一版本约束 | 2, 3 |
| create/update/delete 记版 | 4 |
| 完整 serialize 快照 | 4 + 测试 1 |
| 删除先快照再软删 | 4 Step 4 |
| 无 UI / 无查询 API | Task 5 |
| 自动化测试 | 1, 4 |
## 执行说明
实现前按 personal-dev:创建/领取 Gitea 工单 → worktree(基线 `V2.0`)→ 在 worktree 内执行本计划。提交仅在用户要求或交付流程授权时进行。
@@ -0,0 +1,107 @@
# 项目组版本历史(仅落库)设计
日期:2026-11-05
状态:待用户确认规格后进入实现计划
范围:`backend`(Web 项目组管理经现有 API 写入;本期不改 `web-admin` UI,不画 Figma)
## 背景与目标
项目组管理(`/project/management/teams`)每次新增、编辑或软删除后,需要可追溯的版本历史,使 N 次修改后仍能还原当时完整信息:名称、负责人、成员、负责地区、负责医院、启用状态、备注及操作元数据。
本期只保证**数据正确落库**;不做历史列表/详情界面。
## 已确认决策
| 项 | 结论 |
| --- | --- |
| 交互 | 无界面;不生成 Figma |
| 存储 | 方案 A:独立历史表 + 完整 JSON 快照 |
| 业务范围 | 仅项目组(`ProjectTeam`);不含项目(`Project`) |
| 修改端 | `backend`;Web 继续走现有保存/删除 API |
| 查询 API | 本期不暴露只读接口;用自动化测试验证落库 |
| 与现有审计关系 | 不复用 `biz_project_assignment_audit`(仅业务归属变更) |
## 架构
在 `save_entity` / `delete_entity` 中,当模型为 `ProjectTeam` 且事务即将成功提交前:
1. 用现有 `serialize_entity` 生成当前(删除场景为软删后)完整字典;
2. 计算该 `team_id` 在租户内下一 `version_no`;
3. 追加写入 `biz_project_team_history` 一行(append-only,软删除标记仅遵循表结构惯例,业务不改历史行)。
对齐协议操作日志(`biz_protocol_operation_log`)的「动作 + 快照 JSON」思路,但本表按项目组版本号递增,强调「第 N 版完整态」而非前后 diff。
```
Web 保存/删除
→ API project_management
→ save_entity / delete_entity
→ 同步子行 / 软删
→ 写入 ProjectTeamHistory(version_no, action, snapshot_json, operator_*)
→ commit
```
失败时与主写操作同一事务回滚,避免「主数据已改、历史未记」。
## 数据模型
表名:`biz_project_team_history`
迁移:`docs/sql/migrations/` 下新增脚本(命名对齐现有 `2026-11-02-project_management.sql` 风格)。
| 字段 | 说明 |
| --- | --- |
| `id` | UUID PK |
| `team_id` | 项目组 ID(软删后仍可关联) |
| `version_no` | 租户内同一 `team_id` 从 1 递增 |
| `action` | `create` / `update` / `delete` |
| `snapshot_json` | Text,UTF-8 JSON;结构等于当时 `serialize_entity` 输出(含 `teamName`、`leaderUserId`、`leaderName`、`members`、`regions`、`hospitals`、`enabled`、`remark` 等) |
| `operator_user_id` / `operator_user_name` | 操作人 ID 与姓名快照 |
| `operate_time` | 业务操作时间 |
| `tenant_no` 及标准审计字段 | 与工程表字段顺序约定一致;`del_flag` 默认 0 |
唯一约束:`(tenant_no, team_id, version_no)`。
索引:`(tenant_no, team_id, version_no DESC)` 便于日后按组查历史。
ORM:在 `backend/app/models/project_management.py` 增加 `ProjectTeamHistory`,并在 `models/__init__.py` 导出。
## 写入规则
| 场景 | `action` | 快照时机 |
| --- | --- | --- |
| 新增项目组 | `create` | 子行同步完成后 |
| 编辑项目组 | `update` | 子行同步完成后(每次成功保存记一版,不要求字段 diff) |
| 软删除项目组 | `delete` | **先**用 `serialize_entity` 打删除前快照,**再**对主表与子行置 `del_flag=1`,最后写入历史行并 `commit`(`scoped` 默认排除已删行,禁止先软删再序列化) |
`version_no`:同事务内对 `(tenant_no, team_id)` 取 `MAX(version_no)+1`,无历史则从 1 开始;配合行锁/唯一约束防止并发撞号。
不在「无字段变化的重复保存」上做跳过优化(YAGNI);每次成功保存都记一版,满足「N 次修改 N 条历史」的验收语义。
## 非目标(本期不做)
- Web/小程序/移动 OA 任何历史入口或展示
- 历史恢复(rollback)到某版本
- 字段级 diff 展示
- 项目(非项目组)版本历史
- 对外只读 History API(可后续单独开需求)
## 测试
在现有项目组相关测试附近增加用例(隔离库):
1. 创建项目组 → 存在 `version_no=1`、`action=create`,`snapshot_json` 含负责人与成员/医院等;
2. 连续更新 N 次 → 存在版本 1…N+1,最新快照与当前 `serialize_entity` 一致;
3. 软删除 → 新增 `action=delete` 版本,且主流程仍成功。
不要求启动 `web-admin` 或改前端。
## 验收条件
1. 对任一项目组完成创建与若干次编辑后,库中按 `version_no` 可还原每一次的完整快照(负责人、成员、地区、医院等)。
2. 软删除同样产生历史行。
3. Web 项目组管理界面行为与现网一致(无新增按钮/弹窗)。
4. 相关自动化测试通过;字段/表结构有 `docs/sql` 迁移可查。
## 风险与备注
- `snapshot_json` 含展示名,人员改名后历史仍保留操作当时姓名(刻意为之)。
- 工程 `permissions.write_issue` 当前为 false:实现阶段创建/回写 Gitea 工单需用户授权或代建;本地代码与测试仍可推进。
- 集成分支:`V2.0`。
+2 -1
View File
@@ -40,7 +40,8 @@ export function createListFilters(module, source = '') {
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
return {
source: result.source,
protocolStatus: result.protocolStatus
protocolStatus: result.protocolStatus,
projectId: ''
}
}
return result
+11 -3
View File
@@ -15,11 +15,13 @@ test('creates isolated defaults for every market module', () => {
assert.deepEqual(first, {
source: 'cell_storage',
protocolStatus: 'sxz'
protocolStatus: 'sxz',
projectId: ''
})
assert.deepEqual(second, {
source: 'research_donation',
protocolStatus: ''
protocolStatus: '',
projectId: ''
})
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
@@ -66,8 +68,14 @@ test('drops incomplete ranges and counts only user-selectable filters', () => {
}), { progressStatus: 'yjq' })
assert.equal(activeFilterCount('protocol', {
source: 'cell_storage',
protocolStatus: 'sxz'
protocolStatus: 'sxz',
projectId: ''
}), 1)
assert.equal(activeFilterCount('protocol', {
source: 'research_donation',
protocolStatus: 'sxz',
projectId: 'p1'
}), 2)
assert.equal(activeFilterCount('freight', {
transportMode: '', sampleSource: 'research_donation'
}), 1)
@@ -313,6 +313,7 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
assert.match(protocol, /inlineDropdownFilters/)
assert.match(protocol, /全部协议状态/)
assert.match(protocol, /source:\s*'research_donation'/)
assert.match(protocol, /filterFields:\s*\[\]/)
assert.doesNotMatch(protocol, /createTimeRange/)
assert.doesNotMatch(protocol, /sampleCollectTimeRange/)
@@ -320,6 +321,9 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
assert.doesNotMatch(filters, /createTimeRange/)
assert.match(genericList, /inlineDropdownFilters/)
assert.match(genericList, /market-list__filter-row/)
assert.match(genericList, /showProtocolProjectFilter/)
assert.match(genericList, /全部项目/)
assert.doesNotMatch(genericList, /ProjectAssignmentField/)
})
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
@@ -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', () => {
+77 -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 || '')
@@ -418,6 +478,12 @@ export default {
allText: '全部协议状态',
source: 'cell_storage',
options: CELL_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
},
{
key: 'protocolStatus',
allText: '全部协议状态',
source: 'research_donation',
options: DONATION_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
}
],
statusLabel(detail) {
@@ -683,6 +749,8 @@ export default {
? detail.statusChanges
: synthesizeStatusChangesFromBasic(basic, source)
const statusChangeCards = buildStatusChangeCards(statusChangeRows, statusRegistry)
const operationLogRows = Array.isArray(detail?.operationLogs) ? detail.operationLogs : []
const operationLogCards = buildOperationLogCards(operationLogRows)
values.push({
key: 'statusChange',
title: '状态变更记录',
@@ -690,9 +758,17 @@ export default {
? []
: [{ label: '记录', value: '暂无状态变更记录' }],
cards: statusChangeCards
},
{
key: 'operationLog',
title: '操作日志',
fields: operationLogCards.length
? []
: [{ label: '记录', value: '暂无操作日志' }],
cards: operationLogCards
})
if (isDonation) {
const order = ['protocol', 'collection', 'samples', 'customer', 'audits', 'attachments', 'statusChange']
const order = ['protocol', 'collection', 'samples', 'customer', 'audits', 'attachments', 'statusChange', 'operationLog']
const byKey = new Map(values.map((section) => [section.key, section]))
return order.map((key) => byKey.get(key)).filter(Boolean)
}
@@ -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,8 +6,6 @@
:show-header="false"
>
<div class="market-list__filters">
<ProjectAssignmentField v-if="context.module === 'protocol'" v-model="listFilters.projectId"
:biz-type="listStatus" filter :context="context" @update:model-value="search" />
<div
v-if="listFilterTabs.length"
class="market-source-tabs"
@@ -59,15 +57,24 @@
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
</button>
</div>
<div v-if="inlineDropdownFilters.length" class="market-list__filter-row">
<div
v-if="inlineDropdownFilters.length || showProtocolProjectFilter"
class="market-list__filter-row"
>
<van-dropdown-menu active-color="#0a9f6d">
<van-dropdown-item
v-for="field in inlineDropdownFilters"
:key="field.key"
:key="`${field.source || 'all'}:${field.key}`"
:model-value="listFilters[field.key]"
:options="inlineFilterOptionsByKey[field.key] || []"
@change="(value) => onInlineFilterChange(field.key, value)"
/>
<van-dropdown-item
v-if="showProtocolProjectFilter"
:model-value="listFilters.projectId || ''"
:options="protocolProjectFilterOptions"
@change="(value) => onInlineFilterChange('projectId', value)"
/>
</van-dropdown-menu>
</div>
</div>
@@ -154,11 +161,15 @@
</template>
<script setup>
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
import { useRoute, useRouter } from 'vue-router'
import { fetchMarketFormOptions, fetchMarketList, performMarketAction } from '../../api/market.js'
import {
fetchMarketFormOptions,
fetchMarketList,
fetchProjectOptions,
performMarketAction
} from '../../api/market.js'
import { intersectAllowedActions } from '../../market/action-policy.js'
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
import {
@@ -169,6 +180,7 @@ import {
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
import { marketErrorMessage } from '../../market/market-error.js'
import { getMarketModule } from '../../market/module-registry.js'
import { projectOptions } from '../../market/project-assignment.js'
import {
getMarketRouteContext,
MARKET_MODULE_TITLES,
@@ -247,6 +259,12 @@ const inlineDropdownFilters = computed(() => {
const source = listFilters.value.source || listStatus.value
return rows.filter((field) => !field.source || field.source === source)
})
const showProtocolProjectFilter = computed(() => context.value.module === 'protocol')
const protocolProjectOptions = ref([])
const protocolProjectFilterOptions = computed(() => [
{ text: '全部项目', value: '' },
...protocolProjectOptions.value
])
const activeFilters = computed(() =>
activeFilterCount(context.value.module, listFilters.value)
)
@@ -299,6 +317,7 @@ const allDeletableSelected = computed(() =>
)
let requestSequence = 0
let abortController = null
let projectOptionsController = null
let searchTimer = null
function normalizeList(result) {
@@ -360,7 +379,32 @@ function queueSearch() {
searchTimer = window.setTimeout(search, 350)
}
async function loadProtocolProjectOptions() {
projectOptionsController?.abort()
if (!showProtocolProjectFilter.value) {
protocolProjectOptions.value = []
return
}
const current = new AbortController()
projectOptionsController = current
const bizType = listFilters.value.source || listStatus.value || 'cell_storage'
try {
const data = await fetchProjectOptions(bizType, {
signal: current.signal,
filter: true,
context: context.value
})
if (projectOptionsController !== current) return
protocolProjectOptions.value = projectOptions(data)
} catch (error) {
if (projectOptionsController === current && error?.name !== 'AbortError') {
protocolProjectOptions.value = []
}
}
}
async function loadListFilterOptions() {
await loadProtocolProjectOptions()
const optionKeys = [
...sheetFilterFields.value.map((field) => field.optionKey),
...inlineDropdownFilters.value.map((field) => field.optionKey)
@@ -596,6 +640,7 @@ function switchListStatus(value) {
tab: next
}
router.replace({ query: nextQuery }).catch(() => {})
loadProtocolProjectOptions()
loadPage(true)
}
@@ -644,6 +689,7 @@ onBeforeUnmount(() => {
const requestId = ++requestSequence
listState.value = invalidateMarketRequestState(listState.value, requestId)
abortController?.abort()
projectOptionsController?.abort()
window.clearTimeout(searchTimer)
})
</script>
+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,
@@ -20,7 +20,13 @@
v-for="option in displayOptions"
:key="option.id"
:value="option.id"
:label="team ? option.teamName : `${option.projectName} · ${option.teamName}`"
:label="
team
? option.teamName
: teamId
? option.projectName
: `${option.projectName} · ${option.teamName}`
"
:disabled="!filter && option.disabled"
/>
</el-select>
@@ -43,6 +49,8 @@
const props = defineProps<{
filter?: boolean
team?: boolean
/** When selecting projects, keep options under this team. */
teamId?: string
modelValue?: string
bizType?: BizType
disabled?: boolean
@@ -54,15 +62,18 @@
ready: [value: boolean]
}>()
const options = ref<ProjectOption[]>([])
const displayOptions = computed(() =>
props.team
? [
...new Map(
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
).values()
]
const displayOptions = computed(() => {
if (props.team) {
return [
...new Map(
options.value.map((option) => [option.teamId, { ...option, id: option.teamId }])
).values()
]
}
return props.teamId
? options.value.filter((option) => option.teamId === props.teamId)
: options.value
)
})
const loading = ref(false)
const error = ref(false),
retry = ref(0)
+10 -1
View File
@@ -22,13 +22,14 @@ export function assignmentSnapshot(records: ProjectRecord[]) {
}))
}
/** Write bodies intentionally exclude response-only labels, timestamps and generated numbers. */
/** Write bodies intentionally exclude response-only labels and timestamps. */
export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecord): ProjectRecord {
const fields =
kind === 'teams'
? ['teamName', 'leaderUserId', 'enabled', 'remark']
: [
'projectName',
'projectNo',
'teamId',
'leaderUserId',
'bizTypes',
@@ -58,3 +59,11 @@ export function managementPayload(kind: 'teams' | 'projects', form: ProjectRecor
export function projectOptionsParams(bizType: BizType, filter = false) {
return filter ? { bizType, usage: 'filter' as const } : { bizType }
}
/** List cells join child display names; empty entries are dropped. */
export function joinChildNames(items?: ProjectRecord[]) {
return (items || [])
.map((item) => item?.name)
.filter(Boolean)
.join('、')
}
@@ -26,6 +26,8 @@ describe('flattenProtocolAuditRows', () => {
id: 'p1',
protocolNo: 'ZKZX1',
motherName: '郭梦朵',
projectName: '测试项目',
teamName: '陈星项目组111',
sampleCollectTime: '2025-09-15 08:51:00',
sampleItems: [
{ sampleTypeName: '脐带血', sampleCode: 'A1', labStorageCode: 'B0000004' },
@@ -40,6 +42,25 @@ describe('flattenProtocolAuditRows', () => {
assert.equal(rows[0].labStorageCode, 'B0000004')
assert.equal(rows[1].labStorageCode, 'B0000004A')
assert.equal(rows[0].protocolId, 'p1')
assert.equal(rows[0].projectName, '测试项目')
assert.equal(rows[0].teamName, '陈星项目组111')
assert.equal(rows[1].projectName, '测试项目')
assert.equal(rows[1].teamName, '陈星项目组111')
})
it('copies protocol project attribution onto each flat row for table props', () => {
const rows = flattenProtocolAuditRows([
{
id: 'p-attr',
projectName: '归属项目',
teamName: '归属项目组',
sampleItems: [{ sampleCode: 'ZKZX34000199', sampleTypeName: '围产期造血干细胞' }]
}
])
assert.equal(rows.length, 1)
assert.equal(rows[0].projectName, '归属项目')
assert.equal(rows[0].teamName, '归属项目组')
assert.equal(rows[0].sampleCode, 'ZKZX34000199')
})
it('zero samples → one placeholder row, not co-stored', () => {
@@ -20,6 +20,9 @@ export type ProtocolAuditFlatRow = {
sampleCount: number
sampleIndex: number
isCoStored: boolean
/** 协议级项目归属,供扁平行表格 prop 绑定 */
projectName: string
teamName: string
collectDate: string
sampleCollectTime: string
motherName: string
@@ -95,6 +98,8 @@ export function flattenProtocolAuditRows(
sampleCount,
sampleIndex,
isCoStored,
projectName: str(protocol.projectName),
teamName: str(protocol.teamName),
collectDate: extractCollectDate(str(protocol.sampleCollectTime)),
sampleCollectTime: str(protocol.sampleCollectTime),
motherName: str(protocol.motherName),
+1 -1
View File
@@ -363,7 +363,7 @@ export const taskStatusTagType = createTagTypeGetter(taskStatus)
export const acceptConclusion: StatusRegistry = {
js: { label: '接收', tagType: 'success' },
bj: { label: '拒收', tagType: 'danger' }
bj: { label: '废弃', tagType: 'danger' }
}
export const acceptConclusionLabel = createLabelGetter(acceptConclusion)
export const acceptConclusionTagType = createTagTypeGetter(acceptConclusion)
+2 -1
View File
@@ -6,7 +6,8 @@ export const VOID_TYPE_DICT = 'xyzflx'
const FALLBACK_LABELS: Record<string, string> = {
khhc: '客户毁存',
jcbhg: '检测不合格'
jcbhg: '检测不合格',
sysfq: '实验室废弃'
}
function buildFallbackOptions(): Api.Dict.DictDataOption[] {
@@ -69,8 +69,7 @@
@selection-change="(rows: any[]) => (selectedRows = rows)"
@row-dblclick="handleEdit"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -79,6 +78,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="orderDate"
label="业务日期"
@@ -132,11 +132,12 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column label="序号" min-width="64" align="center" header-align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="collectDate"
label="采集日期"
@@ -144,11 +144,12 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleRowDblClick"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column type="selection" min-width="48" align="center" header-align="center" />
<el-table-column label="序号" min-width="64" align="center" header-align="center">
<template #default="{ $index }">{{ $index + 1 }}</template>
</el-table-column>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="collectDate"
label="采集日期"
@@ -1174,6 +1174,41 @@
</el-table>
</div>
</div>
<!-- 操作日志(Issue #24) -->
<div id="view-section-operation-log" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>操作日志</h3></div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="operationLogLoading"
class="list-table inner-table"
:data="operationLogRecords"
stripe
empty-text="暂无操作日志"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column label="动作" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="operationActionTagType(String(row.action || ''))" size="small">
{{ operationActionLabel(String(row.action || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源端" min-width="90" align="center" header-align="center">
<template #default="{ row }">{{ operationChannelLabel(String(row.clientChannel || '')) }}</template>
</el-table-column>
<el-table-column label="操作时间" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operateTime) }}</template>
</el-table-column>
<el-table-column label="操作人" min-width="120" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operatorUserName) }}</template>
</el-table-column>
<el-table-column label="变更摘要" min-width="260" align="left" header-align="center" show-overflow-tooltip>
<template #default="{ row }">{{ operationSummary(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
</div>
@@ -1226,6 +1261,7 @@
fetchCollectionProtocolAuditList,
fetchCollectionProtocolDetail,
fetchCollectionProtocolStatusChanges,
fetchCollectionProtocolOperationLogs,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import {
@@ -1317,6 +1353,7 @@
}
tabs.push({ key: 'attachment', label: '附件信息' })
tabs.push({ key: 'status-change', label: '状态变更记录' })
tabs.push({ key: 'operation-log', label: '操作日志' })
return tabs
})
@@ -1327,6 +1364,90 @@
const bundle = ref<Record<string, any> | null>(null)
const auditRecords = ref<Array<Record<string, unknown>>>([])
const statusChangeRecords = ref<Array<Record<string, unknown>>>([])
const operationLogLoading = ref(false)
const operationLogRecords = ref<Array<Record<string, unknown>>>([])
const OPERATION_ACTION_LABEL: Record<string, string> = {
create: '创建',
update: '修改',
delete: '删除',
void: '作废',
activate: '生效',
expire: '过期',
to_research: '转科研',
to_storage: '转细胞存储',
audit: '审核'
}
const OPERATION_ACTION_TAG: Record<string, 'success' | 'warning' | 'danger' | 'primary' | 'info'> = {
create: 'success',
update: 'primary',
delete: 'danger',
void: 'danger',
activate: 'success',
expire: 'warning',
to_research: 'primary',
to_storage: 'primary',
audit: 'info'
}
function operationActionLabel(action: string) {
return OPERATION_ACTION_LABEL[action] || action || '—'
}
function operationActionTagType(action: string) {
return OPERATION_ACTION_TAG[action] || 'info'
}
function operationChannelLabel(channel: string) {
if (channel === 'mini') return '小程序'
if (channel === 'web') return 'Web'
return channel || '—'
}
function operationSummary(row: Record<string, unknown>) {
const before = (row.before || {}) as Record<string, any>
const after = (row.after || {}) as Record<string, any>
const beforeProto = (before.protocol || before.donation || {}) as Record<string, any>
const afterProto = (after.protocol || after.donation || {}) as Record<string, any>
const beforeParty = (before.partyCustomer || before.party || {}) as Record<string, any>
const afterParty = (after.partyCustomer || after.party || {}) as Record<string, any>
const parts: string[] = []
const beforeStatus = String(beforeProto.protocolStatus || beforeProto.donationStatus || '')
const afterStatus = String(afterProto.protocolStatus || afterProto.donationStatus || '')
if (beforeStatus || afterStatus) {
if (beforeStatus && afterStatus && beforeStatus !== afterStatus) {
parts.push(`状态 ${beforeStatus}→${afterStatus}`)
} else if (afterStatus) {
parts.push(`状态 ${afterStatus}`)
}
}
for (const key of ['motherName', 'customerName', 'donorName', 'name']) {
const b = String(beforeParty[key] || '').trim()
const a = String(afterParty[key] || '').trim()
if (b || a) {
if (b && a && b !== a) parts.push(`姓名 ${b}→${a}`)
else if (a && !b) parts.push(`姓名 ${a}`)
break
}
}
const beforeSamples = Array.isArray(before.samples) ? before.samples : []
const afterSamples = Array.isArray(after.samples) ? after.samples : []
if (beforeSamples.length || afterSamples.length) {
const pick = (list: any[]) =>
list
.map((s) => `${s?.sampleCode || ''}:${s?.sampleStatus || s?.status || ''}`)
.filter((x) => x !== ':')
.join(',')
const bs = pick(beforeSamples)
const as = pick(afterSamples)
if (bs || as) {
if (bs && as && bs !== as) parts.push(`样本 ${bs}→${as}`)
else if (as) parts.push(`样本 ${as}`)
}
}
if (!parts.length) {
const remark = String(row.remark || '').trim()
return remark || '见前后快照'
}
return parts.join(';')
}
const STATUS_CHANGE_TYPE_LABEL: Record<string, string> = {
activate: '生效',
@@ -2088,6 +2209,22 @@
}
}
async function loadOperationLogRecords() {
if (!props.protocolId) {
operationLogRecords.value = []
return
}
operationLogLoading.value = true
try {
const res = await fetchCollectionProtocolOperationLogs(props.protocolId)
operationLogRecords.value = res.list || []
} catch {
operationLogRecords.value = []
} finally {
operationLogLoading.value = false
}
}
async function loadAuditRecords() {
if (!props.showAuditHistory || !props.protocolId) {
auditRecords.value = []
@@ -2115,10 +2252,12 @@
bundle.value = null
auditRecords.value = []
statusChangeRecords.value = []
operationLogRecords.value = []
const baseLookupsPromise = loadBaseLookups().catch(() => {})
const auditPromise = props.showAuditHistory ? loadAuditRecords() : null
const statusChangePromise = loadStatusChangeRecords()
const operationLogPromise = loadOperationLogRecords()
let data: Record<string, any> | null = null
try {
@@ -2136,6 +2275,7 @@
void finishLookups(data, baseLookupsPromise)
void auditPromise
void statusChangePromise
void operationLogPromise
if (props.highlightSampleCode) {
nextTick(() => {
document
@@ -2159,6 +2299,7 @@
bundle.value = null
auditRecords.value = []
statusChangeRecords.value = []
operationLogRecords.value = []
activeTab.value = 'basic'
detailLoading.value = false
lookupLoading.value = false
@@ -105,8 +105,7 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -115,6 +114,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="protocolStatus"
label="状态"
@@ -116,9 +116,7 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -127,6 +125,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="applicationNo"
label="申请单号"
@@ -74,6 +74,16 @@
<el-table-column v-if="!isTeam" prop="projectNo" label="项目编号" min-width="160" />
<el-table-column v-if="!isTeam" prop="teamName" label="所属项目组" min-width="160" />
<el-table-column prop="leaderName" label="负责人" min-width="120" />
<el-table-column v-if="isTeam" label="成员" min-width="180" show-overflow-tooltip
><template #default="{ row }">{{
joinChildNames(row.members)
}}</template></el-table-column
>
<el-table-column v-if="isTeam" label="负责医院" min-width="200" show-overflow-tooltip
><template #default="{ row }">{{
joinChildNames(row.hospitals)
}}</template></el-table-column
>
<el-table-column label="状态" min-width="110"
><template #default="{ row }"
><el-tag
@@ -139,12 +149,19 @@
v-else
v-model="form.projectName"
/></el-form-item>
<el-form-item
v-if="!isTeam"
label="项目编号"
prop="projectNo"
:rules="[{ required: true, message: '请输入项目编号' }]"
><el-input v-model="form.projectNo" maxlength="64" show-word-limit
/></el-form-item>
<el-form-item
v-if="!isTeam"
label="所属项目组"
prop="teamId"
:rules="[{ required: true, message: '请选择项目组' }]"
><el-select v-model="form.teamId" filterable
><el-select v-model="form.teamId" filterable @change="onTeamChange"
><el-option
v-for="team in teams"
:key="team.id"
@@ -243,7 +260,7 @@
>
</template>
<script setup lang="ts">
import { managementPayload } from '@/utils/project-management'
import { joinChildNames, managementPayload } from '@/utils/project-management'
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
import {
Plus,
@@ -333,6 +350,11 @@
})
search()
}
function onTeamChange(teamId: string) {
if (readonly.value || isTeam.value) return
const team = teams.value.find((item) => item.id === teamId)
if (team?.leaderUserId) form.value.leaderUserId = team.leaderUserId
}
async function edit(row?: ProjectRecord, view = false) {
readonly.value = view
editingId.value = row?.id || ''
@@ -341,6 +363,7 @@
: {
teamName: '',
projectName: '',
projectNo: '',
leaderUserId: '',
enabled: true,
status: 'pending',
@@ -143,57 +143,58 @@
<el-dialog
v-model="visible"
title="项目归属维护"
width="720px"
width="560px"
class="dialog-form-layout"
append-to-body
modal-class="protocol-dialog-mask"
:close-on-click-modal="false"
>
<el-form label-position="top"
><el-form-item label="目标项目" required
><ProjectSelect
v-model="target"
:key="selected.id"
<el-form class="form-grid-2" label-position="top">
<el-form-item label="原所属项目组">
<el-input :model-value="selected.teamName || '未归属'" disabled />
</el-form-item>
<el-form-item label="原所属项目">
<el-input :model-value="selected.projectName || '未归属'" disabled />
</el-form-item>
<el-form-item label="新项目组" required>
<ProjectSelect
:key="`team-${selected.id}`"
team
v-model="targetTeamId"
:biz-type="selected.bizType"
@update:model-value="previewRows = []" /></el-form-item
><el-form-item label="调整原因" required
><el-input v-model="reason" type="textarea" :maxlength="500" /></el-form-item
></el-form>
<el-alert
title="请先预览全部关联单据,再确认调整。关联集合或归属发生变化时需要重新预览。"
type="info"
:closable="false"
/>
<el-table :data="previewRows" class="list-table" stripe
><el-table-column
type="index"
label="序号"
min-width="68"
align="center"
header-align="center" />
<el-table-column prop="bizNo" label="关联单据" min-width="200" /><el-table-column
prop="projectName"
label="原项目"
min-width="160"
/></el-table>
<template #footer
><el-button @click="visible = false"
@update:model-value="onTargetTeamChange"
/>
</el-form-item>
<el-form-item label="新项目" required>
<ProjectSelect
:key="`project-${selected.id}-${targetTeamId}`"
v-model="target"
:team-id="targetTeamId"
:biz-type="selected.bizType"
:disabled="!targetTeamId"
/>
</el-form-item>
<el-form-item label="调整原因" required class="form-col-span-2">
<el-input v-model="reason" type="textarea" :maxlength="500" show-word-limit />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="visible = false"
><el-icon><Close /></el-icon>取消</el-button
><el-button :loading="busy" @click="preview"
><el-icon><View /></el-icon>预览关联单据</el-button
><el-button
>
<el-button
type="primary"
:disabled="!previewRows.length || !reason.trim()"
:disabled="!target || !reason.trim()"
:loading="busy"
@click="commit"
><el-icon><Check /></el-icon>确认调整</el-button
></template
>
>
</template>
</el-dialog>
<el-dialog
v-model="historyVisible"
title="归属审计记录"
width="800px"
width="1100px"
append-to-body
class="dialog-form-layout"
><el-table :data="historyRows" class="list-table" stripe
@@ -233,7 +234,7 @@
presignOrderStatusTagType,
applicationStatusTagType
} from '@/utils/status-tag-registry'
import { Search, Edit, Clock, Close, View, Check, RefreshRight } from '@element-plus/icons-vue'
import { Search, Edit, Clock, Close, Check, RefreshRight } from '@element-plus/icons-vue'
import { assignmentTabs, assignmentColumns, assignmentKeywordHints } from './assignment-columns'
import ProjectSelect from '@/components/business/ProjectSelect.vue'
import { assignmentSnapshot } from '@/utils/project-management'
@@ -268,10 +269,10 @@
loadError = ref(false),
visible = ref(false),
busy = ref(false),
targetTeamId = ref(''),
target = ref(''),
reason = ref(''),
selected = ref<ProjectRecord>({}),
previewRows = ref<ProjectRecord[]>([]),
historyVisible = ref(false),
historyRows = ref<ProjectRecord[]>([])
const visibleColumns = computed(() =>
@@ -361,47 +362,54 @@
}
function open(row: ProjectRecord) {
selected.value = row
targetTeamId.value = ''
target.value = ''
reason.value = ''
previewRows.value = []
visible.value = true
}
async function preview() {
function onTargetTeamChange() {
target.value = ''
}
async function commit() {
if (!targetTeamId.value) {
ElMessage.warning('请选择新项目组')
return
}
if (!target.value) {
ElMessage.warning('请选择目标项目')
ElMessage.warning('请选择新项目')
return
}
if (!reason.value.trim()) {
ElMessage.warning('请填写调整原因')
return
}
busy.value = true
const previewTarget = target.value
const previewRecord = selected.value
const commitTarget = target.value
const commitRecord = selected.value
try {
const result = await previewAssignment({
const preview = await previewAssignment({
bizType: selected.value.bizType,
id: selected.value.id,
projectId: target.value
})
if (selected.value === previewRecord && target.value === previewTarget && visible.value) {
previewRows.value = result.records
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
return
}
} finally {
busy.value = false
}
}
async function commit() {
busy.value = true
try {
await saveAssignment({
bizType: selected.value.bizType,
id: selected.value.id,
projectId: target.value,
reason: reason.value.trim(),
expectedAssignments: assignmentSnapshot(previewRows.value)
expectedAssignments: assignmentSnapshot(preview.records)
})
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
return
}
visible.value = false
ElMessage.success('归属已调整')
await load()
} catch {
previewRows.value = []
// API layer already surfaces the conflict / validation message.
} finally {
busy.value = false
}
@@ -576,6 +576,10 @@
ElMessage.warning(`${label}:请指定负责人`)
return false
}
if (!(b.labItemIds?.length || 0) && !(b.thirdPartyItemIds?.length || 0)) {
ElMessage.warning(`${label}:请至少选择一个检测项目`)
return false
}
const labSet = new Set(b.labItemIds)
if (labSet.size !== b.labItemIds.length) {
ElMessage.warning(`${label}:实验室项目不能重复`)
@@ -290,7 +290,7 @@
@change="onReceiveAcceptConclusionChange"
>
<el-radio value="js">接收</el-radio>
<el-radio value="bj">拒收</el-radio>
<el-radio value="bj">废弃</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="实验室入库编码" prop="labStorageCode">
@@ -643,7 +643,7 @@
@change="() => onBatchAcceptConclusionChange(entry)"
>
<el-radio value="js">接收</el-radio>
<el-radio value="bj">拒收</el-radio>
<el-radio value="bj">废弃</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item
@@ -482,6 +482,41 @@
</el-table>
</div>
</div>
<!-- 操作日志(Issue #24) -->
<div id="view-section-operation-log" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>操作日志</h3></div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="operationLogLoading"
class="list-table inner-table"
:data="operationLogRecords"
stripe
empty-text="暂无操作日志"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column label="动作" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="operationActionTagType(String(row.action || ''))" size="small">
{{ operationActionLabel(String(row.action || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源端" min-width="90" align="center" header-align="center">
<template #default="{ row }">{{ operationChannelLabel(String(row.clientChannel || '')) }}</template>
</el-table-column>
<el-table-column label="操作时间" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operateTime) }}</template>
</el-table-column>
<el-table-column label="操作人" min-width="120" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operatorUserName) }}</template>
</el-table-column>
<el-table-column label="变更摘要" min-width="260" align="left" header-align="center" show-overflow-tooltip>
<template #default="{ row }">{{ operationSummary(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
</div>
@@ -532,6 +567,7 @@
import SampleStorageReleaseInfo from '@/components/business/sample-storage-release-info/index.vue'
import {
fetchDonationStatusChanges,
fetchDonationOperationLogs,
fetchResearchDonationAuditList,
fetchResearchDonationDetail,
type ResearchDonationRow
@@ -593,16 +629,79 @@
tabs.push({ key: 'audit', label: '审核记录' })
}
tabs.push({ key: 'attachment', label: '附件信息' })
tabs.push({ key: 'status-change', label: '状态变更记录' })
tabs.push({ key: 'status-change', label: '状态变更记录' }, { key: 'operation-log', label: '操作日志' })
return tabs
})
const loading = ref(false)
const auditLoading = ref(false)
const statusChangeLoading = ref(false)
const operationLogLoading = ref(false)
const bundle = ref<Record<string, any> | null>(null)
const auditRecords = ref<Array<Record<string, unknown>>>([])
const statusChangeRecords = ref<Array<Record<string, unknown>>>([])
const operationLogRecords = ref<Array<Record<string, unknown>>>([])
const OPERATION_ACTION_LABEL: Record<string, string> = {
create: '创建',
update: '修改',
delete: '删除',
void: '作废',
activate: '生效',
expire: '过期',
to_research: '转科研',
to_storage: '转细胞存储',
audit: '审核'
}
const OPERATION_ACTION_TAG: Record<
string,
'success' | 'warning' | 'danger' | 'primary' | 'info'
> = {
create: 'success',
update: 'primary',
delete: 'danger',
void: 'danger',
activate: 'success',
expire: 'warning',
to_research: 'primary',
to_storage: 'primary',
audit: 'info'
}
function operationActionLabel(action: string) {
return OPERATION_ACTION_LABEL[action] || action || '—'
}
function operationActionTagType(action: string) {
return OPERATION_ACTION_TAG[action] || 'info'
}
function operationChannelLabel(channel: string) {
if (channel === 'mini') return '小程序'
if (channel === 'web') return 'Web'
return channel || '—'
}
function operationSummary(row: Record<string, unknown>) {
const before = (row.before || {}) as Record<string, any>
const after = (row.after || {}) as Record<string, any>
const beforeProto = (before.donation || before.protocol || {}) as Record<string, any>
const afterProto = (after.donation || after.protocol || {}) as Record<string, any>
const beforeParty = (before.party || before.partyCustomer || {}) as Record<string, any>
const afterParty = (after.party || after.partyCustomer || {}) as Record<string, any>
const parts: string[] = []
const bs = String(beforeProto.donationStatus || beforeProto.protocolStatus || '')
const as = String(afterProto.donationStatus || afterProto.protocolStatus || '')
if (bs && as && bs !== as) parts.push(`状态 ${bs}→${as}`)
else if (as) parts.push(`状态 ${as}`)
for (const key of ['motherName', 'customerName', 'donorName', 'name']) {
const b = String(beforeParty[key] || '').trim()
const a = String(afterParty[key] || '').trim()
if (b || a) {
if (b && a && b !== a) parts.push(`姓名 ${b}→${a}`)
else if (a && !b) parts.push(`姓名 ${a}`)
break
}
}
return parts.join(';') || String(row.remark || '').trim() || '见前后快照'
}
const STATUS_CHANGE_TYPE_LABEL: Record<string, string> = {
activate: '生效',
@@ -959,6 +1058,22 @@
}
}
async function loadOperationLogRecords() {
if (!props.donationId) {
operationLogRecords.value = []
return
}
operationLogLoading.value = true
try {
const res = await fetchDonationOperationLogs(props.donationId)
operationLogRecords.value = res.list || []
} catch {
operationLogRecords.value = []
} finally {
operationLogLoading.value = false
}
}
async function loadAuditRecords() {
if (!props.showAuditHistory || !props.donationId) {
auditRecords.value = []
@@ -985,12 +1100,17 @@
bundle.value = null
auditRecords.value = []
statusChangeRecords.value = []
operationLogRecords.value = []
void loadVoidTypeDict()
try {
const data = await fetchResearchDonationDetail(props.donationId)
bundle.value = data as Record<string, any>
await loadLookups(data as Record<string, any>)
await Promise.all([loadAuditRecords(), loadStatusChangeRecords()])
await Promise.all([
loadAuditRecords(),
loadStatusChangeRecords(),
loadOperationLogRecords()
])
if (props.highlightSampleCode) {
nextTick(() => {
document
@@ -1017,10 +1137,12 @@
bundle.value = null
auditRecords.value = []
statusChangeRecords.value = []
operationLogRecords.value = []
activeTab.value = 'basic'
loading.value = false
auditLoading.value = false
statusChangeLoading.value = false
operationLogLoading.value = false
}
watch(
@@ -93,8 +93,7 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -103,6 +102,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="donationStatus"
label="状态"
+11 -1
View File
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { buildDrillFilters, assignmentSnapshot } from '../src/utils/project-management'
import {
buildDrillFilters,
assignmentSnapshot,
joinChildNames
} from '../src/utils/project-management'
test('drill preserves applied dates and selected project when a team is opened', () => {
assert.deepEqual(
@@ -65,6 +69,12 @@ test('management payload excludes readonly response fields and preserves child I
)
})
test('joinChildNames joins display names and skips blanks', () => {
assert.equal(joinChildNames([{ name: '甲' }, { name: '' }, { name: '乙' }, {}]), '甲、乙')
assert.equal(joinChildNames([]), '')
assert.equal(joinChildNames(undefined), '')
})
test('filter options use visible historical scope; create and reassignment remain strict', async () => {
const { projectOptionsParams } = await import('../src/utils/project-management')
assert.deepEqual(projectOptionsParams('cell_storage', true), {