Compare commits

..
Author SHA1 Message Date
zlwuandCursor 9f37181478 fix(web-admin): 样本接收弹窗不展示协议附件项
验收要求仅展示知情同意书与孕妇健康调查表,移除第三项「协议附件」分组。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 23:00:37 +08:00
cykj 00635740f1 Merge pull request 'fix(web-admin): 修复样本接收弹窗协议附件不展示' (#64) from codex/issue-43-reception-atts-fix-859e92e16ac2 into V2.0
fix(web-admin): 修复样本接收弹窗协议附件不展示

Merge PR #64 for issue #43
2026-11-05 14:43:28 +00:00
zlwuandCursor 76316fad7a fix(web-admin): 修复样本接收弹窗协议附件不展示
弹窗改用与列表一致的 MiniReadonly 缩略图,并统一 attachmentFullUrl 解析 /minio 路径;编辑时合并详情附件字段,分组顺序优先知情同意书与调查表。

关联 #43

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 22:42:47 +08:00
cykj d4617b4988 Merge pull request 'fix(web-admin): 样本接收展示协议知情同意书等附件' (#63) from codex/issue-43-sample-reception-atts-f8ee4652859e into V2.0
fix(web-admin): 样本接收展示协议知情同意书等附件

Merge PR #63 for issue #43
2026-11-05 13:56:45 +00:00
zlwuandCursor c40feab76a fix(web-admin): 样本接收展示协议知情同意书等附件
质检样本接收列表与弹窗需查看采集协议侧附件。后端待接收/已接收列表补齐协议附件字段;前端列表增加知情同意书缩略图,接收弹窗右侧改为上运输、下协议附件分组展示。

关联 #43

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 21:53:56 +08:00
cykj 9cf1110495 fix(mobile): 协议单表单项目归属移入协议信息 (#61)
fix(mobile): 协议单表单项目归属移入协议信息 (#61)

Merged from PR #62
2026-11-05 09:59:44 +00:00
zlwuandCursor fefef41524 fix(mobile): 协议单表单将项目归属移入协议信息分区
按专员/经理协议单诉求,把项目归属嵌入协议信息卡片并置于协议状态上方,避免独立成卡打断填写顺序。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 17:57:57 +08:00
cykj e97f47b06a fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选 (#59)
fix(mobile): 协议单列表项目筛选下移并补齐科研捐赠筛选 (#59)
2026-11-05 09:14:29 +00:00
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 af730ad050 Merge remote-tracking branch 'origin/V2.0' into codex/issue-33-team-dialog 2026-11-05 10:48:32 +08:00
zlwu 2e21884d65 Merge remote-tracking branch 'origin/V2.0' into V2.0 2026-11-05 10:40:34 +08:00
cykj 6292badf8e Merge pull request 'fix(oa): 审批单 PDF 补齐评论、提及及附件链接' (#35) from codex/issue-30-oa-print-comments into V2.0
User explicitly requested merging issue #30 before user testing. Acceptance remains pending; keep issue open.
2026-11-05 02:40:21 +00:00
zlwu 5a55e9f574 fix(web): widen project team edit dialogs and give multi-select fields full rows 2026-11-05 10:35:08 +08:00
zlwu a511004812 fix(oa): include discussion comments and attachment links in approval PDFs
Merge discussion comments into the existing printed approval flow on Web and mobile OA, preserving detail timeline order and hiding revoked content. Include mentions and absolute attachment URLs, and add PDF link annotations so rasterized exports retain usable links. Add regression tests for ordering, attachments, mentions and revoked comments. Related to Gitea issue #30.
2026-11-05 10:27:39 +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
cykj 7ac2ff282c Merge pull request 'feat(project): add a standalone presign statistics column' (#22) from codex/issue-13-project-statistics into V2.0 2026-11-04 06:55:22 +00:00
74 changed files with 3942 additions and 276 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
+24
View File
@@ -287,9 +287,15 @@ def _donation_rows_to_sample_list_items(
),
"sourceType": "research_donation",
"sourceTypeLabel": "科研捐赠",
"consentAttachmentFiles": urls_to_attachment_list(
donation.consent_form_urls if donation else None, db, tenant_no
),
"infectiousDiseaseReportAttachmentFiles": urls_to_attachment_list(
donation.infectious_disease_report_urls if donation else None, db, tenant_no
),
"attachmentFiles": urls_to_attachment_list(
donation.attachment_urls if donation else None, db, tenant_no
),
"etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
"transportType": (transport.transport_type if transport else "") or "",
"waybillNo": (transport.waybill_no if transport else "") or "",
@@ -462,6 +468,12 @@ def _rows_to_sample_list_items(
**sample_special_to_dict(sample),
"sourceType": "cell_storage",
"sourceTypeLabel": "细胞存储",
"protocolAttachmentFiles": urls_to_attachment_list(
protocol.protocol_attachments if protocol else None, db, tenant_no
),
"consentAttachmentFiles": urls_to_attachment_list(
protocol.consent_form_urls if protocol else None, db, tenant_no
),
"healthSurveyAttachmentFiles": urls_to_attachment_list(
protocol.health_survey_form_urls if protocol else None, db, tenant_no
),
@@ -1033,6 +1045,12 @@ def create_sample_reception(
user_id=user_id,
create_dept_id=create_dept_id or "",
)
if conclusion == "bj":
from app.services.protocol_reception_discard_sync import after_sample_reception_saved
after_sample_reception_saved(
db, reception=obj, tenant_no=tenant_no, user_id=user_id
)
db.commit()
db.refresh(obj)
return obj
@@ -1151,6 +1169,12 @@ def update_sample_reception(
)
check_result_svc.sync_legacy_columns_from_results(db, rec, saved_rows)
db.add(rec)
if conclusion == "bj":
from app.services.protocol_reception_discard_sync import after_sample_reception_saved
after_sample_reception_saved(
db, reception=rec, tenant_no=tenant_no, user_id=user_id
)
db.commit()
db.refresh(rec)
return rec
+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()
@@ -0,0 +1,129 @@
"""样本接收列表项应携带协议侧附件字段,供质检查看。"""
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
class _FakeQuery:
def filter(self, *args, **kwargs):
return self
def all(self):
return []
def first(self):
return None
class _FakeDb:
def query(self, *args, **kwargs):
return _FakeQuery()
class TestSampleReceptionProtocolAttachments(unittest.TestCase):
def test_cell_storage_item_includes_protocol_consent_health_files(self):
from app.crud import sample_reception as svc
protocol = SimpleNamespace(
id="proto-1",
protocol_no="P1",
protocol_status="effective",
protocol_attachments="http://a/p.pdf",
consent_form_urls="http://a/c.pdf",
health_survey_form_urls="http://a/h.pdf",
)
sample = SimpleNamespace(
id="sample-1",
sample_code="S001",
sample_type_id="t1",
collect_amount=None,
sample_status="",
)
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
transport = SimpleNamespace(
eta_time=None,
transport_type="",
waybill_no="",
express_company_id="",
carrier_name="",
carrier_phone="",
)
with patch.object(
svc,
"urls_to_attachment_list",
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
), patch(
"app.services.qc_work_order_fee_summary.build_protocol_payment_summaries",
return_value={},
):
items = svc._rows_to_sample_list_items(
_FakeDb(),
[(sample, protocol, party, transport)],
"t1",
reception_map={},
)
self.assertEqual(len(items), 1)
item = items[0]
self.assertEqual(item["protocolAttachmentFiles"][0]["fileUrl"], "http://a/p.pdf")
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
self.assertEqual(item["healthSurveyAttachmentFiles"][0]["fileUrl"], "http://a/h.pdf")
def test_donation_item_includes_consent_infectious_other_files(self):
from app.crud import sample_reception as svc
donation = SimpleNamespace(
id="don-1",
donation_no="D1",
donation_status="effective",
consent_form_urls="http://a/c.pdf",
infectious_disease_report_urls="http://a/i.pdf",
attachment_urls="http://a/o.pdf",
)
sample = SimpleNamespace(
id="ds-1",
sample_code="D001",
sample_type_id="t1",
collect_amount=None,
)
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
transport = SimpleNamespace(
eta_time=None,
transport_type="",
waybill_no="",
express_company_id="",
carrier_name="",
carrier_phone="",
)
with patch.object(
svc,
"urls_to_attachment_list",
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
):
items = svc._donation_rows_to_sample_list_items(
_FakeDb(),
[(sample, donation, party, transport)],
"t1",
reception_map={},
)
self.assertEqual(len(items), 1)
item = items[0]
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
self.assertEqual(
item["infectiousDiseaseReportAttachmentFiles"][0]["fileUrl"], "http://a/i.pdf"
)
self.assertEqual(item["attachmentFiles"][0]["fileUrl"], "http://a/o.pdf")
if __name__ == "__main__":
unittest.main()
+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`。
@@ -28,25 +28,27 @@
class="market-form-fields"
:class="{ 'market-form-fields--grid-2': section.fieldsLayout === 'grid-2' }"
>
<MarketFormField
v-for="field in visibleFields"
:key="field.key"
:field="field"
:model-value="value?.[field.key]"
:options="fieldOptions(field)"
:uploading="uploading"
:disabled="fieldDisabled(field)"
:search-customers="searchCustomers"
@update:model-value="setValue(field, $event)"
@upload="$emit('upload', { section, field, file: $event })"
@customer-select="$emit('customer-select', { section, ...$event })"
/>
<template v-for="item in fieldsWithInsert" :key="item.field.key">
<slot v-if="item.insertBefore" name="insert" />
<MarketFormField
:field="item.field"
:model-value="value?.[item.field.key]"
:options="fieldOptions(item.field)"
:uploading="uploading"
:disabled="fieldDisabled(item.field)"
:search-customers="searchCustomers"
@update:model-value="setValue(item.field, $event)"
@upload="$emit('upload', { section, field: item.field, file: $event })"
@customer-select="$emit('customer-select', { section, ...$event })"
/>
</template>
<slot v-if="appendInsert" name="insert" />
</div>
</section>
</template>
<script setup>
import { computed } from 'vue'
import { computed, useSlots } from 'vue'
import MarketFormField from './MarketFormField.vue'
import { sampleCodeOptionsForRow } from '../reference-options.js'
@@ -58,9 +60,12 @@ const props = defineProps({
mode: { type: String, default: 'create' },
uploading: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
searchCustomers: { type: Function, default: null }
searchCustomers: { type: Function, default: null },
/** 在首个匹配可见字段前插入 #insert 插槽(如协议状态下的项目归属) */
insertBeforeKeys: { type: Array, default: () => [] }
})
const emit = defineEmits(['update:modelValue', 'upload', 'field-change', 'customer-select'])
const slots = useSlots()
const value = computed(() => props.modelValue || {})
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
const visibleFields = computed(() => (props.section.fields || []).filter(
@@ -71,6 +76,19 @@ const visibleFields = computed(() => (props.section.fields || []).filter(
!(typeof field.showWhen === 'function' && field.showWhen(value.value) === false) &&
!(field.createOnly && props.mode !== 'create')
))
const fieldsWithInsert = computed(() => {
const keys = props.insertBeforeKeys || []
let inserted = false
return visibleFields.value.map((field) => {
const insertBefore = Boolean(slots.insert) && !inserted && keys.includes(field.key)
if (insertBefore) inserted = true
return { field, insertBefore }
})
})
const appendInsert = computed(() => {
if (!slots.insert || !(props.insertBeforeKeys || []).length) return false
return !fieldsWithInsert.value.some((item) => item.insertBefore)
})
function fieldOptions(field, rowIndex = -1) {
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
@@ -1,27 +1,49 @@
<template>
<section class="market-form-section">
<h2>{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
<component :is="embedded ? 'div' : 'section'" :class="rootClass">
<h2 v-if="!embedded">{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
<label v-else :id="labelId">所属项目<b v-if="required && !editing && !filter">*</b></label>
<template v-if="editing">
<van-cell title="所属项目" :value="projectLabel(record)" />
<van-cell title="项目组" :value="record.teamName || '—'" />
<van-cell label="归属调整请使用 Web 项目归属维护" />
<template v-if="embedded">
<div class="market-form-field__control market-form-field__control--text" :aria-labelledby="labelId">
{{ projectLabel(record) }}
</div>
<p class="market-form-field__tip">项目组:{{ record.teamName || '—' }};归属调整请使用 Web 项目归属维护</p>
</template>
<template v-else>
<van-cell title="所属项目" :value="projectLabel(record)" />
<van-cell title="项目组" :value="record.teamName || '—'" />
<van-cell label="归属调整请使用 Web 项目归属维护" />
</template>
</template>
<template v-else>
<PresignSelectField
:model-value="modelValue || ''"
:options="selectOptions"
:disabled="disabled || loading || Boolean(error)"
:loading="loading"
searchable
title="所属项目"
:placeholder="filter ? '全部项目' : '请选择所属项目'"
@update:model-value="emit('update:modelValue', $event)"
/>
<van-cell v-if="loading" title="正在加载项目…" />
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
<div :class="embedded ? 'market-form-field__control' : undefined">
<PresignSelectField
:model-value="modelValue || ''"
:options="selectOptions"
:disabled="disabled || loading || Boolean(error)"
:loading="loading"
searchable
title="所属项目"
:placeholder="filter ? '全部项目' : '请选择所属项目'"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
<template v-if="embedded">
<p v-if="loading" class="market-form-field__tip">正在加载项目…</p>
<button v-else-if="error" type="button" class="market-form-field__tip market-form-field__tip--action" @click="load">
项目加载失败,点击重试({{ error }})
</button>
<p v-else-if="!options.length" class="market-form-field__tip">
{{ required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目' }}
</p>
</template>
<template v-else>
<van-cell v-if="loading" title="正在加载项目…" />
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
</template>
</template>
</section>
</component>
</template>
<script setup>
@@ -35,6 +57,7 @@ const props = defineProps({
bizType: { type: String, required: true },
editing: { type: Boolean, default: false },
filter: { type: Boolean, default: false },
embedded: { type: Boolean, default: false },
context: { type: Object, default: null },
disabled: { type: Boolean, default: false },
record: { type: Object, default: () => ({}) }
@@ -44,7 +67,9 @@ const options = ref([])
const required = ref(false)
const loading = ref(true)
const error = ref('')
const labelId = `project-assignment-${Math.random().toString(36).slice(2, 9)}`
let controller
const rootClass = computed(() => (props.embedded ? 'market-form-field' : 'market-form-section'))
const selectOptions = computed(() => [
...(!required.value || props.filter ? [{ text: props.filter ? '全部项目' : '暂不归属', value: '' }] : []),
...options.value
+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,25 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
assert.doesNotMatch(filters, /createTimeRange/)
assert.match(genericList, /inlineDropdownFilters/)
assert.match(genericList, /market-list__filter-row/)
assert.match(genericList, /showProtocolProjectFilter/)
assert.match(genericList, /全部项目/)
assert.doesNotMatch(genericList, /ProjectAssignmentField/)
})
test('protocol form embeds project assignment inside protocol info before status fields', () => {
const form = source('../views/market/MarketModuleForm.vue')
const formSection = source('./components/MarketFormSection.vue')
const projectField = source('./components/ProjectAssignmentField.vue')
assert.match(form, /insert-before-keys="\['protocolStatus', 'donationStatus'\]"/)
assert.match(form, /#insert/)
assert.match(form, /embedded/)
assert.match(form, /<ProjectAssignmentField[\s\S]*?embedded/)
assert.doesNotMatch(form, /ProjectAssignmentField v-if="context\.module === 'protocol'"/)
assert.match(formSection, /insertBeforeKeys/)
assert.match(formSection, /name="insert"/)
assert.match(projectField, /embedded/)
assert.match(projectField, /market-form-field/)
})
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
@@ -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), [
@@ -1,3 +1,4 @@
import { attachmentFullUrl } from '../../utils/attachment.js'
/**
* OA 审批单移动端打印(S6)
* 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;支持 html2canvas + jsPDF 导出。
@@ -66,7 +67,6 @@ function escapeHtml(s) {
.replace(/"/g, '&quot;')
}
function formatDateOnly(createTime) {
const raw = String(createTime || '').trim()
if (!raw) return '-'
@@ -99,10 +99,12 @@ function formatScalar(value, sep = '、') {
return String(value)
}
if (Array.isArray(value)) {
return value
.map((x) => formatScalar(x, sep))
.filter((x) => x && x !== '-')
.join(sep) || '-'
return (
value
.map((x) => formatScalar(x, sep))
.filter((x) => x && x !== '-')
.join(sep) || '-'
)
}
if (typeof value === 'object') {
if (value.name != null) return String(value.name)
@@ -397,7 +399,13 @@ export function buildOaPrintModel(detail, opts = {}) {
stampClass,
stampText,
rows: [...headerRows, ...buildFormRows(detail)],
flowItems: buildFlowItems(detail),
flowItems: buildFlowItems(detail, (url) => {
try {
return new URL(attachmentFullUrl(url), window.location.origin).href
} catch {
return ''
}
}),
printTime,
printerName,
watermarkName: printerName === '-' ? '审批' : printerName
@@ -435,6 +443,9 @@ function flowCellHtml(f) {
} else if (f.comment) {
html += `<div class="oa-print-flow__comment">意见:${escapeHtml(f.comment)}</div>`
}
for (const url of f.attachmentLinks || []) {
html += `<div class="oa-print-url"><a data-oa-print-link href="${escapeHtml(url)}">${escapeHtml(url)}</a></div>`
}
return html
}
@@ -472,9 +483,10 @@ function buildPrintStylesContent() {
line-height: 1.5; word-break: break-all; }
.oa-print-table th { width: 118px; font-weight: 400; text-align: left; background: #fff; }
.oa-print-text { white-space: pre-wrap; }
.oa-print-url a { color: inherit; }
.oa-print-url { color: #3370ff; word-break: break-all; }
.oa-print-flow__row { display: flex; justify-content: space-between; gap: 12px; }
.oa-print-flow__left { flex: 1; min-width: 0; }
.oa-print-flow__left { white-space: pre-wrap; overflow-wrap: anywhere; flex: 1; min-width: 0; }
.oa-print-flow__right { flex: 0 0 auto; color: #646464; white-space: nowrap; }
.oa-print-flow__person-lines { margin-top: 4px; padding-left: 8px; }
.oa-print-flow__person-line { margin-top: 2px; font-size: 12px; }
@@ -602,7 +614,7 @@ function renderPageLabelCanvas(text) {
return c
}
async function canvasToPdfBlob(canvas) {
async function canvasToPdfBlob(canvas, sheet) {
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
@@ -620,6 +632,23 @@ async function canvasToPdfBlob(canvas) {
const position = -pageIndex * pageHeight
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
// html2canvas 只保留像素,单独把附件链接写为 PDF 注解;跨页部分按页裁切。
const sheetRect = sheet.getBoundingClientRect()
const ratio = pageWidth / sheetRect.width
for (const link of sheet.querySelectorAll('a[data-oa-print-link]')) {
if (!/^https?:\/\//i.test(link.href)) continue
for (const rect of link.getClientRects()) {
const top = (rect.top - sheetRect.top) * ratio - pageIndex * pageHeight
const bottom = Math.min(pageHeight, top + rect.height * ratio)
const y = Math.max(0, top)
if (bottom > y) {
pdf.link((rect.left - sheetRect.left) * ratio, y, rect.width * ratio, bottom - y, {
url: link.href
})
}
}
}
const label = `第${pageIndex + 1}页`
const labelCanvas = renderPageLabelCanvas(label)
if (labelCanvas.width > 1) {
@@ -664,7 +693,7 @@ export async function generateOaApprovalPdfBlob(detail, opts = {}) {
windowWidth: el.scrollWidth,
windowHeight: el.scrollHeight
})
const blob = await canvasToPdfBlob(canvas)
const blob = await canvasToPdfBlob(canvas, el)
return { blob, filename }
} finally {
host.remove()
@@ -0,0 +1,95 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildFlowItems } from './oa-print-flow.js'
describe('打印讨论评论', () => {
it('按时间展示正文、提及和附件,并排在待审节点前', () => {
const items = buildFlowItems({
progress: [
{
kind: 'submit',
title: '发起人',
status: 'submitted',
timestamp: '2026-11-01 10:00:00'
},
{
kind: 'pending_node',
title: '审批人',
status: 'pending',
timestamp: '2026-11-01 10:00:00'
}
],
comments: [
{
id: '2',
createByName: '李四',
createTime: '2026-11-01 12:00:00',
content: '',
attachmentRefs: [{ url: 'https://example.com/file.pdf', fileName: '附件.pdf' }]
},
{
id: '1',
createByName: '张三',
createTime: '2026-11-01 11:00:00',
content: '@李四 请处理\n第二行',
mentions: [{ userId: 'u2', userName: '李四' }]
}
]
})
assert.equal(items.length, 4)
assert.equal(items[1].left, '【评论】 张三')
assert.equal(items[1].right, '2026-11-01 11:00:00')
assert.deepEqual(items[1].personLines, [
{ left: '@李四 请处理\n第二行', right: '' },
{ left: '提到:@李四', right: '' }
])
assert.deepEqual(items[2].attachmentLinks, ['https://example.com/file.pdf'])
assert.match(items[3].left, /待处理/)
})
it('撤回评论不泄露正文、提及和附件', () => {
const [item] = buildFlowItems({
comments: [
{
id: '1',
createBy: 'u1',
status: 'revoked',
content: 'secret',
mentions: [{ userName: 'secret' }],
attachmentRefs: [{ url: 'https://example.com/secret' }]
}
]
})
assert.equal(item.left, '【评论】 u1 已撤回')
assert.equal(JSON.stringify(item).includes('secret'), false)
})
it('安全过滤附件协议,保留纯附件与纯提及评论', () => {
const items = buildFlowItems({
comments: [
{
id: '1',
mentions: [{ userId: 'u1' }],
attachmentRefs: [{ url: 'javascript:alert(1)' }, { url: 'https://example.com/a?x=1&y=2' }]
}
]
})
assert.deepEqual(items[0].attachmentLinks, ['https://example.com/a?x=1&y=2'])
assert.deepEqual(items[0].personLines, [{ left: '提到:@u1', right: '' }])
})
})
it('同秒节点使用详情页的稳定排序键', () => {
const progress = Array.from({ length: 12 }, (_, index) => ({
kind: 'task',
nodeKey: `node-${String(11 - index).padStart(2, '0')}`,
title: `节点${11 - index}`,
status: 'approved',
timestamp: '2026-11-01 10:00:00'
}))
const items = buildFlowItems({
progress,
comments: [{ id: 'comment', content: '讨论', createTime: '2026-11-01 10:00:00' }]
})
assert.equal(items[0].left, '【节点0】 已同意')
assert.equal(items[11].left, '【节点11】 已同意')
assert.equal(items[12].left, '【评论】 评论')
})
+46 -8
View File
@@ -1,3 +1,4 @@
import { mergeProgressWithComments } from '../oa-progress-merge-comments.js'
/**
* 移动端打印「审批流程」行组装(无 html2canvas/API 副作用,便于单测)
*/
@@ -37,16 +38,22 @@ function isCcProgressItem(p) {
const taskType = String(p?.taskType || p?.task_type || '').trim()
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
const title = String(p?.title || p?.nodeName || '').trim()
return (
taskType === 'cc' ||
signMode === 'cc' ||
title === '抄送人' ||
/^抄送/.test(title)
)
return taskType === 'cc' || signMode === 'cc' || title === '抄送人' || /^抄送/.test(title)
}
export function buildFlowItems(detail) {
const list = Array.isArray(detail?.progress) ? detail.progress : []
export function buildFlowItems(detail, resolveUrl = (url) => url) {
const progress = Array.isArray(detail?.progress) ? detail.progress : []
// 沿用详情页的时间顺序;无讨论时保持原有审批节点顺序。
const list =
Array.isArray(detail?.comments) && detail.comments.length
? mergeProgressWithComments(
progress.map((p, index) => ({
...p,
key: `${p.kind}-${p.nodeKey || p.taskId || index}-${p.roundNo || 1}`
})),
detail.comments
)
: progress
const items = []
let ccBuf = null
const flushCc = () => {
@@ -70,6 +77,37 @@ export function buildFlowItems(detail) {
const signMode = String(p.signMode || p.sign_mode || '')
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
if (kind === 'comment') {
flushCc()
const revoked = p.status === 'revoked'
const author = String(p.assignee || p.title || '评论').trim()
const item = {
left: `【评论】 ${author}${revoked ? ' 已撤回' : ''}`,
right: ts
}
if (!revoked) {
const lines = []
if (comment) lines.push({ left: comment, right: '' })
const mentions = (p.mentions || [])
.map((m) => String(m.userName || m.userId || '').trim())
.filter(Boolean)
if (mentions.length)
lines.push({
left: `提到:${mentions.map((name) => `@${name}`).join('、')}`,
right: ''
})
if (lines.length) item.personLines = lines
const links = (p.attachmentRefs || [])
.map((file) => String(file.url || '').trim())
.filter((url) => url && !/^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(url))
.map(resolveUrl)
.filter((url) => /^https?:\/\//i.test(url))
if (links.length) item.attachmentLinks = links
}
items.push(item)
continue
}
if (isCcProgressItem(p)) {
if (!ccBuf) ccBuf = { names: [], ts }
if (names.length) ccBuf.names.push(...names)
+19
View File
@@ -1247,6 +1247,25 @@ body.van-toast--unclickable .presign-order-page * {
font-weight: 500;
}
.market-form-field > .market-form-field__tip,
.market-form-field .market-form-field__control--text .market-form-field__tip {
display: block;
margin: 0;
color: #64748b;
font-size: 11px;
font-weight: 500;
line-height: 1.4;
}
.market-form-field > .market-form-field__tip--action {
padding: 0;
border: 0;
background: transparent;
color: #0a9f6d;
text-align: left;
cursor: pointer;
}
.market-form-field > .van-field,
.market-form-field__control {
box-sizing: border-box;
@@ -12,25 +12,52 @@
</van-empty>
</div>
<template v-else>
<ProjectAssignmentField v-if="context.module === 'protocol'" ref="projectAssignment"
v-model="formData.projectId" :biz-type="formData.source" :editing="mode === 'edit'"
:record="formData" :disabled="fieldsLocked" @update:model-value="dirty = true" />
<MarketFormSection
v-for="section in leadingSections"
:key="section.key"
:section="section"
:model-value="sectionValue(section)"
:options="formOptions"
:source="formData.source"
:mode="mode"
:uploading="false"
:disabled="fieldsLocked"
:search-customers="searchProtocolCustomers"
@update:model-value="setSectionValue(section, $event)"
@upload="uploadField"
@field-change="handleFieldChange"
@customer-select="handleCustomerSelect"
/>
<template v-for="section in leadingSections" :key="section.key">
<MarketFormSection
v-if="!(context.module === 'protocol' && section.key === 'protocol')"
:section="section"
:model-value="sectionValue(section)"
:options="formOptions"
:source="formData.source"
:mode="mode"
:uploading="false"
:disabled="fieldsLocked"
:search-customers="searchProtocolCustomers"
@update:model-value="setSectionValue(section, $event)"
@upload="uploadField"
@field-change="handleFieldChange"
@customer-select="handleCustomerSelect"
/>
<MarketFormSection
v-else
:section="section"
:model-value="sectionValue(section)"
:options="formOptions"
:source="formData.source"
:mode="mode"
:uploading="false"
:disabled="fieldsLocked"
:search-customers="searchProtocolCustomers"
:insert-before-keys="['protocolStatus', 'donationStatus']"
@update:model-value="setSectionValue(section, $event)"
@upload="uploadField"
@field-change="handleFieldChange"
@customer-select="handleCustomerSelect"
>
<template #insert>
<ProjectAssignmentField
ref="projectAssignment"
embedded
v-model="formData.projectId"
:biz-type="formData.source"
:editing="mode === 'edit'"
:record="formData"
:disabled="fieldsLocked"
@update:model-value="dirty = true"
/>
</template>
</MarketFormSection>
</template>
<section
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
@@ -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)
@@ -86,6 +86,9 @@
<div v-else-if="f.comment" class="oa-print-flow__comment">
意见:{{ f.comment }}
</div>
<div v-for="url in f.attachmentLinks" :key="url" class="oa-print-url">
<a :href="url" data-oa-print-link>{{ url }}</a>
</div>
</td>
</tr>
</template>
@@ -221,6 +224,10 @@
background: #fff;
}
.oa-print-url a {
color: inherit;
}
.oa-print-url {
color: #3370ff;
word-break: break-all;
@@ -248,6 +255,8 @@
}
.oa-print-flow__left {
white-space: pre-wrap;
overflow-wrap: anywhere;
flex: 1 1 auto;
min-width: 0;
}
@@ -0,0 +1,90 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { buildFlowItems } from './oa-approval-print-flow'
describe('打印讨论评论', () => {
it('按时间展示正文、提及和附件,并排在待审节点前', () => {
const items = buildFlowItems({
progress: [
{ kind: 'submit', title: '发起人', status: 'submitted', timestamp: '2026-11-01 10:00:00' },
{
kind: 'pending_node',
title: '审批人',
status: 'pending',
timestamp: '2026-11-01 10:00:00'
}
],
comments: [
{
id: '2',
createByName: '李四',
createTime: '2026-11-01 12:00:00',
content: '',
attachmentRefs: [{ url: 'https://example.com/file.pdf', fileName: '附件.pdf' }]
},
{
id: '1',
createByName: '张三',
createTime: '2026-11-01 11:00:00',
content: '@李四 请处理\n第二行',
mentions: [{ userId: 'u2', userName: '李四' }]
}
]
})
assert.equal(items.length, 4)
assert.equal(items[1].left, '【评论】 张三')
assert.equal(items[1].right, '2026-11-01 11:00:00')
assert.deepEqual(items[1].personLines, [
{ left: '@李四 请处理\n第二行', right: '' },
{ left: '提到:@李四', right: '' }
])
assert.deepEqual(items[2].attachmentLinks, ['https://example.com/file.pdf'])
assert.match(items[3].left, /待处理/)
})
it('撤回评论不泄露正文、提及和附件', () => {
const [item] = buildFlowItems({
comments: [
{
id: '1',
createBy: 'u1',
status: 'revoked',
content: 'secret',
mentions: [{ userName: 'secret' }],
attachmentRefs: [{ url: 'https://example.com/secret' }]
}
]
})
assert.equal(item.left, '【评论】 u1 已撤回')
assert.equal(JSON.stringify(item).includes('secret'), false)
})
it('安全过滤附件协议,保留纯附件与纯提及评论', () => {
const items = buildFlowItems({
comments: [
{
id: '1',
mentions: [{ userId: 'u1' }],
attachmentRefs: [{ url: 'javascript:alert(1)' }, { url: 'https://example.com/a?x=1&y=2' }]
}
]
})
assert.deepEqual(items[0].attachmentLinks, ['https://example.com/a?x=1&y=2'])
assert.deepEqual(items[0].personLines, [{ left: '提到:@u1', right: '' }])
})
})
it('同秒节点使用详情页的稳定排序键', () => {
const progress = Array.from({ length: 12 }, (_, index) => ({
kind: 'task',
nodeKey: `node-${String(11 - index).padStart(2, '0')}`,
title: `节点${11 - index}`,
status: 'approved',
timestamp: '2026-11-01 10:00:00'
}))
const items = buildFlowItems({
progress,
comments: [{ id: 'comment', content: '讨论', createTime: '2026-11-01 10:00:00' }]
})
assert.equal(items[0].left, '【节点0】 已同意')
assert.equal(items[11].left, '【节点11】 已同意')
assert.equal(items[12].left, '【评论】 评论')
})
+50 -8
View File
@@ -1,3 +1,4 @@
import { mergeProgressWithComments } from './oa-progress-merge-comments'
/**
* OA 打印「审批流程」行组装(无 Vue/canvas 副作用,便于单测)
*/
@@ -11,6 +12,7 @@ export type OaPrintPersonLine = {
export type OaPrintFlowItem = {
left: string
right: string
attachmentLinks?: string[]
/** @deprecated 多人节点改用 personLines;单人/发起人可保留 */
comment?: string
/** 或签/会签按人行:左侧文案 + 右侧时间(右对齐) */
@@ -66,20 +68,29 @@ function isCcProgressItem(p: any): boolean {
const taskType = String(p?.taskType || p?.task_type || '').trim()
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
const title = String(p?.title || p?.nodeName || '').trim()
return (
taskType === 'cc' ||
signMode === 'cc' ||
title === '抄送人' ||
/^抄送/.test(title)
)
return taskType === 'cc' || signMode === 'cc' || title === '抄送人' || /^抄送/.test(title)
}
/**
* 从 progress 组装审批流程行;抄送节点聚合为「抄送A, B, C」对齐钉钉导出。
* 若 progress 漏抄送,则用 tasks 兜底补一行。
*/
export function buildFlowItems(detail: any): OaPrintFlowItem[] {
const list = Array.isArray(detail?.progress) ? detail.progress : []
export function buildFlowItems(
detail: any,
resolveUrl: (url: string) => string = (url) => url
): OaPrintFlowItem[] {
const progress = Array.isArray(detail?.progress) ? detail.progress : []
// 沿用详情页的时间顺序;无讨论时保持原有审批节点顺序。
const list =
Array.isArray(detail?.comments) && detail.comments.length
? mergeProgressWithComments(
progress.map((p: any, index: number) => ({
...p,
key: `${p.kind}-${p.nodeKey || p.taskId || index}-${p.roundNo || 1}`
})),
detail.comments
)
: progress
const items: OaPrintFlowItem[] = []
let ccBuf: { names: string[]; ts: string } | null = null
@@ -104,6 +115,37 @@ export function buildFlowItems(detail: any): OaPrintFlowItem[] {
const signMode = String(p.signMode || p.sign_mode || '')
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
if (kind === 'comment') {
flushCc()
const revoked = p.status === 'revoked'
const author = String(p.assignee || p.title || '评论').trim()
const item: OaPrintFlowItem = {
left: `【评论】 ${author}${revoked ? ' 已撤回' : ''}`,
right: ts
}
if (!revoked) {
const lines = []
if (comment) lines.push({ left: comment, right: '' })
const mentions = (p.mentions || [])
.map((m: any) => String(m.userName || m.userId || '').trim())
.filter(Boolean)
if (mentions.length)
lines.push({
left: `提到:${mentions.map((name: string) => `@${name}`).join('、')}`,
right: ''
})
if (lines.length) item.personLines = lines
const links = (p.attachmentRefs || [])
.map((file: any) => String(file.url || '').trim())
.filter((url: string) => url && !/^(?!https?:)[a-z][a-z0-9+.-]*:/i.test(url))
.map(resolveUrl)
.filter((url: string) => /^https?:\/\//i.test(url))
if (links.length) item.attachmentLinks = links
}
items.push(item)
continue
}
if (isCcProgressItem(p)) {
if (!ccBuf) ccBuf = { names: [], ts }
if (names.length) ccBuf.names.push(...names)
+27 -3
View File
@@ -1,3 +1,4 @@
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
/**
* OA 审批单钉钉风格 PDF 导出
*/
@@ -398,7 +399,13 @@ export async function buildOaPrintModel(
stampClass,
stampText,
rows: [...headerRows, ...formRows],
flowItems: buildFlowItems(detail),
flowItems: buildFlowItems(detail, (url) => {
try {
return new URL(attachmentFullUrl(url), window.location.origin).href
} catch {
return ''
}
}),
printTime,
printerName,
watermarkName: printerName === '-' ? '审批' : printerName
@@ -447,7 +454,7 @@ function renderPageLabelCanvas(text: string): HTMLCanvasElement {
return c
}
async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) {
async function canvasToPdf(canvas: HTMLCanvasElement, filename: string, sheet: HTMLElement) {
const pdf = new jsPDF({ orientation: 'p', unit: 'mm', format: 'a4' })
const pageWidth = pdf.internal.pageSize.getWidth()
const pageHeight = pdf.internal.pageSize.getHeight()
@@ -466,6 +473,23 @@ async function canvasToPdf(canvas: HTMLCanvasElement, filename: string) {
const position = -pageIndex * pageHeight
pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight)
// html2canvas 只保留像素,单独把附件链接写为 PDF 注解;跨页部分按页裁切。
const sheetRect = sheet.getBoundingClientRect()
const ratio = pageWidth / sheetRect.width
for (const link of sheet.querySelectorAll<HTMLAnchorElement>('a[data-oa-print-link]')) {
if (!/^https?:\/\//i.test(link.href)) continue
for (const rect of link.getClientRects()) {
const top = (rect.top - sheetRect.top) * ratio - pageIndex * pageHeight
const bottom = Math.min(pageHeight, top + rect.height * ratio)
const y = Math.max(0, top)
if (bottom > y) {
pdf.link((rect.left - sheetRect.left) * ratio, y, rect.width * ratio, bottom - y, {
url: link.href
})
}
}
}
const label = `第${pageIndex + 1}页`
const labelCanvas = renderPageLabelCanvas(label)
if (labelCanvas.width > 1) {
@@ -526,7 +550,7 @@ export async function exportOaApprovalPdf(
windowWidth: el.scrollWidth,
windowHeight: el.scrollHeight
})
await canvasToPdf(canvas, filename)
await canvasToPdf(canvas, filename, el)
} finally {
app.unmount()
host.remove()
+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),
@@ -0,0 +1,63 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
getConsentProtocolAttachment,
getSourceProtocolAttachment,
getSourceProtocolAttachmentGroups
} from './protocol-source-attachment'
describe('protocol-source-attachment', () => {
it('returns health survey as primary for cell storage', () => {
const meta = getSourceProtocolAttachment({
sourceType: 'cell_storage',
healthSurveyAttachmentFiles: [{ id: '1', fileUrl: '/a.png' }]
})
assert.equal(meta.label, '孕妇健康调查表')
assert.equal(meta.files.length, 1)
})
it('returns infectious disease report as primary for research donation', () => {
const meta = getSourceProtocolAttachment({
sourceType: 'research_donation',
infectiousDiseaseReportAttachmentFiles: [{ id: '2', fileUrl: '/b.png' }]
})
assert.equal(meta.label, '传染病检测报告')
assert.equal(meta.files.length, 1)
})
it('returns consent attachment from shared field', () => {
const meta = getConsentProtocolAttachment({
consentAttachmentFiles: [{ id: '3', fileUrl: '/c.png' }]
})
assert.equal(meta.label, '知情同意书')
assert.equal(meta.files.length, 1)
})
it('groups cell storage attachments for reception dialog', () => {
const groups = getSourceProtocolAttachmentGroups({
sourceType: 'cell_storage',
protocolAttachmentFiles: [{ id: 'p' }],
consentAttachmentFiles: [{ id: 'c' }],
healthSurveyAttachmentFiles: [{ id: 'h' }]
})
assert.deepEqual(
groups.map((g) => g.label),
['知情同意书', '孕妇健康调查表']
)
assert.ok(groups.every((g) => g.files.length === 1))
assert.equal(groups.length, 2)
})
it('groups research donation attachments for reception dialog', () => {
const groups = getSourceProtocolAttachmentGroups({
sourceType: 'research_donation',
consentAttachmentFiles: [{ id: 'c' }],
infectiousDiseaseReportAttachmentFiles: [{ id: 'i' }],
attachmentFiles: [{ id: 'o' }]
})
assert.deepEqual(
groups.map((g) => g.label),
['知情同意书', '传染病检测报告', '其他附件']
)
})
})
@@ -8,7 +8,7 @@ function normalizeAttachmentFiles(raw: unknown): Array<Record<string, unknown>>
return raw.filter((item) => item && typeof item === 'object') as Array<Record<string, unknown>>
}
/** 按来源返回协议级附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
/** 按来源返回协议级主附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
export function getSourceProtocolAttachment(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta {
@@ -24,3 +24,50 @@ export function getSourceProtocolAttachment(
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
}
}
/** 知情同意书(细胞存储与科研捐赠共用字段) */
export function getConsentProtocolAttachment(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta {
return {
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
}
}
/**
* 样本接收弹窗:按来源返回质检需查看的协议附件分组。
* 细胞存储:仅知情同意书 / 孕妇健康调查表(不展示「协议附件」项)
* 科研捐赠:知情同意书 / 传染病检测报告 / 其他附件
*/
export function getSourceProtocolAttachmentGroups(
row: Record<string, unknown>
): ProtocolSourceAttachmentMeta[] {
const source = String(row.sourceType || 'cell_storage').trim()
if (source === 'research_donation') {
return [
{
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
},
{
label: '传染病检测报告',
files: normalizeAttachmentFiles(row.infectiousDiseaseReportAttachmentFiles)
},
{
label: '其他附件',
files: normalizeAttachmentFiles(row.attachmentFiles ?? row.otherAttachmentFiles)
}
]
}
return [
{
label: '知情同意书',
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
},
{
label: '孕妇健康调查表',
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
}
]
}
+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="采集日期"
@@ -44,6 +44,7 @@
<script setup lang="ts">
import { Document, Download, VideoPlay } from '@element-plus/icons-vue'
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
withDefaults(
defineProps<{
@@ -58,14 +59,8 @@
(e: 'download', file: Record<string, any>): void
}>()
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
function resolveFileUrl(suffix: string) {
if (!suffix) return ''
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
return suffix
}
return `${minioBaseUrl}/${suffix}`
return attachmentFullUrl(suffix)
}
function isImageFile(file: { fileType?: string; fileName?: string; fileUrl?: string }) {
@@ -1174,6 +1174,41 @@
</el-table>
</div>
</div>
<!-- 操作日志(Issue #24) -->
<div id="view-section-operation-log" class="section-card section-card--white">
<div class="section-title section-title--banner"><h3>操作日志</h3></div>
<div class="list-body list-wrap view-inner-table-wrap">
<el-table
v-loading="operationLogLoading"
class="list-table inner-table"
:data="operationLogRecords"
stripe
empty-text="暂无操作日志"
>
<el-table-column type="index" label="序号" min-width="68" align="center" header-align="center" />
<el-table-column label="动作" min-width="100" align="center" header-align="center">
<template #default="{ row }">
<el-tag :type="operationActionTagType(String(row.action || ''))" size="small">
{{ operationActionLabel(String(row.action || '')) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="来源端" min-width="90" align="center" header-align="center">
<template #default="{ row }">{{ operationChannelLabel(String(row.clientChannel || '')) }}</template>
</el-table-column>
<el-table-column label="操作时间" min-width="170" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operateTime) }}</template>
</el-table-column>
<el-table-column label="操作人" min-width="120" align="center" header-align="center">
<template #default="{ row }">{{ dash(row.operatorUserName) }}</template>
</el-table-column>
<el-table-column label="变更摘要" min-width="260" align="left" header-align="center" show-overflow-tooltip>
<template #default="{ row }">{{ operationSummary(row) }}</template>
</el-table-column>
</el-table>
</div>
</div>
</div>
</template>
</div>
@@ -1226,6 +1261,7 @@
fetchCollectionProtocolAuditList,
fetchCollectionProtocolDetail,
fetchCollectionProtocolStatusChanges,
fetchCollectionProtocolOperationLogs,
type CollectionProtocolRow
} from '@/api/collection-protocol'
import {
@@ -1317,6 +1353,7 @@
}
tabs.push({ key: 'attachment', label: '附件信息' })
tabs.push({ key: 'status-change', label: '状态变更记录' })
tabs.push({ key: 'operation-log', label: '操作日志' })
return tabs
})
@@ -1327,6 +1364,90 @@
const bundle = ref<Record<string, any> | null>(null)
const auditRecords = ref<Array<Record<string, unknown>>>([])
const statusChangeRecords = ref<Array<Record<string, unknown>>>([])
const operationLogLoading = ref(false)
const operationLogRecords = ref<Array<Record<string, unknown>>>([])
const OPERATION_ACTION_LABEL: Record<string, string> = {
create: '创建',
update: '修改',
delete: '删除',
void: '作废',
activate: '生效',
expire: '过期',
to_research: '转科研',
to_storage: '转细胞存储',
audit: '审核'
}
const OPERATION_ACTION_TAG: Record<string, 'success' | 'warning' | 'danger' | 'primary' | 'info'> = {
create: 'success',
update: 'primary',
delete: 'danger',
void: 'danger',
activate: 'success',
expire: 'warning',
to_research: 'primary',
to_storage: 'primary',
audit: 'info'
}
function operationActionLabel(action: string) {
return OPERATION_ACTION_LABEL[action] || action || '—'
}
function operationActionTagType(action: string) {
return OPERATION_ACTION_TAG[action] || 'info'
}
function operationChannelLabel(channel: string) {
if (channel === 'mini') return '小程序'
if (channel === 'web') return 'Web'
return channel || '—'
}
function operationSummary(row: Record<string, unknown>) {
const before = (row.before || {}) as Record<string, any>
const after = (row.after || {}) as Record<string, any>
const beforeProto = (before.protocol || before.donation || {}) as Record<string, any>
const afterProto = (after.protocol || after.donation || {}) as Record<string, any>
const beforeParty = (before.partyCustomer || before.party || {}) as Record<string, any>
const afterParty = (after.partyCustomer || after.party || {}) as Record<string, any>
const parts: string[] = []
const beforeStatus = String(beforeProto.protocolStatus || beforeProto.donationStatus || '')
const afterStatus = String(afterProto.protocolStatus || afterProto.donationStatus || '')
if (beforeStatus || afterStatus) {
if (beforeStatus && afterStatus && beforeStatus !== afterStatus) {
parts.push(`状态 ${beforeStatus}→${afterStatus}`)
} else if (afterStatus) {
parts.push(`状态 ${afterStatus}`)
}
}
for (const key of ['motherName', 'customerName', 'donorName', 'name']) {
const b = String(beforeParty[key] || '').trim()
const a = String(afterParty[key] || '').trim()
if (b || a) {
if (b && a && b !== a) parts.push(`姓名 ${b}→${a}`)
else if (a && !b) parts.push(`姓名 ${a}`)
break
}
}
const beforeSamples = Array.isArray(before.samples) ? before.samples : []
const afterSamples = Array.isArray(after.samples) ? after.samples : []
if (beforeSamples.length || afterSamples.length) {
const pick = (list: any[]) =>
list
.map((s) => `${s?.sampleCode || ''}:${s?.sampleStatus || s?.status || ''}`)
.filter((x) => x !== ':')
.join(',')
const bs = pick(beforeSamples)
const as = pick(afterSamples)
if (bs || as) {
if (bs && as && bs !== as) parts.push(`样本 ${bs}→${as}`)
else if (as) parts.push(`样本 ${as}`)
}
}
if (!parts.length) {
const remark = String(row.remark || '').trim()
return remark || '见前后快照'
}
return parts.join(';')
}
const STATUS_CHANGE_TYPE_LABEL: Record<string, string> = {
activate: '生效',
@@ -2088,6 +2209,22 @@
}
}
async function loadOperationLogRecords() {
if (!props.protocolId) {
operationLogRecords.value = []
return
}
operationLogLoading.value = true
try {
const res = await fetchCollectionProtocolOperationLogs(props.protocolId)
operationLogRecords.value = res.list || []
} catch {
operationLogRecords.value = []
} finally {
operationLogLoading.value = false
}
}
async function loadAuditRecords() {
if (!props.showAuditHistory || !props.protocolId) {
auditRecords.value = []
@@ -2115,10 +2252,12 @@
bundle.value = null
auditRecords.value = []
statusChangeRecords.value = []
operationLogRecords.value = []
const baseLookupsPromise = loadBaseLookups().catch(() => {})
const auditPromise = props.showAuditHistory ? loadAuditRecords() : null
const statusChangePromise = loadStatusChangeRecords()
const operationLogPromise = loadOperationLogRecords()
let data: Record<string, any> | null = null
try {
@@ -2136,6 +2275,7 @@
void finishLookups(data, baseLookupsPromise)
void auditPromise
void statusChangePromise
void operationLogPromise
if (props.highlightSampleCode) {
nextTick(() => {
document
@@ -2159,6 +2299,7 @@
bundle.value = null
auditRecords.value = []
statusChangeRecords.value = []
operationLogRecords.value = []
activeTab.value = 'basic'
detailLoading.value = false
lookupLoading.value = false
@@ -105,8 +105,7 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column prop="projectName" label="所属项目" min-width="160"/><el-table-column prop="teamName" label="项目组" min-width="150"/>
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -115,6 +114,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column prop="projectName" label="所属项目" min-width="160" />
<el-table-column prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="protocolStatus"
label="状态"
@@ -116,9 +116,7 @@
@selection-change="handleSelectionChange"
@row-dblclick="handleView"
>
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column type="selection" min-width="55" align="center" header-align="center" />
<el-table-column
type="index"
label="序号"
@@ -127,6 +125,8 @@
header-align="center"
:index="(index) => (pagination.page - 1) * pagination.pageSize + index + 1"
/>
<el-table-column v-if="!isClinical" prop="projectName" label="所属项目" min-width="160" />
<el-table-column v-if="!isClinical" prop="teamName" label="项目组" min-width="150" />
<el-table-column
prop="applicationNo"
label="申请单号"
@@ -74,6 +74,16 @@
<el-table-column v-if="!isTeam" prop="projectNo" label="项目编号" min-width="160" />
<el-table-column v-if="!isTeam" prop="teamName" label="所属项目组" min-width="160" />
<el-table-column prop="leaderName" label="负责人" min-width="120" />
<el-table-column v-if="isTeam" label="成员" min-width="180" show-overflow-tooltip
><template #default="{ row }">{{
joinChildNames(row.members)
}}</template></el-table-column
>
<el-table-column v-if="isTeam" label="负责医院" min-width="200" show-overflow-tooltip
><template #default="{ row }">{{
joinChildNames(row.hospitals)
}}</template></el-table-column
>
<el-table-column label="状态" min-width="110"
><template #default="{ row }"
><el-tag
@@ -118,7 +128,7 @@
<el-dialog
v-model="visible"
:title="`${readonly ? '查看' : editingId ? '编辑' : '新增'}${isTeam ? '项目组' : '项目'}`"
width="720px"
:width="isTeam && !readonly ? '960px' : '720px'"
class="dialog-form-layout"
append-to-body
modal-class="protocol-dialog-mask"
@@ -139,12 +149,19 @@
v-else
v-model="form.projectName"
/></el-form-item>
<el-form-item
v-if="!isTeam"
label="项目编号"
prop="projectNo"
:rules="[{ required: true, message: '请输入项目编号' }]"
><el-input v-model="form.projectNo" maxlength="64" show-word-limit
/></el-form-item>
<el-form-item
v-if="!isTeam"
label="所属项目组"
prop="teamId"
:rules="[{ required: true, message: '请选择项目组' }]"
><el-select v-model="form.teamId" filterable
><el-select v-model="form.teamId" filterable @change="onTeamChange"
><el-option
v-for="team in teams"
:key="team.id"
@@ -163,7 +180,7 @@
:label="user.name" /></el-select
></el-form-item>
<template v-if="isTeam">
<el-form-item label="成员"
<el-form-item label="成员" :class="{ 'form-col-span-2': !readonly }"
><el-select v-model="memberIds" multiple filterable
><el-option
v-for="user in refs.users"
@@ -171,7 +188,7 @@
:value="user.id"
:label="user.name" /></el-select
></el-form-item>
<el-form-item label="负责地区(信息记录)"
<el-form-item label="负责地区(信息记录)" :class="{ 'form-col-span-2': !readonly }"
><el-select v-model="regionIds" multiple filterable
><el-option
v-for="region in refs.regions"
@@ -179,7 +196,7 @@
:value="region.id"
:label="region.name" /></el-select
></el-form-item>
<el-form-item label="负责医院(信息记录)"
<el-form-item label="负责医院(信息记录)" :class="{ 'form-col-span-2': !readonly }"
><el-select v-model="hospitalIds" multiple filterable
><el-option
v-for="hospital in refs.hospitals"
@@ -187,7 +204,9 @@
:value="hospital.id"
:label="hospital.name" /></el-select
></el-form-item>
<el-form-item label="启用"><el-switch v-model="form.enabled" /></el-form-item>
<el-form-item label="启用" :class="{ 'form-col-span-2': !readonly }"
><el-switch v-model="form.enabled"
/></el-form-item>
<div class="form-tip form-col-span-2"
>地区、医院仅记录项目组职责,不改变业务地点候选或数据权限。</div
>
@@ -241,7 +260,7 @@
>
</template>
<script setup lang="ts">
import { managementPayload } from '@/utils/project-management'
import { joinChildNames, managementPayload } from '@/utils/project-management'
import { useMenuPerm } from '@/hooks/core/useMenuPerm'
import {
Plus,
@@ -331,6 +350,11 @@
})
search()
}
function onTeamChange(teamId: string) {
if (readonly.value || isTeam.value) return
const team = teams.value.find((item) => item.id === teamId)
if (team?.leaderUserId) form.value.leaderUserId = team.leaderUserId
}
async function edit(row?: ProjectRecord, view = false) {
readonly.value = view
editingId.value = row?.id || ''
@@ -339,6 +363,7 @@
: {
teamName: '',
projectName: '',
projectNo: '',
leaderUserId: '',
enabled: true,
status: 'pending',
@@ -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}:实验室项目不能重复`)
@@ -80,6 +80,16 @@
/>
</dd>
</div>
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
<dt>{{ consentAttachmentMeta(row).label }}</dt>
<dd>
<ProtocolAttachmentMiniReadonly
:files="consentAttachmentMeta(row).files"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</dd>
</div>
<div v-if="activeTab === 'received'" class="sample-reception-card__meta-item">
<dt>实验室入库编码</dt>
<dd>
@@ -242,7 +252,10 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
import { getSourceProtocolAttachment } from '@/utils/protocol-source-attachment'
import {
getConsentProtocolAttachment,
getSourceProtocolAttachment
} from '@/utils/protocol-source-attachment'
import { useAttachmentActions } from '@/composables/useAttachmentActions'
defineOptions({ name: 'SampleReceptionCardList' })
@@ -284,6 +297,10 @@
return getSourceProtocolAttachment(row)
}
function consentAttachmentMeta(row: Record<string, unknown>) {
return getConsentProtocolAttachment(row)
}
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
return source === 'research_donation'
? donationStatusDisplayLabel(status, voidType)
@@ -204,41 +204,63 @@
</div>
</div>
</div>
<div id="form-section-transport" class="sample-transport-col">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>运输信息</h3></div>
<div id="form-section-transport" class="sample-transport-col sample-transport-col--stack">
<div class="sample-transport-stack-block">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>运输信息</h3></div>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ formatDisplayOrDash(currentRow.etaTime) }}</div>
</div>
<template v-if="currentRow.transportType === 'kdys'">
<div class="data-item">
<div class="data-label">快递公司</div>
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">快递单号</div>
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
</div>
</template>
<template v-else-if="currentRow.transportType === 'ryxd'">
<div class="data-item">
<div class="data-label">携带人</div>
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">携带人电话</div>
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
</div>
</template>
</div>
</div>
<div class="data-grid data-grid--2">
<div class="data-item">
<div class="data-label">运输方式</div>
<div class="data-value">
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
<div class="sample-transport-stack-block sample-protocol-attachments">
<div class="section-title section-title--compact">
<div class="section-title-left"><h3>协议附件</h3></div>
</div>
<div class="sample-protocol-attachments__list">
<div
v-for="group in receiveProtocolAttachmentGroups"
:key="group.label"
class="sample-protocol-attachments__item"
>
<div class="data-label">{{ group.label }}</div>
<ProtocolAttachmentMiniReadonly
:files="group.files"
empty-text="暂无附件"
@preview="handleAttachmentPreview"
@download="handleAttachmentDownload"
/>
</div>
</div>
<div class="data-item">
<div class="data-label">预计到达</div>
<div class="data-value">{{ formatDisplayOrDash(currentRow.etaTime) }}</div>
</div>
<template v-if="currentRow.transportType === 'kdys'">
<div class="data-item">
<div class="data-label">快递公司</div>
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">快递单号</div>
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
</div>
</template>
<template v-else-if="currentRow.transportType === 'ryxd'">
<div class="data-item">
<div class="data-label">携带人</div>
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
</div>
<div class="data-item">
<div class="data-label">携带人电话</div>
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
</div>
</template>
</div>
</div>
</div>
@@ -290,7 +312,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 +665,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
@@ -722,6 +744,7 @@
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
import SampleReceptionCardList from './SampleReceptionCardList.vue'
import SampleReceptionDualReadonlyPanel from '@/components/business/sample-reception-dual-readonly-panel/index.vue'
import {
@@ -745,6 +768,7 @@
buildPrefilledLabStorageCode,
parseLabStorageReferenceDate
} from '@/utils/sample-reception-lab-storage-code'
import { getSourceProtocolAttachmentGroups } from '@/utils/protocol-source-attachment'
import type { ProductionReceptionRecord } from '@/utils/sample-production-reception-fields'
import {
formatProductionFieldValue,
@@ -955,6 +979,12 @@
.filter(Boolean) as Array<Record<string, unknown>>
})
const receiveProtocolAttachmentGroups = computed(() => {
const row = currentRow.value
if (!row) return []
return getSourceProtocolAttachmentGroups(row as Record<string, unknown>)
})
function formatMetric(v: unknown) {
if (v === null || v === undefined || v === '') return '-'
const n = Number(v)
@@ -1485,6 +1515,8 @@
let detail: Record<string, unknown> = row
try {
detail = (await fetchSampleReceptionDetail(receptionId)) as Record<string, unknown>
// 详情可能带更完整的协议附件字段,合并后供弹窗右侧展示
currentRow.value = { ...row, ...detail }
} catch {
/* 列表行已含主要字段,详情失败时回退 */
}
@@ -1653,6 +1685,44 @@
padding-left: 20px;
border-left: 1px solid #e2e8f0;
}
&--stack {
display: flex;
flex-direction: column;
gap: 14px;
}
}
.sample-transport-stack-block {
min-width: 0;
}
.sample-protocol-attachments {
background: #ecfdf5;
border-radius: 8px;
padding: 12px 14px;
border: 1px solid #a7f3d0;
min-height: 72px;
}
.sample-protocol-attachments__list {
display: flex;
flex-direction: column;
gap: 12px;
}
.sample-protocol-attachments__item {
min-width: 0;
.data-label {
margin-bottom: 6px;
font-size: 12px;
color: var(--mint-text-muted, #64748b);
}
:deep(.protocol-attachment-mini) {
width: 100%;
}
}
.section-title {
@@ -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), {