Compare commits

...
Author SHA1 Message Date
zlwuandCursor 67a209298c fix(web-admin): 样本接收弹窗恢复展示协议附件
验收反馈要求协议附件可见;细胞存储分组恢复为知情同意书、孕妇健康调查表、协议附件三项。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 13:57:26 +08:00
zlwuandCursor e2a50b33fe chore(qc): 补充阴阳性参数变更后历史结果刷新工具
提供第 1–10 条阴/阳→阴性/阳性及空值默认回填的预览执行脚本与迁移 SQL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:37:41 +08:00
zlwuandCursor 3d9a71da46 chore(agents): 同步 personal-dev 单元测试合入与 task-next
把合入门槛改为受影响代码的单元测试,并补充只读 task-next 入口与确认问题展示规则。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 11:37:41 +08:00
cykj 49e36e0bbb Merge pull request 'fix(web-admin): 临时隐藏采集大表四处导出按钮' (#72) from codex/issue-26-hide-export into V2.0
Merge pull request 'fix(web-admin): 临时隐藏采集大表四处导出按钮' (#72) from codex/issue-26-hide-export into V2.0
2026-11-09 03:22:04 +00:00
zlwu 654f9a8904 fix(web-admin): 临时隐藏采集大表四处导出按钮
验收未完成前关闭 COLLECTION_LEDGER_EXPORT_ENABLED,后续测试时改回 true 即可恢复入口。
2026-11-09 11:21:14 +08:00
cykj b40e41bd87 Merge pull request 'fix(web-admin): 采集大表导出自存退款与科研/预签约对齐' (#71) from codex/issue-26-ledger-align into V2.0
Merge pull request 'fix(web-admin): 采集大表导出自存退款与科研/预签约对齐' (#71) from codex/issue-26-ledger-align into V2.0
2026-11-06 04:42:20 +00:00
zlwu 1afbe06021 fix(web-admin): 采集大表导出自存退款与科研/预签约对齐
自存表写入当月核量号与负值退款金额;科研废弃行第10列复用储存状态;预签约到账金额带定金后缀并填充退款。
2026-11-06 12:41:05 +08:00
cykj 3ad1a08e30 Merge pull request 'fix(web-admin): 细胞存储导出协议编号改样本编码并补齐运费' (#70) from codex/issue-26-cell-freight into V2.0
Merge pull request 'fix(web-admin): 细胞存储导出协议编号改样本编码并补齐运费' (#70) from codex/issue-26-cell-freight into V2.0
2026-11-06 04:12:39 +00:00
zlwu a4425addb0 fix(web-admin): 细胞存储导出协议编号改样本编码并补齐运费
自存表导出列值改为样本编码;列表补运费后写入运输方式/运费列,并按同批次合并单元格。
2026-11-06 12:11:06 +08:00
cykj 471a6823e2 Merge pull request 'fix(web-admin): 科研捐赠导出补齐运费并按批次合并单元格' (#69) from codex/issue-26-research-freight into V2.0
科研捐赠导出补齐运费并按批次合并单元格。
2026-11-06 04:07:37 +00:00
zlwuandCursor d413398b3d fix(web-admin): 科研捐赠导出补齐运费并按批次合并单元格
列表未返回样本运费批次导致运费列为空。导出按同一运费批次合并运输方式与运费列,并按模板对齐储存状态等字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 12:06:49 +08:00
cykj 6be55186b0 Merge pull request 'fix(web-admin): 采集大表导出将 sdfq 等作废编码显示为文案' (#68) from codex/issue-26-sdfq-label into V2.0
采集大表导出将 sdfq 等作废编码显示为文案。
2026-11-06 03:59:45 +00:00
zlwuandCursor 348c5c1d3c fix(web-admin): 采集大表导出将 sdfq 等作废编码显示为文案
科研捐赠导出把作废类型编码原样写入储存状态和废弃原因。改为走字典并回退协议状态文案。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 11:58:48 +08:00
cykj 411a4cb57c Merge pull request 'fix(web-admin): 客服导出协议编号列改用样本编码' (#67) from codex/issue-26-cs-export-samplecode into V2.0
客服导出协议编号列改用样本编码。
2026-11-06 03:53:52 +00:00
zlwuandCursor 6df6306ef0 fix(web-admin): 客服导出协议编号列改用样本编码
验收反馈要求客户服务管理导出表头「协议编号」填样本编码,避免与协议号混淆。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-06 11:52:45 +08:00
cykj 57049f17b0 Merge pull request 'feat(web-admin): 按采集大表导出预签约、自存、科研和客服表' (#66) from codex/issue-26-collection-ledger-b0b170257c35 into V2.0
按采集大表导出预签约、自存、科研和客服表。
2026-11-06 02:25:49 +00:00
zlwuandCursor a214a35f4f feat(web-admin): 按采集大表导出预签约、自存、科研和客服表
市场人员需要把四张列表按采集大表对应工作表导出为 Excel 文本。补预签约列表的地区、医院、样本类型和转账时间,并沿用现有工具栏导出。

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

Refs #43
2026-11-05 15:05:59 +00:00
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
zlwu 79ca01ac0d feat(project): show presign counts as a statistics column
Group presign counts by project and team, include totals and unassigned rows, and reuse count drill-down. Remove the standalone footer while retaining presignTotal compatibility and the four formal business types.

Related to issue #13. Independent source review completed; no tests or builds run under user self-test arrangement.
2026-11-04 14:53:07 +08:00
cykj ce8babcb7b Merge pull request 'feat(project): show business fields in statistics detail dialogs' (#21) from codex/issue-13-project-statistics into V2.0 2026-11-04 06:42:47 +00:00
zlwu fa5efa9629 feat(project): show business fields in statistics details
Reuse assignment business columns and keyword searches in the four statistics drill-down lists. Add a statistics-permission read endpoint sharing tenant-scoped detail queries without granting assignment access.

Related to issue #13. Independent static review completed. User self-test arrangement: regression cases authored, no tests or builds executed.
2026-11-04 14:40:04 +08:00
cykj f04e24186f Merge pull request 'fix(project): show sample codes in storage and donation assignments' (#20) from codex/issue-13-project-statistics into V2.0 2026-11-04 06:30:24 +00:00
zlwu 6e682f8d18 fix(project): identify storage and donation assignments by sample codes
Replace order number columns with batched sample codes and support sample keyword searches without duplicating parent records. Preserve tenant and soft-delete boundaries, existing assignment writes and preview identifiers.

Related to issue #13. Independent static review completed; tests authored but not run under user self-test arrangement.
2026-11-04 14:27:57 +08:00
cykj 74064e137c Merge pull request 'feat(project): add business tabs and identifying fields for assignment' (#19) from codex/issue-13-project-statistics into V2.0 2026-11-04 06:16:58 +00:00
zlwu 29f9866a24 feat(project): group assignment records by business type
Add four business tabs and identifying fields so users can locate orders before assigning projects. Introduce a permission-scoped detail query with child EXISTS searches and batched enrichment, preserving statistics and assignment writes.

Approved Figma v2; independent source review completed. Tests authored but not executed under the user self-test arrangement. Related to issue #13.
2026-11-04 14:14:50 +08:00
cykj 2a7e763cdf Merge pull request 'fix(project): remove statistics explanation banner' (#18) from codex/issue-13-project-statistics into V2.0 2026-11-04 05:46:15 +00:00
zlwu 560bb3dde2 fix(project): remove statistics list explanation banner
Remove the explanatory alert above the statistics table as requested, keeping the separate presign count and existing statistics behavior.
2026-11-04 13:45:35 +08:00
cykj ab019f6e0e Merge pull request 'fix(project): fill date fields in project dialogs' (#17) from codex/issue-13-project-statistics into V2.0 2026-11-04 05:42:47 +00:00
zlwu 801a5a75d2 fix(project): fill date input widths in project dialogs
Set both project date pickers to full column width, matching existing form conventions in create and edit dialogs.
2026-11-04 13:41:44 +08:00
cykj dae1d04672 Merge pull request 'fix(project): add project management menu icon' (#16) from codex/issue-13-project-statistics into V2.0 2026-11-04 04:21:23 +00:00
zlwu 88b971e1b7 fix(project): add icon to project management directory
Reuse the existing folder-chart icon in frontend and backend menu metadata so the project management sidebar directory has a leading icon.
2026-11-04 12:20:21 +08:00
cykj 4b1bf0f783 Merge pull request 'feat(project): add project management menu grouping' (#15) from codex/issue-13-project-statistics into V2.0 2026-11-04 04:16:38 +00:00
zlwu d8d7001b9b feat(project): group project pages under a management menu
Add a second-level project management directory to match the requested navigation hierarchy. Preserve page permissions and legacy URLs, and migrate existing role grants to include the new ancestor. User will validate after integration.
2026-11-04 12:15:00 +08:00
cykj c4d157b23a Merge pull request 'feat(project): 项目组归属、四业务统计与项目选择权限' (#14) from codex/issue-13-project-statistics into V2.0 2026-11-04 03:54:19 +00:00
zlwu 0bddf38e52 feat(project): enforce team-scoped selection with explicit all-project permission
Separate cross-team selection from attribution maintenance and apply the same scope to options, history filters and writes. Add an opt-in tenant permission without granting existing roles.

Protect authorization mutation paths against self-grants, validate tenant targets and links, and include menu ancestors atomically. Cover role union, revocation, mobile writes and administration boundaries with regression tests.
2026-11-02 17:09:20 +08:00
zlwu b510d18439 fix(project): add list row numbers and align search actions
Keep project management lists and related detail, preview and audit dialogs consistent with existing table and filter styles. Number paginated rows continuously and align statistics and assignment searches to the right.
2026-11-02 16:31:02 +08:00
zlwu f41e6f60d6 feat(projects): move management pages into a dedicated top-level menu
Add the Project navigation group alongside Market, Production and Quality. Preserve existing leaf IDs and role grants while migrating the menu hierarchy, keep legacy URLs redirecting, and align frontend/backend menus.
2026-11-02 16:22:18 +08:00
zlwu a4c4028144 fix(projects): resolve runtime permissions and migration compatibility
Use existing menu permissions for management actions, preserve shared list styles in teleported detail dialogs, and align project attribution column collations with the project primary key. Record isolated MySQL and browser validation while retaining the real-device acceptance gate.
2026-11-02 16:04:31 +08:00
zlwu bd62416263 feat(project): add team attribution and business statistics
Add tenant-scoped teams and projects, order attribution across four businesses, conversion inheritance, scoped statistics and audited reassignment. Reuse existing Web and mobile UI patterns; team regions and hospitals remain informational.

Include incremental SQL and isolated regression coverage. Actual MySQL and browser/WebView integration validation remains pending; relates to issue #13.
2026-11-02 15:29:43 +08:00
cykj e9df9d65e1 Merge pull request 'chore(agents): 将协议 6 个人智能体接入 V2.0' (#12) from codex/personal-agent-pilot into V2.0
固定官方 main eb4c320,保留工程配置与原授权;252 项测试及独立审查通过。
2026-11-02 04:02:21 +00:00
167 changed files with 11664 additions and 342 deletions

No files matched your search

+5 -5
View File
@@ -1,6 +1,6 @@
---
name: personal-dev
description: Use when starting a project, implementing a tracked feature, fixing a bug, receiving acceptance feedback, committing/pushing an existing task, preparing delivery, or starting, stopping, inspecting or resuming an issue queue, or continuously monitoring future issues, or inspecting/cleaning task worktrees in a project using the personal development system. Not for concept-only questions or unrelated tasks.
description: Use when starting a project, designing or prototyping project UI, implementing a tracked feature, fixing a bug, receiving acceptance feedback, committing/pushing an existing task, preparing delivery, or starting, stopping, inspecting or resuming an issue queue, or continuously monitoring future issues, or inspecting/cleaning task worktrees in a project using the personal development system. Not for concept-only questions or unrelated tasks.
---
# 个人开发任务入口
@@ -12,22 +12,22 @@ description: Use when starting a project, implementing a tracked feature, fixing
新任务首次启用本技能时先按[版本检查与更新选择](references/updates.md)调用检查入口;同一任务续聊不重复检查或更换规则。检查失败沿用已安装版本,不阻塞业务。
1. 判断任务与已完成阶段,复用工单、用户确认及现有 worktree 的证据。涉及关键未知项时按[澄清与推荐选择](references/planning.md#关键疑问先澄清)处理,默认提供推荐并真实等待 60 秒,满足前置条件后采用推荐继续;无需单独启用,明确关闭、必须等回复或暂停时遵循该规则的等待分支;按下表读取其余分支。
2. 正式工单(含普通编号、新建和续接)先按[工单占用与隔离](references/issue-claims.md)取得当前会话占用;其他会话占用或已完成时不重复开发。开始实现前明确验收条件、集成分支、影响应用及对应目录规则;修改端不明确时按[实施流程](references/implementation.md)先确认范围,并读取[测试责任](references/testing.md);默认 Agent 测试,用户明确自测时按用户模式交付。未知环境不视为已验证。
2. 正式工单进行中先运行 `python3 .agents/skills/personal-dev/scripts/pda.py task-next --project . --owner CURRENT_OWNER --issue N`。它只读,不领取、不测试、不合入。只打开返回的 `read`;`can_merge` 为 false 时不合入。返回的 `ask` 要求决定时,先写「待你确认」。源码仓库里的入口是 `python3 scripts/pda.py`。没有当前工单时,仍先按[工单占用与隔离](references/issue-claims.md)取得当前会话占用;其他会话占用或已完成时不重复开发。开始实现前明确验收条件、集成分支、影响应用及对应目录规则;修改端不明确时按[实施流程](references/implementation.md)先确认范围,并读取[测试责任](references/testing.md)。默认在 worktree 只运行受影响代码的单元测试,通过后按本机验证规则串行合入集成分支;构建和整体运行验收由用户在合入后进行。用户明确自测时,单元测试也改由用户确认后再合入。未跑过的测试不视为已通过。
3. 用户确认通过、要求提交/push/合并或从中断续接时,先读[工单收尾检查](references/completion.md),恢复原任务的关联与未完成步骤;已有授权下完成关单及读回核验,不能只做最后一句 Git 动作。用户明确限定动作或停止时保留剩余待办。
4. 移交时保留“当前阶段、依据、下一动作、验证缺口”。交付前读取交付分支,不能以提交成功代替验收。
| 任务 | 读取 |
| --- | --- |
| 工单领取、并行会话、流程标签、转交、共享环境或合并排队 | [工单占用与隔离](references/issue-claims.md) |
| 发送确认问题、请求用户操作、补发缺推荐或缺超时说明的旧题 | 发送前完成[发问前检查](references/planning.md#发问前检查),发送后按[本地确认语音](references/question-notification.md)提醒 |
| 发送确认问题、请求用户操作、补发缺推荐或缺超时说明的旧题 | 先按[问题展示](references/planning.md#问题展示)在当前回复写出「待你确认」,完成[发问前检查](references/planning.md#发问前检查)后,再按[本地确认语音](references/question-notification.md)提醒 |
| 关闭/恢复声音提醒 | [本地确认语音](references/question-notification.md) |
| 空白工程、大需求、技术栈或架构变化 | [建设与规格](references/planning.md) |
| 拉取工单、理解评论和截图、续接工单或交付前核对补充 | [工单全文、评论与图片](references/issue-context.md) |
| 正式功能、局部 Bug、界面实现 | [实施流程](references/implementation.md) |
| 安装或更新依赖、下载工具链、配置 CI/Docker 构建源 | [依赖下载与镜像](references/dependency-downloads.md) |
| 配置应用运行密钥,或使用服务器、仓库、设计平台等操作账号 | [运行凭据与 Agent 操作凭据](references/credentials.md) |
| 界面实现、布局样式修改,含原型豁免与已有组件复用 | [界面一致性、原型与验收](references/ui-design.md) |
| 切换本机运行版本、集成冒烟、恢复环境或验证失败后修复 | [本机环境验证与集成](references/local-validation.md) |
| 界面设计或原型(含只做 Figma/其他工具设计)、界面实现、布局样式修改 | 在调用设计/生成工具前读取[界面一致性、原型与验收](references/ui-design.md);免除原型仍需核对现有界面依据 |
| 单元测试后合入集成分支、避免多个 worktree 同时合入,或验证失败后修复 | [本机环境验证与集成](references/local-validation.md) |
| 创建/复用隔离目录、同名工程或 worktree 命名冲突 | [Worktree 路径与命名](references/worktree-layout.md) |
| 交付后回收或巡检遗留 worktree | [Worktree 生命周期与清理](references/worktree-lifecycle.md) |
| 选择合并方式、创建/续接 PR、检查审查意见或合并状态 | [PR 合并方式与证据](references/pull-requests.md) |
@@ -23,10 +23,10 @@ python3 .agents/skills/personal-dev/scripts/pda.py delivery-record --project . -
| `--stage` | `--evidence` 必须能定位的事实 |
| --- | --- |
| `waiting_user` | 待测或反馈失败,写明任务分支/worktree、提交、运行入口和待办;清除本轮通过记录 |
| `accepted` | 测试责任、对应提交、Agent 真实测试或用户明确通过的记录,以及适用的审查结果 |
| `integration_verified` | 最终合并内容对应的集成环境验证;纯文档可记录不适用及依据 |
| `accepted` | 测试责任、对应提交,以及单元测试命令和结果,或用户确认该提交的定位。没有可跑的单元测试时写「无单元测试,留给用户验收」。只写「已通过」或「不适用」会被命令拒绝 |
| `integration_verified` | 写明单元测试仍覆盖即将合入的代码树,并写上「构建验收留给用户」。这不是构建或运行已经通过 |
| `remote_synced` | 实际仓库、集成分支与远端最终提交;PR 模式附 PR 链接、base/head、实时 merged 状态及内容映射;直接合并附明确选择依据 |
| `environment_restored` | 实际运行版本恢复/沿用相同已验收环境的依据;不适用须有理由 |
| `environment_restored` | 本单未切换运行环境时写明未改动;曾经切换或启动过服务时写明已恢复到的版本。缺少这条事实时保留缺项 |
| `issue_updated` | 已写入工单的验收/交付评论链接或 ID,包含测试责任、提交及对应 PR(直接合并时说明选择依据) |
后五项使用相同命令形态,将 `--stage` 和 `--evidence` 换为对应真实结果。证据不确定时保留缺项,不能填 `N/A` 绕过。更新较早阶段且证据发生变化会清除下游记录;相同阶段/证据重试不重复推进。提交改变会归档旧记录并清空本轮证据,包括 squash/rebase 或合并 SHA 改变;先核对内容与环境等价性,再逐项记录可复用依据,不能自动继承旧验收。后续失败即使提交未变,也先记录 `waiting_user` 或更新相关阶段使旧完成证据失效,说明实际阻碍。
@@ -55,6 +55,6 @@ python3 .agents/skills/personal-dev/scripts/pda.py delivery-check --project . --
## 最终回复与完成边界
每次工单交付回复必须包含:工单链接/编号、PR 链接及实际状态(直接合并则给出选择依据)、最终提交和目标分支、验收依据、工单远端状态,以及 worktree 清理结果或保留原因。状态只能写实际结果:已关闭并读回确认、待用户测试、待集成验证、待回写/关单、关单结果待核对,或用户明确暂不关闭。尚有步骤缺失时说明缺口及下一动作,不能只用“已 push、工作区干净”结束任务。
每次工单交付回复必须包含三行结果:单元测试命令和结果,或「无单元测试,留给用户验收」;合入提交与 PR 状态;构建与运行尚未由 Agent 验收。同时写工单链接或编号、远端关闭状态,以及 worktree 清理结果或保留原因。状态只能写实际结果:已关闭并读回确认、待用户测试、待串行合入、待回写/关单、关单结果待核对,或用户明确暂不关闭。尚有步骤缺失时说明缺口及下一动作,不能只用“已 push、工作区干净”结束任务。
用户说“验证通过,请 push”,在已有自动收尾授权、最终版本及环境证据满足时,应继续回写、关单和读回确认;用户不必再提醒一次。必要前提缺失或请求失败时报告“代码已推送,工单收尾未完成”及具体原因。最终回复不是后台调度器;结束会话后不会自行补关单,必须在本轮完成已授权可执行的收尾,或明确保留阻碍。
@@ -6,18 +6,18 @@
用户明确将“测试通过后自动合并、提交推送、关闭工单”作为工程常规流程时,按[权限与作用域](permissions.md)记录已明确授予的长期动作,含任务分支推送、创建 PR、工单回写、关闭及合入指定集成分支;占用与标签另按控制授权核对。已有明确授权沿用,不重复逐项批准。仅针对当前单的决定写本 owner/issue 记录,不提升为工程长期授权;当前 deny/撤销始终优先于旧授权。仅安装本 Skill 不构成外部操作授权,未采用此流程的工程仍按实际授权处理。Tag、部署及服务启停不在这项授权中。
完成条件按[测试责任与验收](testing.md)判断:默认 Agent 测试通过即收尾;用户测试模式按测试责任中的反馈/默认转测流程取得真实通过证据后收尾。重要业务变更仍需独立审查;无子代理时使用独立会话或人工审查,执行者自查不计为独立审查。
完成条件按[测试责任与验收](testing.md)判断:默认是受影响代码的单元测试通过后收尾;用户测试模式按测试责任取得该提交的确认后收尾。整体构建验收由用户在合入后进行,不作为本步门槛。重要业务变更仍需独立审查;无子代理时使用独立会话或人工审查,执行者自查不计为独立审查。
## 合并、推送与关单
先按[工单隔离](issue-claims.md#共享环境与合并排队)核对本 owner,占用共享环境;已验收且集成验证有效时进入 ready_to_merge。取得目标分支集成锁进入 merging 后才执行远端合并/集成推送及收尾,其他会话保持待合并。锁不代替权限、测试或分支保护。
先按[工单隔离](issue-claims.md#共享环境与合并排队)核对本 owner。单元测试证据覆盖候选代码树后进入 ready_to_merge。取得目标分支集成锁进入 merging 后才执行远端合并或集成分支推送,其他 worktree 保持等待。没有另行要求操作共享运行环境时,不申请 environment 锁,也不启动服务。锁不代替权限、单元测试或分支保护。
先读[PR 合并方式与证据](pull-requests.md):默认 PR,只有工程或用户明确选择才直接合并;已有明确选择保留。方式不扩大授权。Agent 每次业务 push、PR 写入、合并、工单评论和关闭前,除 queue-guard 外执行对应 [permissions-check](permissions.md#每次外部写入前检查);CLI 不自动执行这些动作。
1. 保存实现和证据到任务提交;按选定方式和授权推送任务分支、创建或复用 PR,并回写关联工单。未完成测试时明确标注待 Agent 测试或待用户测试,修复实现完成不等于验收通过。
2. 按[本机环境验证与集成](local-validation.md)核对最新主分支和验收提交,准备合并结果,完成适用的集成环境冒烟;失败在任务分支修复,保留现场,不推进远端主版本。
3. 必要验证通过或明确不适用后,记录同提交 accepted/integration_verified 并进入 ready_to_merge;申请 merging 成功后再次核对目标未变化,按选定方式及授权合并 PR 并读回合并结果,或在明确的直接合并模式推送集成分支;PR 模式必须先验证本地等价候选,不能等远端合并后才首次验证。
4. 同步本地主分支与远端,按本机验证流程完成运行环境恢复及版本核对。证据覆盖最终代码和实际环境后,在工单记录测试责任、验收/冒烟结果、合并提交与 PR 并关闭,按[完成检查](completion.md#回写关闭和恢复)读回远端状态;推送失败、环境恢复未完成或验证缺失时保持开放。
1. 保存实现和证据到任务提交;按选定方式和授权推送任务分支、创建或复用 PR,并回写关联工单。单元测试尚未通过时明确标注待 Agent 测试或待用户测试,代码写完不等于可以合入。
2. 按[本机环境验证与集成](local-validation.md)核对最新集成分支和已通过单元测试的提交,准备合并结果。单元测试失败或冲突未解决时留在任务分支修复,不推进远端集成分支。不在这一步执行构建或运行冒烟。
3. 单元测试证据覆盖候选代码树后,记录同提交 accepted/integration_verified 并进入 ready_to_merge。`integration_verified` 在这里表示串行合入前的代码树核对,以及构建验收留给用户;申请 merging 成功后再次核对目标未变化,按选定方式及授权合并 PR 并读回合并结果,或在明确的直接合并模式推送集成分支。同一集成分支同时只合入一个 worktree。PR 模式必须先让单元测试覆盖本地合并候选,不能等远端合并后才第一次跑单元测试。
4. 同步本地集成分支与远端。本单未切换运行环境时记录未改动;改过则先恢复。工单写明单元测试结果、合并提交与 PR,并写明构建与整体验收留给用户,再按[完成检查](completion.md#回写关闭和恢复)关闭并读回远端状态。推送失败、锁未释放或单元测试证据缺失时保持开放。
收尾结果用 queue-finish 核验并同步 ai-done;尚有步骤缺失时保留原占用,不因已关闭、已 push 或标签错误而重新开发。
@@ -49,7 +49,9 @@ python3 .agents/skills/personal-dev/scripts/pda.py queue-resource-release --proj
占用失败就等待,不切换别人的运行版本,不换一个名称绕过同一物理资源。资源锁不授予启停服务、迁移或部署权限。释放前按[本机环境规则](local-validation.md)恢复并记录实际版本;释放命令不执行或证明恢复。blocked 阶段若仍持有集成锁,先核对实际合并状态及环境恢复,再用同一 release 命令指定 `integration:实际集成分支` 释放;该资源不能通过 resource-acquire 取得。停止/中断不自动释放,另一会话不能按时间抢占。
进入 `merging` 会自动原子申请 `integration:<集成分支>` 锁;忙时保持待合并,稍后重新核对目标后尝试。取得锁后再次检查最新主分支、PR head 和候选;若变更影响验收,回实际开发/验证阶段,补验后重排。锁持续保护合并、远端同步、环境恢复、工单回写及 finish;退出 merging 阶段会释放集成锁,必须先保证没有仍在执行的集成动作。按[交付](delivery.md)执行实际 Git/平台操作,CLI 不自动合并 PR。
进入 `merging` 会自动原子申请 `integration:<集成分支>` 锁;忙时保持待合并,稍后重新核对目标后尝试。取得锁后再次检查最新主分支、PR head 和候选;代码树相对已通过的单元测试有变化时,回任务分支重跑受影响单元测试后再排队。锁持续保护合并、远端同步、工单回写及 finish;退出 merging 阶段会释放集成锁,必须先保证没有仍在执行的集成动作。按[交付](delivery.md)执行实际 Git/平台操作,CLI 不自动合并 PR。
同一仓库的同一集成分支,同一时刻只允许一个 worktree 写入合并结果。其他 worktree 可以继续开发并运行各自的单元测试,合入必须等当前持锁任务离开 merging 并释放锁之后再申请。不得并行推送同一集成分支,不得换成另一个锁名,不得强推覆盖刚合入的提交。取得锁后发现目标已前进时,先退出本次合并,把最新目标合回任务分支,重跑受影响单元测试,再重新排队。这项互斥只防止同时改写集成分支,不代替单元测试,也不表示构建已经验收。
## 停止、转交与恢复
@@ -1,48 +1,45 @@
# 本机环境验证与集成
本文件定义任务版本验证、集成环境冒烟、失败修复和环境恢复的顺序。测试责任遵循[测试规则](testing.md),远端动作遵循[交付授权](delivery.md)。这是 Agent 工作流程,不是自动启停服务或部署脚本。
本文件定义 worktree 里单元测试通过后,如何串行合入集成分支。测什么以[测试规则](testing.md)为准:worktree 只要求受影响代码的单元测试通过。整体构建、启动和关键路径由用户在合入后的集成分支上自行验收。远端动作遵循[交付授权](delivery.md)。这是 Agent 工作流程,不是自动启停服务或部署脚本。
## 适用范围与准备
应用代码、依赖、配置、构建、数据库迁移或启动方式受影响时,执行本机环境验证。纯文档等不影响运行的任务,记录“不适用”及依据,执行对应文档/脚本检查,不为走流程额外启动应用。相同代码树可复用完整业务测试证据,但不能代替环境切换后的冒烟检查。
应用代码有行为变化时,在对应 worktree 运行受影响的单元测试。纯文档等不影响运行的任务,记录“不适用”及依据,执行对应文档或静态检查,不为此构建或启动应用。相同代码树可复用已通过的单元测试证据;冲突处理或目标更新改变代码树时重跑受影响单元测试。
开始前在当前工单或已有任务记录中保留最小环境清单:集成主分支与当前远端提交、任务分支/worktree、实际运行目录、前后端入口与端口、启动/停止/构建命令、配置来源、依赖/镜像/数据环境、测试责任及服务启停权限。只记录来源和可核对版本,不记录密钥值。未知项先核实,不从浏览器页面正常或 Git 当前分支推断运行版本。
多个 worktree 可以同时开发、同时跑各自的单元测试。写入同一个集成分支必须按下一节串行,避免同时合入造成冲突或互相覆盖。
worktree 只隔离代码;端口、Docker 项目名/容器/卷、数据库和构建产物需另行核对。优先按工程现有方式隔离任务环境。只有一套可复用环境时,按授权切换运行目录并记录恢复方法;不覆盖其他任务配置、抢占端口、共用可破坏的数据卷或擅自清理用户改动。需要迁移、重建镜像或调整数据时单独核对权限与恢复方案,切回代码不会自动还原数据库。
共享环境使用前按[资源互斥](issue-claims.md#共享环境与合并排队)取得 environment:约定名称,忙则等待;同一物理环境所有会话必须用相同名称。独立任务环境可并行,共享主目录运行版本、端口及测试数据不能因有独立 worktree 就并用。释放前记录恢复版本及证据,命令不证明服务已恢复;停止或故障保留资源,未经恢复不能交给下一会话。
默认在合入前不切换共享端口、容器、数据库或构建产物,也不为本单启动服务。用户另行要求操作共享环境时,先保留最小环境清单:集成分支与当前远端提交、任务分支/worktree、实际运行目录、入口与端口、配置来源、数据环境和启停权限。只记录来源和可核对版本,不记录密钥值。使用前按[资源互斥](issue-claims.md#共享环境与合并排队)取得 `environment:约定名称`,忙则等待;同一物理环境所有会话必须用相同名称。worktree 分开不等于运行环境已经分开。
## 跨工程运行环境隔离
同机多个仓库或同仓库多个 clone 并行时,先记录各环境的实际主机/容器运行上下文、监听地址与端口、Docker Compose 项目名、显式容器名、网络/卷、数据库/schema、缓存键前缀和对象存储路径,以及当前使用者。只记录与任务有关的来源与定位,不存凭据,也不遍历无关工程的私有文件。
只有实际操作共享运行环境时才适用本节。同机多个仓库或同仓库多个 clone 并行时,先记录各环境的实际主机/容器运行上下文、监听地址与端口、Docker Compose 项目名、显式容器名、网络/卷、数据库/schema、缓存键前缀和对象存储路径,以及当前使用者。只记录与任务有关的来源与定位,不存凭据,也不遍历无关工程的私有文件。
- 默认给不同工程/任务独立的运行标识,可用[工程标识与任务短 ID](worktree-layout.md#新建命名)组合,并适配实际工具的长度与字符限制。Compose 显式使用独立项目名;固定 `container_name`、external 网络/卷和主机绑定目录仍须单独核对,不因项目名不同认定全部隔离。已有稳定环境配置优先保留,不擅自改名迁移数据。
- 端口按工程约定分配并检查实际占用,记录最终入口;预检查不等于保留端口。启动时绑定失败则报告/换用获准的空闲端口,不停止其他工程进程抢占。服务启停仍需原有授权。
- 测试写入使用独立数据库/schema、缓存命名空间及存储路径;是否真正隔离以应用配置和实际连接为准。不把生产数据或共享可变测试数据当作临时环境,迁移/重置另核对权限。
- `environment:*` 和 `integration:*` 的远端锁都只在各自 Git 仓库生效。不同仓库拿到同名锁,不能证明互斥;本机停止令牌也不控制其他工程。必须共用同一物理资源时,先落实覆盖全部使用者的既有协调/排他机制,并保留实际占用证据。缺少可靠跨工程机制时暂停该环境操作、安排独立环境或交用户协调,可继续独立的代码工作;不凭口头空闲、一次端口扫描或本仓库锁宣称安全。
- 端口按工程约定分配并检查实际占用,记录最终入口;预检查不等于保留端口。启动时绑定失败则报告或换用获准的空闲端口,不停止其他工程进程抢占。服务启停仍需原有授权。
- 测试写入使用独立数据库/schema、缓存命名空间及存储路径;是否真正隔离以应用配置和实际连接为准。不把生产数据或共享可变测试数据当作临时环境,迁移或重置另核对权限。
- `environment:*` 和 `integration:*` 的远端锁都只在各自 Git 仓库生效。不同仓库拿到同名锁,不能证明互斥;本机停止令牌也不控制其他工程。必须共用同一物理资源时,先落实覆盖全部使用者的既有协调机制,并保留实际占用证据。缺少可靠跨工程机制时暂停该环境操作,可继续独立的代码和单元测试。
当前个人智能体没有跨仓库的全局环境锁或自动端口分配器。以上是执行规则,不是安装后自动完成环境隔离;共享资源的名称不能替代真实占用核验。
当前个人智能体没有跨仓库的全局环境锁或自动端口分配器。以上是执行规则,不是安装后自动完成环境隔离。
## 验证与集成顺序
1. **任务版本验证**:在任务分支/worktree 修复,按影响执行测试与必要构建,核对进程工作目录、容器挂载/镜像、前后端配套版本和实际入口对应待验收提交。修改后产生的新提交须有相应验证证据,不能以旧结果代替。失败继续在任务分支修复,不推进集成。
2. **准备合并结果**:读取最新远端集成分支,与已验收提交比较,在任务或临时集成分支处理冲突。代码树变化时按原测试责任补验;相同代码树不机械重复全量测试。保留基线和候选提交,使用 Git 合并,不复制 worktree 文件覆盖主目录。
3. **验证集成本机环境**:按[合并方式](pull-requests.md),默认 PR 走下节候选流程;工程明确选择且允许直接合并时,可先本地合并、暂不推送,然后把本机环境切到该集成版本。核对实际运行版本,按权限执行必要重建/重启。检查受影响服务启动/健康状态、前后端连通、BUG 或需求的关键操作路径及相关错误日志。依赖、配置、数据库或产物有变化时补做对应检查,不能只验证页面能打开;与任务无关的功能不要求全量重测。
4. **更新远端主版本**:集成环境冒烟通过或有明确不适用依据后,保存同提交证据,进入 ready_to_merge,取得 integration:集成分支 锁进入 merging 后再核对目标分支未变化,按选定方式和授权合并 PR,或在明确直接合并模式推送集成分支。若目标又有新提交,退回适用阶段、释放集成锁,重新准备候选、核对证据并补验,不能用强推绕过。任务分支提前推送用于备份/审查不受此门槛限制,不能把它当作主版本已交付。
5. **恢复与交付核对**:同步本地主分支和远端,确认本机实际运行目录/版本已恢复到本次集成结果,不遗留任务 worktree 服务或旧产物冒充主版本。环境完全沿用刚才已验收实例且代码树、产物、配置和数据条件未变时,记录等价证据,不再重复冒烟;若更换了目录、实例、挂载、镜像或其他运行条件则补做相应冒烟。全部条件满足后才按交付流程回写并关闭工单、清理本任务环境。
1. **任务版本验证**:在任务分支/worktree 运行受影响代码的单元测试,记录命令、提交和结果。不执行构建、打包、启动或集成冒烟。失败留在任务分支修复,不进入合入。
2. **准备合并结果**:读取最新远端集成分支,与已通过单元测试的提交比较,在任务分支上用 Git 合并处理冲突。不把 worktree 文件复制覆盖主目录。代码树变化时重跑受影响单元测试;相同代码树沿用原证据。
3. **串行合入**:单元测试证据覆盖即将合入的代码树后,进入 ready_to_merge,按[合并排队](issue-claims.md#共享环境与合并排队)取得 `integration:集成分支` 锁再进入 merging。同一仓库、同一集成分支,同一时刻只允许一个 worktree 写入合并结果;其他任务保持等待。取得锁后再次核对目标未变化,按选定方式和授权合并 PR,或在明确直接合并模式推送集成分支。目标又有新提交时退出本次合并,重新准备候选并补跑单元测试,不能强推。任务分支提前推送只用于备份和审查,不算主版本已交付。
4. **留给用户的整体验收**:合入成功后在工单写明构建、启动、前后端连通和关键路径由用户在该集成分支上后续验收。这项记录表示尚未由 Agent 执行,不能写成通过。同步本地集成分支与远端。本单没有切换本机运行环境时,记录未改动及这条理由;曾经改动过运行环境时,先恢复到本次集成结果再继续收尾。
5. **回写与关单**:上述单元测试、串行合入和记录都完成后,才按交付流程回写并关闭工单。构建验收未做不阻止这一步,但工单评论必须保留“构建与整体验收留给用户”。
## PR 合并前的候选验证
先以最新目标分支和任务提交准备本地临时集成候选,用工程约定的主分支运行条件验证该候选,记录目标 SHA、候选代码树和环境证据;PR 流程不能变成“先远端合并,再首次验证”。本地主分支可以暂留原提交,避免生成与平台合并策略不同的本地历史。
先以最新目标分支和任务提交准备本地合并候选,确认单元测试证据覆盖该候选代码树,再合入远端。记录目标 SHA、候选代码树和单元测试证据。本地主分支可以暂留原提交,避免生成与平台合并策略不同的本地历史。
合并前核对 PR 的 base/head、拟合并内容及所需 CI 检查;平台使用 squash/rebase 时提交号可变化,但最终内容必须对应已验收候选。无法证明结果一致或目标已更新时重新准备并验证,不把未验证的变化直接合入远端。合并后拉取平台结果,安全同步本地主分支,按上节完成运行环境恢复核对。不要通过自动关单关键字提前绕过环境恢复与交付核对。
合并前核对 PR 的 base/head、拟合并内容及仓库已有的必需检查。平台使用 squash/rebase 时提交号可变化,最终内容必须对应已跑过单元测试的候选。无法证明结果一致或目标已更新时重新准备并补跑受影响单元测试。合并后拉取平台结果,安全同步本地集成分支。不通过自动关单关键字跳过收尾记录。
## 失败、用户自测与停止
- **任务测试或集成冒烟失败**:记录失败阶段、提交、入口和原因,暂停远端主分支更新与关单。保留未推送合并、用户改动和诊断依据,在原任务分支/worktree 修复后重新进行必要测试、准备合并结果和冒烟;不直接在主分支补丁式修改。恢复本地主分支或运行环境时先核查未提交/未推送内容,不用破坏性重置掩盖失败;数据迁移失败另按已授权方案处理。
- **远端已合并但恢复检查失败**:如实记录远端已变化,保持工单未完成,停止后续发布,继续任务分支修复或按已有授权制定回退方案;不强制改写远端历史、不宣称尚未推送。仓库级环境问题也不能在队列中跳单继续使用故障环境。
- **用户自测**:任务验证和集成冒烟都由用户执行,Agent 只做源码/Git 核对并交付对应分支、提交、运行入口和步骤。用户仅确认任务版本通过,不能代替不同环境中的集成冒烟;分别取得证据。是否转由 Agent 测试仍按既有反馈规则,用户要求必须等回复时保持等待。
- **无服务启停权限**:说明需要用户执行的准确操作及验证入口,记录待操作/待验证;不自行启停、不因超时绕过工程禁令。可完成独立工作,必要环境验证未完成前不更新远端主分支或关单。
- **用户停止队列**:停止优先,保存阶段和当前运行环境说明,不继续测试、合并、推送或为收尾擅自重启;必要环境恢复按已有授权或交用户操作。续接先核对实际环境和远端状态。
- **单元测试失败或冲突未解决**:记录失败阶段、提交和原因,暂停远端集成分支更新与关单。保留未推送合并和用户改动,在原任务分支/worktree 修复后重新跑必要的单元测试并重新排队合入。不直接在集成分支上打补丁。恢复时先核查未提交内容,不用破坏性重置掩盖失败。
- **远端已合并但本地同步失败**:如实记录远端已变化,保持工单未完成,继续按已有授权处理;不强制改写远端历史。
- **用户自测**:单元测试和约定检查由用户执行,Agent 只做源码和 Git 核对,并交付分支、提交和步骤。用户确认该提交通过后才进入串行合入。构建与整体运行验收仍在合入之后由用户完成。用户要求必须等回复时保持等待。
- **用户停止队列**:停止优先,保存阶段说明,不继续测试、合并或推送。续接先核对实际分支、锁和远端状态。
交付证据统一保留在既有工单/任务记录中:任务与集成提交、验证责任、运行来源与入口、测试/冒烟结果或不适用依据、环境恢复状态和未解决项。无需为每次切分支新建永久报告。
交付证据统一保留在既有工单或任务记录中:任务与集成提交、单元测试命令和结果、串行合入结果、构建验收留给用户的说明,以及未解决项。无需为每次合入新建永久报告。
@@ -24,24 +24,46 @@
4. **决定后落实**:在现有工单或决策入口记录答案、范围及依据,区分“用户明确确认”和“按默认策略超时采用推荐”,更新相关文档后继续,不新增重复报告、不重复询问同一已确认事项。已有文档不构成业务规则已获批准的证据。
5. **中途出现疑问**:暂停受影响部分,说明未知项及影响并澄清;保留独立工作进度。若新结论改变原有范围或已批准原型,按对应确认流程处理,不能用前一次确认覆盖新的决定。
发出确认问题后按[本地语音提醒](question-notification.md)播放固定音频;一次提醒不改变该问题的等待或批准条件。
发出确认问题后按[本地语音提醒](question-notification.md)播放固定音频;一次提醒不改变该问题的等待或批准条件。问题正文先按下一节展示,再播放。
## 问题展示
需要用户确认时,先把全部题目写进**当前这条用户可见回复**,标题用「待你确认」。这份清单就是问题本身。写在工单草稿、任务记录、工具参数或语音里,都还不算已经发问。清单完成后再播放提示音,并从此刻开始计时。
每题占一段,包含问题、带「(推荐)」的推荐项、理由与主要影响、未回复时的处理。还有其他可选项时写上「备选」。多题一次列完。示例:
```text
待你确认
1. 问题:这次修改哪些端?
推荐:Web 与移动 OA(推荐)
理由:工单截图来自这两端,共用接口也要一起改。
未回复时:发送后 60 秒按此推荐继续。其余端本次不改。
```
必须等待的题,最后一行写「未回复时:本题保持等待。」宿主问题卡片可以同时使用,卡片内容与这份清单一致。回复正文里没有「待你确认」时,不播放提示音,也不开始 60 秒计时。
## 发问前检查
每次发送确认问题(含调用宿主提问工具)前,逐题完成以下检查;缺项先改题,再发送。这里是 Agent 的执行步骤,尚无自动拦截宿主问题卡片的校验器。
每次发送确认问题(含调用宿主提问工具)前,逐题完成以下检查;缺项先改题,再按[问题展示](#问题展示)写入当前回复。这里是 Agent 的执行步骤,尚无自动拦截宿主问题卡片的校验器。
1. **区分决定与事实**:明确本题让用户选择的下一步,以及仍待核实的事实。“已放行”“已登录”“已测试通过”只能由用户回复或实际核实支持;改为推荐核实路径或已授权的独立工作,不把事实确认设为超时默认答案。
2. **展示推荐**:有选项时,推荐项放第一项,标签明确带“(推荐)”;宿主指定其他标记格式时遵循宿主格式。题干写出推荐理由、主要影响和未完成项,选项与题干指向同一动作。纯文本/自由输入题也在题干写出推荐下一步;没有可执行动作时推荐保留待办并等待所需信息。默认排序、预选和语音提醒均不能代替可见推荐。
2. **展示推荐**:按[问题展示](#问题展示)把推荐写入「待你确认」。有选项时推荐项放第一项,标签明确带“(推荐)”;宿主指定其他标记格式时,卡片里遵循宿主格式,回复正文仍保留这份清单。题干写出推荐理由、主要影响和未完成项,选项与题干指向同一动作。纯文本/自由输入题也在题干写出推荐下一步;没有可执行动作时推荐保留待办并等待所需信息。默认排序、预选和语音提醒不能代替这份可见清单。
3. **写清等待方式**:对当前授权内可委托选择的下一步,写明“发送后 60 秒未回复,按默认策略采用此推荐”;缺授权、用户明确等待、宿主要求必答或其他前置条件不满足时,写明“本题保持等待,不能通过超时继续受阻操作”。若存在已授权独立工作,明确其范围和仍受阻部分,必要时将“下一步怎么安排”与“操作是否完成”分开;没有独立工作时不承诺到期推进。
4. **确认能执行计时**:确认有可用的异步提问与时间工具;实际发送后记录发送时间、题目版本、推荐动作和截止时间,继续处理新回复并按下一节决定是否执行。若宿主提问会阻塞到用户答复、会话即将结束或无法可靠计时,先说明不能保证 60 秒自动继续,默认采用是 Agent 调整后续动作,不代用户点击、提交答复或假定卡片自动关闭;也不擅自启动后台调度。
5. **修复漏项旧题**:已发送的题若遗漏推荐、推荐标记或超时说明,补齐后重新发送完整问题,注明替代原题、原题不作超时选择;从重发时重新计时,不能按旧发送时间直接采用。重发前先检查新回复:用户已有明确答案时直接沿用,不重复询问。
端口放行示例(已授权服务器内部只读检查,仍有内部检查未完成):
> 当前公网入口未验证,缺少云控制台操作条件。推荐先完成剩余内部检查,将公网访问保留为待放行后复验,避免阻塞独立工作。发送后 60 秒未回复,按默认策略采用该安排;不会据此认为端口已经放行,也不标记公网验收通过。
>
> 1. 继续内部检查,保留公网待验证项(推荐)
> 2. 我已放行端口,请复验公网入口
```text
待你确认
1. 问题:公网入口尚未验证,下一步怎么安排?
推荐:继续内部检查,保留公网待验证项(推荐)
备选:我已放行端口,请复验公网入口
理由:缺少云控制台操作条件,内部检查尚未完成。按推荐继续不会把端口记为已放行,也不标记公网验收通过。你明确选择备选时再按已有授权复验。
未回复时:发送后 60 秒按此推荐继续。
```
收到第二项明确答复后按已有授权复验;原任务已授权独立探测时,也可据真实结果核实公网状态,不写成用户确认。内部检查已全部完成时,不虚构剩余任务:推荐保存部署结果和公网待验证项,等待用户放行;已授权的内部部署与尚未通过的公网验收分别报告。
@@ -6,10 +6,10 @@
| --- | --- | --- |
| 未完成的需求/领域澄清 | Matt Pocock grilling | 只处理尚未确认决策;已有阶段证据直接移交 |
| Bug 根因定位 | Superpowers systematic-debugging | 核对实际正文与依赖,使用一个调试主流程 |
| 实现测试循环 | Superpowers test-driven-development | 仅 Agent 测试模式按任务范围执行;用户明确自测时不运行,不另叠加 Matt tdd |
| 最终结果核验 | Superpowers verification-before-completion | 按测试责任读取命令证据或用户验收确认,不把用户测试模式变成自动测试,不代替独立审查 |
| 实现测试循环 | Superpowers test-driven-development | 仅 Agent 测试模式按任务范围执行;范围为受影响代码的单元测试,不包含构建或整体运行验收。用户明确自测时不运行,不另叠加 Matt tdd |
| 最终结果核验 | Superpowers verification-before-completion | 按测试责任读取单元测试命令证据或用户对应该提交的确认;构建与整体验收留给用户,不把未执行的构建记为通过,也不代替独立审查 |
| 规格记录 | OpenSpec CLI/宿主入口 | 读取工程 config,按工程指定产物归属保存 |
| 新页面、新弹窗或视觉改版 | Figma MCP | 按[原型与确认流程](ui-design.md)核对用户选择、默认策略到期采用或明确豁免;实际授权与连接可用,按宿主必需技能操作 |
| 新页面、新弹窗或视觉改版 | Figma MCP | 调用前按[界面一致性、原型与确认流程](ui-design.md)取得并传递现行视觉依据,再核对版本选择或明确豁免;实际授权与连接可用,按宿主必需技能操作 |
读取实际候选技能及它引用的依赖后才决定调用。本版 doctor 的入口摘要匹配只说明一个文件相同,不能证明完整依赖已固定或行为兼容;候选组合仍待场景验收。
@@ -13,19 +13,19 @@ PR 创建/合并权限不足、连接失败或平台不支持时,保留任务
## 创建与续接
1. 核对工程实际仓库、工单、配置的目标分支与任务 head。先查同仓库、同 base/head 的现有 PR,再核对工单和差异;复用属于本任务的 PR,多个候选先澄清。中断重试也先查远端,不能仅凭标题新建重复 PR。若任务确需拆分多个 PR,先说明范围及依赖并记录关联;最终工单须覆盖全部验收范围。
2. 在 queue-guard 有效的前提下,推送前检查 `push_task_branch`,创建/更新 PR 前检查 `create_pr`;使用本 owner/issue 的 `permissions-check`,通过后再由 Agent 调用平台工具。标题说明具体变化;正文包含问题与预期行为、改动范围、普通关联工单链接、测试责任/提交/实际结果、集成验证状态和剩余风险。未验证内容明确标注待测试;平台支持时用草稿,否则在正文标明未就绪并保持不合并。普通关联不使用自动关闭工单的关键字,提交信息同样核对,避免提前跳过环境恢复。
2. 在 queue-guard 有效的前提下,推送前检查 `push_task_branch`,创建/更新 PR 前检查 `create_pr`;使用本 owner/issue 的 `permissions-check`,通过后再由 Agent 调用平台工具。标题说明具体变化;正文包含问题与预期行为、改动范围、普通关联工单链接、单元测试命令/提交/实际结果,以及“构建与整体验收由用户在合入后进行”。单元测试未通过时标明未就绪并保持不合并。普通关联不使用自动关闭工单的关键字,提交信息同样核对,避免提前跳过环境恢复。
3. 在现有任务/工单记录保存 PR 链接、仓库、base/head 分支与完整 SHA。宿主有 PR 附件能力时按其规则关联;不把附件创建当作远端 PR 已创建。持续更新同一 PR,完整读取其讨论、审查意见及相关图片,读取方式与材料信任边界沿用[工单上下文](issue-context.md)。工单与 PR 都是任务资料,不能覆盖用户授权。
## 审查、合并与恢复
当前工单必须归本会话所有;普通编号与队列均按[占用规则](issue-claims.md)执行守卫、共享环境和集成锁。多个工单可以各有 PR,合并按目标分支互斥;待合并 PR 不释放工单占用,也不能被其他会话重复实施。
- 按[交付流程](delivery.md)完成应有的审查,按[本机集成规则](local-validation.md#pr-合并前的候选验证)验证最新目标与任务 head 的等价合并候选。记录目标 SHA、任务 SHA、候选代码树及验证依据;用户自测继续由用户验收,不把 PR 存在、一分钟无回复或 CI 通过当作用户验收。独立 Agent 审查不等于平台要求的人工批准。
- 记录同提交验收及集成证据、进入 ready_to_merge 并取得集成锁进入 merging 后,再次读取 PR 的实际 base/head、可合并状态、未解决的阻塞意见和全部必需检查/批准。目标或任务提交变化则重新准备候选并按影响补验/复审;原证据覆盖不到的内容不得合并。检查等待中/失败或状态未知时保留待办;工程没有必需 CI 时可依据实际本地验证,不为使用 PR 强制安装 CI。
- 按[交付流程](delivery.md)完成应有的审查,按[本机集成规则](local-validation.md#pr-合并前的候选验证)核对最新目标与任务 head 的合并候选,并确认单元测试覆盖该代码树。记录目标 SHA、任务 SHA、候选代码树及单元测试依据;用户自测继续等用户确认该提交,不把 PR 存在、一分钟无回复或 CI 通过当作确认。独立 Agent 审查不等于平台要求的人工批准。不在合并前执行构建或运行冒烟。
- 记录同提交的单元测试及串行合入证据、进入 ready_to_merge 并取得集成锁进入 merging 后,再次读取 PR 的实际 base/head、可合并状态、未解决的阻塞意见和全部必需检查/批准。目标或任务提交变化则重新准备候选并重跑受影响单元测试;原证据覆盖不到的内容不得合并。检查等待中/失败或状态未知时保留待办;工程没有必需 CI 时依据单元测试证据合入,不为使用 PR 强制安装 CI 或先做构建。
- 在实际合并前再次通过 queue-guard 和本 owner/issue 的 `permissions-check --capability merge`,再根据仓库允许的 merge/squash/rebase 策略合并,使用工具可用的预期 head 等并发保护。无法保证目标仍对应验收候选时停止重算,不用强推补救。已有授权且条件齐全时 Agent 执行合并,无需额外人工点击。
- 请求超时或续接时先读回实际状态。必须确认 PR 已 merged、目标正确,并取得平台实际合并结果 SHA 和验收内容映射;仅 closed 且未合并不算完成,保留工单及恢复线索,查明关闭原因后按授权续接。合并结果还须可达目标远端分支。squash/rebase 允许 SHA 不同,须证明最终内容等价,不能拿任务 SHA 冒充最终提交。
- 远端合并后按[完成检查](completion.md)同步本地、恢复并核对环境,回写工单、关闭且读回,再按[worktree 生命周期](worktree-lifecycle.md)清理。环境恢复失败仍为未完成;平台若已自动关单也不能据此宣布成功,记录真实状态和缺口。PR 合并不授权删除远端分支;沿用现有清理策略。
- 远端合并后按[完成检查](completion.md)同步本地、回写工单、关闭且读回,再按[worktree 生命周期](worktree-lifecycle.md)清理。本单未改运行环境时记录未改动;改过而恢复失败则仍为未完成。平台若已自动关单也不能据此宣布成功,记录真实状态和缺口。PR 合并不授权删除远端分支;沿用现有清理策略。
直接合并例外仍执行同等测试、审查、集成验证、远端同步和关单检查,在证据中注明选择来源与无 PR 原因。PR 模式的合并证明写入 remote_synced 证据,最终报告给出 PR 链接及实际状态;缺证据时不能借直接模式补写完成。
直接合并例外仍执行同等单元测试、审查、串行合入、远端同步和关单检查,在证据中注明选择来源与无 PR 原因。构建验收同样留给用户。PR 模式的合并证明写入 remote_synced 证据,最终报告给出 PR 链接及实际状态;缺证据时不能借直接模式补写完成。
工单队列输入仍排除 PR,PR 是当前工单的交付记录。当前单完成或按队列规则标记阻塞后才推进下一单;停止优先于 PR 创建、推送、合并与清理。
@@ -1,10 +1,10 @@
# 确认问题的本地语音提醒
向用户发出需要答复的确认问题后,默认在用户使用的本机播放一次固定提示:“有问题待确认,请及时处理”。语音只提醒有待办,内容不含问题正文、账号或业务信息;实际选项与推荐仍通过文字发送。
向用户发出需要答复的确认问题后,默认在用户使用的本机播放一次固定提示:“有问题待确认,请及时处理”。语音只提醒有待办,内容不含问题正文、账号或业务信息。要确认的题目、推荐和未回复时的处理,按[问题展示](planning.md#问题展示)写在同一条用户可见回复的「待你确认」清单里。
## 触发与顺序
1. 先核对用户/工程的静音选择。实际发送问题后,记录该批问题的发送时间、推荐及范围,再播放;不能在问题尚未发出时声称已提醒。
1. 先核对用户/工程的静音选择。当前回复已经写上「待你确认」清单后,记录该批问题的发送时间、推荐及范围,再播放。回复里还没有这份清单时不播放,也不把这一轮当作已经发问。
2. 每批问题只播放一次,多题合并发送共用一次提醒。将“已尝试提醒/结果”记在现有任务记录;同一批等待、轮询、重试和会话恢复不重复播放。真正重新提出的新问题或用户要求再提醒时才播放下一次。
3. 执行以下已安装入口。它只读取随包预生成的本地 WAV,并调用播放器;不联网、不调用语音生成服务、不在提问时临时生成语音。命令最多等待播放器 12 秒,不另开后台常驻进程。
@@ -2,24 +2,24 @@
## 选择模式
默认由 Agent 测试。用户明确“我来测试/我自己验收”时,该任务切为用户测试;不是全工程永久切换。将模式、任务分支、worktree 路径和待验收提交记录在工单中,不重复询问已明确的选择。
默认由 Agent 在任务 worktree 运行单元测试。用户明确“我来测试/我自己验收”时,该任务切为用户测试;不是全工程永久切换。将模式、任务分支、worktree 路径和待验收提交记录在工单中,不重复询问已明确的选择。
模式决定执行责任,不改变验收条件。缺少环境、平台或必要证据时如实报告阻碍,不自动把未完成测试视为通过。
模式决定谁运行单元测试。整体构建、启动和关键路径由用户在代码合入集成分支之后自行验收。Agent 不把构建或运行冒烟当作 worktree 的完成条件,也不写成已经通过。缺少单元测试条件时如实报告阻碍,不把未运行的测试记为通过。
## Agent 测试(默认)
Agent 在保留本地改动的前提下自动切换到任务分支或进入对应 worktree,核对实际服务目录、端口和运行代码。分支切换与服务启停分别受授权约束;工程禁止启停服务时仍遵守,不能因自动测试而自行重启。
Agent 在保留本地改动的前提下进入任务分支或对应 worktree,对本次改动影响的代码运行单元测试,记录实际命令、提交、结果和缺口。到此为止:不执行构建、打包、启动服务、界面运行验收或集成冒烟。工程没有可运行的单元测试时,记录不适用及依据,并完成与改动相符的静态检查。
按影响运行测试、构建及必要的平台检查,记录实际命令、提交、结果和缺口。测试通过且审查无阻塞后执行[交付流程](delivery.md),不额外等待用户再说“合并”。失败或未完成时继续处理,不能合并或关单。
单元测试通过且审查无阻塞后,按[本机环境验证与集成](local-validation.md)串行合入工程配置的集成分支。这项流程里说的主分支,就是该集成分支。不额外等待用户再说“合并”。单元测试失败或尚未完成时留在该 worktree 修复,不合入、不关单。
## 用户测试(明确指定)
Agent 完成实现和代码审查,不运行自动测试、构建或替代性的业务验收;Git 状态、源码差异及提交核对仍执行。工单标注“待用户测试”,不能宣称已测试通过,也不提前合并、关闭。
Agent 完成实现和代码审查,不运行单元测试、构建或替代性的业务验收;Git 状态、源码差异及提交核对仍执行。工单标注“待用户测试”,不能宣称单元测试已通过,也不提前合入、关闭。
交付时明确提醒用户切到哪个任务分支,提供 worktree 路径、提交、测试入口、覆盖点和预期结果。核对已知运行目录,指出需要配套运行的前端/H5/后端;不能仅提示刷新页面而让用户测试旧主分支。用户要求 Agent 帮忙切分支时直接执行并保留本地改动,按工程规则由用户启停服务。
交付时明确提醒用户切到哪个任务分支,提供 worktree 路径、提交、测试入口、覆盖点和预期结果。用户要求 Agent 帮忙切分支时直接执行并保留本地改动。
交付用户测试前按[收尾记录](completion.md#关联与进度)保存待测版本和入口。用户明确确认任务提交通过后恢复原工单,记录通过依据并进入下述集成验证与收尾流程,仍需取得适用的集成环境验收证据。按默认启用的一分钟策略,且没有明确关闭或必须等待约束时,反馈问题同时推荐“若 60 秒无回复,转由 Agent 完成该版本的真实测试及视觉验证,通过后收尾”,按[统一策略](planning.md#提问推荐与一分钟默认选择)展示并计时;到期可切换并记录测试责任,再实际执行必要测试。不得将沉默记为“用户验收通过”,测试未做、失败或环境缺失时不合并关单。用户新近要求“必须等我”或继续报告问题时尊重指令与失败事实,修复后重新提供推荐与验证安排。
用户明确确认该提交的检查通过后,再进入串行合入。整体构建验收仍在合入之后由用户完成。按默认启用的一分钟策略,且没有明确关闭或必须等待约束时,反馈问题同时推荐“若 60 秒无回复,转由 Agent 完成该版本受影响代码的单元测试,通过后串行合入”,按[统一策略](planning.md#提问推荐与一分钟默认选择)展示并计时;到期只补跑单元测试。不得将沉默记为用户验收通过或构建已通过。用户新近要求“必须等我”或继续报告问题时尊重指令与失败事实,修复后重新提供推荐与验证安排。
## 合并与本机环境验证
## 合入与后续验收
按[本机环境验证与集成](local-validation.md)安排任务验证、合并结果检查、集成环境冒烟和运行环境恢复。相同代码树可复用完整测试结果,不能免除不同运行环境的验证;目标更新或冲突处理改变代码时按同一测试责任补验。用户模式下集成冒烟仍由用户执行,不把任务分支的通过自动推广到另一运行环境。必要验证未完成前不更新远端主分支或关闭工单。
按[本机环境验证与集成](local-validation.md)准备合并结果,并按[共享环境与合并排队](issue-claims.md#共享环境与合并排队)保证同一集成分支同时只有一个 worktree 在合入。目标更新或冲突处理改变代码时,重跑受影响单元测试,通过后再合入。合入记录写明构建与整体运行验收留给用户。必要单元测试未完成,或合并尚未按串行规则完成前,不更新远端集成分支,不关闭工单。
@@ -1,21 +1,30 @@
# 界面一致性、原型与验收
适用于界面实现和布局样式修改。新页面、新弹窗及用户要求的视觉改版须经过下述 Figma 确认;纯文案纠错、无视觉变化的后台逻辑和恢复已确认设计的局部修复不新增原型,按影响验证。
适用于界面设计、原型生成、界面实现和布局样式修改,包括只做 Figma 设计、临时 HTML 预览或用户明确免除原型后的直接编码。在调用设计/生成工具前就读取本规则,不等业务实现时再补参考。只要求设计时保持设计范围,不因此开始业务开发。新页面、新弹窗及用户要求的视觉改版须经过下述 Figma 确认;纯文案纠错、无视觉变化的后台逻辑和恢复已确认设计的局部修复不新增原型,按影响验证。
## 先确定视觉依据
在现有工单或设计记录中留下简短的“视觉依据”,不为每次任务新增永久报告:
已有工程默认延续目标应用现行风格。新增页面或“美化一下”本身不代表重做全站视觉;已有明确改版决定时按其范围执行。先完成以下核对,再开始画原型或生成界面:
- **同端同类参考**:查目标端规范、组件目录及相近业务,记录可用参考的路径/节点、适用场景。区分确认提示、表单编辑、选择器和详情;按交互类型选择,不能仅因名称含 Dialog 就套用。已有参考可运行时按测试责任查看实际效果;不可查看时说明缺口。
- **复用决定**:记录复用的组件与变体、Token 来源,以及本次差异及原因。优先复用符合规范的完整组件;只有能力缺口明确时才局部扩展或新增,避免复制一组 CSS 后混搭原生控件。没有同类参考时,以目标端规范建立候选设计。
- **冲突处理**:已明确的本任务用户决定与原型、工程现行规范共同作为依据;旧实现和旧 Token 是待核对证据。发现冲突先追溯适用范围,无法判定再询问具体差异,不凭最近找到的页面定标准。任务级视觉例外不自动修改全局规范。
1. **定位现行规则与代码**:查目标端规则、UI 规范、布局壳、组件目录、主题与 Token 配置,以及同类页面的实际调用。确认当前主题、组件变体和局部样式覆盖;不能只读框架名称或默认组件文档,就把库的默认样式当成工程风格。
2. **查看同端同类实际界面**:优先选一个最接近本任务的页面或组件,必要时补充参考,不要求扫描全站。区分确认提示、表单编辑、选择器和详情,不能只因名称含 Dialog 就套用。工程已在运行且允许查看时,先核对访问地址、路由、应用和可确定的运行版本,再按[测试责任](testing.md)查看实际效果;记录视口、主题、代表状态及截图或其他可回看的视觉证据。未知版本明确标注,不把其他应用、旧构建或设计稿冒充当前界面。不为取参考擅自启停或重启服务;环境操作遵循[本机环境规则](local-validation.md)。
3. **提取复用决定**:将布局与信息密度、导航/页头、字体层级、颜色、间距、圆角、边框/阴影、图标,以及受影响控件的尺寸与状态,映射到实际组件、变体和 Token 来源。只记录本任务相关项;没有 Token 的工程引用有效样式定义,不强制先建设设计系统。优先复用完整组件,能力缺口明确时才局部扩展或新增,不复制一组 CSS 后混搭默认控件。
4. **处理差异与缺口**:已明确的任务决定和工程现行规范共同限定范围;旧页面、旧 Figma 与旧 Token 都须核对适用性。存在多套风格时优先采用目标模块现行规范及有效参考,不从其他端随意混搭。无法判断且会影响设计时,只询问具体差异;无关部分可继续。新工程无参考时建立候选视觉方向并按原型流程确认,不能把已有工程误当空白工程自由发挥。
颜色、字号、间距、圆角及控件状态来自所选依据;Figma 也复用这些依据。跨端共享业务语义,各端保留其已确认设计体系。
在现有工单、设计记录或本次任务上下文中留下简短“视觉依据”,并在设计前向用户概述参考与复用选择,不另开一轮批准或新增永久报告。至少可追溯到:参考路由/页面/节点与视觉证据、组件和主题/样式路径、本次保留的特征、必要差异及原因、尚未验证项。单写“保持现有风格”不算完成。已有同任务证据仍适用时直接复用,主题、范围或版本改变后只补核对受影响部分。
无法访问运行界面时,先利用已有可信截图、组件示例和代码查证,并说明未观察的部分。用户自测或禁止 Agent 浏览/运行时遵守限制,使用已有材料或交付需要用户核对的项目,不擅自转成 Agent 测试。已有证据足以支持的设计可继续,不能声称已完成实际对照;关键风格无法确定时暂停依赖该选择的设计,只补必要信息,不用通用模板填补未知。
### 把视觉依据传给设计工具
向 Figma、其他已授权的设计/生成工具或执行者交接时,带上上述参考及复用映射、保留项和允许变更范围,不能只传业务需求或一句“现代简洁”。使用目标工具实际可访问的节点、素材或脱敏视觉说明;不可访问的链接不当作已读。参考采集不授权把运行中的业务数据上传到外部工具。
Figma 有匹配的组件库、变量或样式时优先复用并核对与当前工程的一致性;只有代码资产时,据已查证的视觉依据构建可编辑原型并保留映射,不虚称已经导入组件库。其他工具及直接编码沿用相同依据;更换工具或免除 Figma 不免除风格继承,也不自动替代下述 Figma 原型要求。跨端共享业务语义,各端保留其已确认设计体系。
## 先设计,再实现
1. 按上述步骤确定视觉依据与影响范围。简单、单字段、已有匹配组件或时间紧,都不免除本流程;复用资产应体现在原型中。
2. 使用 Figma MCP 创建可编辑原型,覆盖任务涉及的主要尺寸、输入、加载、错误和提交状态。检查文字、间距、按钮、滚动及安全区;向用户提供具体文件/节点链接和可查看的预览。
2. 使用 Figma MCP 创建可编辑原型,覆盖任务涉及的主要尺寸、输入、加载、错误和提交状态。检查文字、间距、按钮、滚动及安全区,并与选定的同端参考对照风格;修正未经决定的偏离后,向用户提供具体文件/节点链接和可查看的预览。展示复用依据及有意差异,不能把“Figma 生成成功”当成风格一致的证据。
3. 在工单或工程指定的设计入口记录原型链接、版本及“待原型确认”。原型完成并通过视觉检查后,给出采用该版本或修正问题的推荐,按[统一一分钟策略](planning.md#提问推荐与一分钟默认选择)处理;用户选择或满足默认策略条件到期采用后可进入业务界面实现,不再把原型列为必须人工回复的例外。记录具体版本与决定来源,已有同版决定直接沿用;工具生成成功本身不算验收。
4. 将原型元素映射到已选组件、变体和 Token,按确认的原型实施并完成下述视觉验收。若后续明显改变布局或交互,更新原型并按同一策略重新提交受影响部分;原型确认不替代最终功能验证。
@@ -31,11 +40,9 @@ Figma 连接或权限不可用时,说明实际阻碍,继续独立工作;
## 实现后的视觉验收
按[测试责任](testing.md)执行:Agent 模式实际打开待验收版本,在工程要求的尺寸与必要状态下截图,逐项比较“批准原型(若有)—实际界面—同端参考”;用户模式不代跑测试或构建,交付同样的视觉核对点、原型及准确版本,反馈与超时后的真实验证按测试责任处理。编译和功能测试通过不能替代视觉验收。
按[测试责任](testing.md)执行:worktree 内以受影响代码的单元测试为完成条件。实现时对照已确认原型、同端参考、组件和主题做源码级核对,列出布局、层级、字体、间距、颜色、按钮主次和受影响状态上的对应关系。不为此构建、启动应用或截取运行界面。运行后的整体视觉验收由用户在合入后的集成分支上完成;工单保留需要用户看的核对点,不把源码对照写成运行界面已经通过。
核对内容包括布局与信息层级、字体/间距/圆角/颜色、按钮尺寸与主次、原生控件默认边框/行高/内边距,以及空值、加载、失败、保存中、长内容、键盘和安全区等受影响状态。记录截图位置、运行版本、视口、差异与结论;临时截图遵守工程临时目录约定,使用合成或脱敏数据。必要渲染环境不可用时标为待视觉验证,不凭源码或 Figma 截图宣称实际界面通过。
实现偏离已确认设计时先修复;设计本身需要改变时按原型确认规则处理。明确免除 Figma 只省去原型,不省去视觉依据、组件复用和实际效果验证。
实现偏离已确认设计时先修复;设计本身需要改变时按原型确认规则处理。新原型若已偏离现行风格且没有对应改版决定,先纠正设计,不能因为实现与该原型相同就判定一致性通过。明确免除 Figma 只省去原型,不省去视觉依据和组件复用。运行后的效果由用户在合入后的集成分支上验收。
## 将有效设计留给下一次任务
@@ -23,7 +23,7 @@ python3 .agents/skills/personal-dev/scripts/pda.py update --project . --revision
python3 .agents/skills/personal-dev/scripts/pda.py update --project . --revision <完整提交> --apply
```
四条命令是不同选择,不能顺序全部执行。更新先预览具体差异;取得该版本的更新选择后才 apply。期间 main 前进仍固定原候选;来源改变、候选失效、本地受管文件有修改或安装协议不兼容时保留现场,重新核对。跳过只针对这一提交,稍后提醒延后 24 小时。更新不自动提交工程改动、不更新第三方插件或 MCP。
四条命令是不同选择,不能顺序全部执行。更新先预览具体差异;取得该版本的更新选择后才 apply。期间 main 前进仍固定原候选;来源改变、候选失效、本地受管文件有修改或安装协议不兼容时保留现场,重新核对。工程内 updater 遇到不认识的 `scripts/pda/*.py` 时停止,不改用半截下载;这种候选改从已审阅的个人仓库、固定提交执行 `init` 预览,确认后再 `init --apply`。受管文件和安装锁不一致时两边都不写入,先人工合并,不删除 lock。跳过只针对这一提交,稍后提醒延后 24 小时。更新不自动提交工程改动、不更新第三方插件或 MCP。
成功更新(或使用完整源码完成接入)后,读取工程 `docs/agents/README.md` 的“快速开始”,在最终答复展示至少一个适合当前用户的可复制案例、指南位置和必要注意事项。已有任务优先展示续接案例,说明新会话加载与占用核对;无任务时展示工单入口。提示词以指南为唯一来源,不把独立接入检查当成开发前必做步骤。旧版 updater 首次升级可能未输出快速开始,仍从已安装指南读取;预览、检查更新、跳过和失败不报告安装完成或引导立即续跑。
@@ -34,7 +34,7 @@
每次操作前重新核对,`eligible` 仅是上次检查结果。任一条件不满足或无法确认,记为 retained 并说明原因:
1. **交付已完成**:按[工单收尾](completion.md)取得真实验收、集成验证、远端同步、环境恢复和关单读回结果。完整运行时的普通单与队列单均完成 queue-finish;未建立远端占用的遗留普通单可用 delivery-check;未安装运行时的 Skill-only 普通单按 completion.md 完成同等实时证据的人工核对并标明方式,不能把现有脚本失败改称 Skill-only 绕过。用户自测未通过不符合。PR 模式另按[PR 证据规则](pull-requests.md)读回确已合并;closed 未合并或仍待合并的 PR 保留目录。再次核对关联工单未重开、最新反馈无未处理问题,目标远端提交可达且本地已同步;网络失败不能沿用旧成功记录。
1. **交付已完成**:按[工单收尾](completion.md)取得五项证据和关单读回结果。集成证据指单元测试仍覆盖最终代码树,并写明构建验收留给用户。完整运行时的普通单与队列单均完成 queue-finish;未建立远端占用的遗留普通单可用 delivery-check;未安装运行时的 Skill-only 普通单按 completion.md 完成同等实时证据的人工核对并标明方式,不能把现有脚本失败改称 Skill-only 绕过。用户自测未通过不符合。PR 模式另按[PR 证据规则](pull-requests.md)读回确已合并;closed 未合并或仍待合并的 PR 保留目录。再次核对关联工单未重开、最新反馈无未处理问题,目标远端提交可达且本地已同步;网络失败不能沿用旧成功记录。
2. **归属与占用明确**:目标是登记的同仓库 linked worktree,实际分支/提交与任务记录一致;不是主目录、共享工作区、其他活跃会话、用户保留或 Git locked 项。按[工单占用](issue-claims.md)核对远端 owner/阶段/资源、所有本地 worker 与台账,不能把本会话 current 为空当作其他会话没有使用。核对宿主附件及其他任务引用,路径改用、新分支、脱离预期仓库时保留。当前 shell 先切到主目录;当前会话仍必须在该目录继续执行或宿主无法安全解除占用时,延后清理。
3. **所有改动已保存**:检查暂存、未暂存、未跟踪、被忽略文件以及独有提交;子模块、嵌套仓库也要辨认,不以外层 status 干净替代。工作树有未提交修改时默认保留,不为清理自动提交、stash、reset 或丢弃。任务提交应已被目标远端集成分支包含;squash/rebase 不能仅凭祖先关系判断失败或成功,核对最终变更映射、PR 和验收,并为原任务提交保留可恢复分支/宿主快照。无法证明完整交付则保留。
4. **非版本文件已有去向**:逐项识别未跟踪及忽略文件。唯一 `.env`、Agent 凭据、上传文件、数据卷、报告和日志证据需移交到工程既有受控位置,验证完整性与引用后才可清理,已有文件不覆盖。仅有可再生成的依赖/缓存/产物时,确认无需保留及无运行引用后按工程已有本地文件清理授权逐项处理;未获授权的文件或用途不明项保留。符号链接只处理本链接,不递归清理链接目标;不能为了让 remove 成功执行全目录强制清空。
+8 -4
View File
@@ -16,7 +16,7 @@ def main():
'queue-start', 'queue-stop', 'queue-status', 'queue-next', 'queue-claim',
'queue-resource-acquire', 'queue-resource-release', 'queue-legacy-stop', 'queue-transfer',
'queue-guard', 'queue-cleanup-guard', 'queue-checkpoint', 'queue-block', 'queue-retry', 'queue-finish',
'delivery-status', 'delivery-record', 'delivery-check',
'delivery-status', 'delivery-record', 'delivery-check', 'task-next',
'watch-start', 'watch-bind', 'watch-tick', 'watch-stop', 'watch-status'))
parser.add_argument('--project', required=True, type=Path)
parser.add_argument('--apply', action='store_true', help='apply previewed init/recover/update or queue-start/watch-start/queue-transfer/permissions-grant/permissions-revoke changes')
@@ -42,17 +42,17 @@ def main():
parser.add_argument('--capability', help='comma-separated capabilities to check')
args = parser.parse_args()
queue_fields = {'thread': ('watch-start', 'watch-tick'), 'scheduler': ('watch-bind', 'watch-tick'),
'generation': ('watch-bind', 'watch-tick'), 'interval': ('watch-start',), 'owner': ('permissions-status','permissions-grant','permissions-revoke','permissions-check','queue-start','queue-next','queue-guard','queue-cleanup-guard','queue-checkpoint','queue-block','queue-retry','queue-finish','queue-status','queue-stop','queue-claim','queue-resource-acquire','queue-resource-release','queue-transfer'),
'generation': ('watch-bind', 'watch-tick'), 'interval': ('watch-start',), 'owner': ('permissions-status','permissions-grant','permissions-revoke','permissions-check','queue-start','queue-next','queue-guard','queue-cleanup-guard','queue-checkpoint','queue-block','queue-retry','queue-finish','queue-status','queue-stop','queue-claim','queue-resource-acquire','queue-resource-release','queue-transfer','task-next'),
'stage': ('queue-checkpoint', 'delivery-record'), 'branch': ('queue-checkpoint',),
'worktree': ('queue-checkpoint',), 'commit': ('queue-checkpoint','queue-finish','delivery-record','delivery-check'),
'reason': ('queue-block',), 'issue': ('permissions-grant','permissions-revoke','permissions-check','queue-start','queue-claim','queue-retry','delivery-status','delivery-record','delivery-check'),
'reason': ('queue-block',), 'issue': ('permissions-grant','permissions-revoke','permissions-check','queue-start','queue-claim','queue-retry','delivery-status','delivery-record','delivery-check','task-next'),
'evidence': ('permissions-grant','permissions-revoke','queue-finish','delivery-record','queue-transfer'), 'target_owner': ('queue-transfer',),
'resource': ('queue-resource-acquire','queue-resource-release'),
'allow': ('permissions-grant',), 'deny': ('permissions-grant',), 'capability': ('permissions-check',)}
for field, actions in queue_fields.items():
if getattr(args, field) is not None and args.action not in actions:
parser.error('--' + field + ' is not valid for this action')
if args.action in queue_fields['owner'] and args.action not in ('queue-status','queue-stop') and not args.owner:
if args.action in queue_fields['owner'] and args.action not in ('queue-status','queue-stop','task-next') and not args.owner:
parser.error('--owner is required for this workflow action')
if args.all and (args.action != 'queue-stop' or args.owner):
parser.error('--all requires queue-stop without --owner')
@@ -78,6 +78,10 @@ def main():
from pda.notifications import notify
print(json.dumps(notify(package), ensure_ascii=False, indent=2))
return 0
if args.action == 'task-next':
from pda.task_next import task_next
print(json.dumps(task_next(root, args.owner, args.issue), ensure_ascii=False, indent=2))
return 0
if args.action.startswith('watch-'):
from pda.issue_watch import dispatch
print(json.dumps(dispatch(root, args), ensure_ascii=False, indent=2))
@@ -38,7 +38,14 @@ def validate(config, template):
'rules': None, 'validation': checks}
def shape(value, sample, key):
if isinstance(sample, dict):
if not isinstance(value, dict) or value.keys() != sample.keys():
if not isinstance(value, dict):
raise InstallError("invalid config keys: " + key)
# Project-owned figma may keep extra authority/archive/notes fields;
# require template keys and leave unknown siblings untouched.
if key == "config.figma":
if not set(sample).issubset(value):
raise InstallError("invalid config keys: " + key)
elif value.keys() != sample.keys():
raise InstallError("invalid config keys: " + key)
for name in sample:
shape(value[name], sample[name], key + "." + name)
@@ -97,7 +97,7 @@ def plan(root, package, source=None):
}
blocks = {
"AGENTS.md": (BEGIN, END,
"工程建设、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,读取 "
"工程建设、界面设计或原型、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,读取 "
"[个人开发入口](.agents/skills/personal-dev/SKILL.md),"
"并按需查阅 [工程配置](docs/agents/README.md)。"
"业务约束继续遵循目标目录规则;纯咨询无需启动开发流程。"),
@@ -381,6 +381,24 @@ class Queue:
DELIVERY_DIRECTORY = '.local/agent/delivery'
DELIVERY_STEPS = ('accepted', 'integration_verified', 'remote_synced', 'environment_restored', 'issue_updated')
_UNIT_TEST_LOCATOR = re.compile(
r'单元测试命令|pytest|go test|npm test|pnpm test|yarn test|cargo test|mvn test|gradle test|ctest|python3 -m unittest|用户确认')
_SKIPPED_TEST = re.compile(r'不适用|not applicable|(?<![A-Za-z])N/A(?![A-Za-z])', re.I)
UNIT_TEST_DEFERRED = '无单元测试,留给用户验收'
INTEGRATION_NOTE = 'integration_verified 只表示单元测试仍覆盖即将合入的代码树,不是构建验收。'
def validate_stage_evidence(stage, evidence):
"""Reject placeholders. This does not run tests or judge that the locator is true."""
text = evidence.strip()
if stage == 'accepted':
if _SKIPPED_TEST.search(text):
raise InstallError('skipped unit tests must say ' + UNIT_TEST_DEFERRED)
if UNIT_TEST_DEFERRED not in text and not _UNIT_TEST_LOCATOR.search(text):
raise InstallError('accepted evidence must locate the unit-test command and result, name 用户确认, or say ' + UNIT_TEST_DEFERRED)
elif stage == 'integration_verified':
if '单元测试' not in text or '构建验收留给用户' not in text:
raise InstallError('integration_verified must say the unit-test tree still matches and 构建验收留给用户')
class Delivery:
@@ -444,6 +462,7 @@ class Delivery:
raise InstallError('unknown delivery stage')
if not isinstance(evidence, str) or not evidence.strip() or not evidence.isprintable() or len(evidence) > 1000:
raise InstallError('evidence locator required; never store credentials or issue bodies')
validate_stage_evidence(stage, evidence)
with self._locked():
old = self.status(number)['record']
if old is None or old['commit'] != commit:
@@ -463,7 +482,10 @@ class Delivery:
value['steps'][stage] = evidence
value.update(waiting=None, last_verified=None)
self._save(number, value)
return self.status(number)
result = self.status(number)
if stage == 'integration_verified':
result = dict(result, integration_verified_means=INTEGRATION_NOTE)
return result
def check(self, number, commit, tracker, lease):
with self._locked():
@@ -240,6 +240,11 @@ class Tracker:
if not value:
return records
records.extend(value)
# Gitea comment pages end when a page is shorter than the limit.
# The following request is not a reliable empty page.
if (self.kind == 'gitea' and suffix.endswith('/comments')
and len(value) < PAGE_SIZE):
return records
raise InstallError('issue tracker pagination limit exceeded')
def issues(self):
@@ -0,0 +1,117 @@
"""Read-only next step for one issue. Does not claim, test, merge, or write state."""
from .issue_queue import Delivery, INTEGRATION_NOTE, configuration, git, main_project
from .issue_workers import WorkerQueue
from .transaction import InstallError
ASK = '需要新的决定时,先在当前回复写出「待你确认」,再播放提示音。本命令看不到聊天正文。'
GUIDE = {
'claimed': ('references/implementation.md', '核对验收条件、集成分支和修改端。修改端未知时先写「待你确认」。'),
'planning': ('references/planning.md', '范围已明确则进入实现。仍有关键未知项时先写「待你确认」。'),
'implementing': ('references/implementation.md', '在任务 worktree 完成实现。然后只跑受影响代码的单元测试。'),
'testing': ('references/testing.md', '把单元测试命令和结果写入 accepted。没有测试可跑时写明无单元测试,留给用户验收。'),
'waiting_user': ('references/planning.md', '等待已经展示的确认。不重复播放提示音,不把沉默写成验收通过。'),
'reviewing': ('references/roles.md', '重要变更做独立审查。审查没有阻塞后准备串行合入。'),
'ready_to_merge': ('references/local-validation.md', '取得该集成分支的集成锁后再合入。其他 worktree 继续等待。'),
'merging': ('references/issue-claims.md', '完成这一次合入、同步和回写后再释放集成锁。'),
'blocked': ('references/issue-queue.md', '按已记录的阻塞原因处理。未恢复前不领取下一单。'),
'done': ('references/worktree-lifecycle.md', '核对关单读回,再检查本任务 worktree 是否可以回收。'),
}
DELIVERY_GUIDE = {
'accepted': ('references/testing.md', '记录单元测试命令和结果,或写明无单元测试,留给用户验收。'),
'integration_verified': ('references/local-validation.md', '写明单元测试仍覆盖即将合入的代码树,并且构建验收留给用户。'),
'remote_synced': ('references/pull-requests.md', '同步远端集成分支,并保存 PR 或直接合并的读回结果。'),
'environment_restored': ('references/local-validation.md', '本单没有切换运行环境时,记录未改动。'),
'issue_updated': ('references/completion.md', '回写并关闭工单。回复写明单元测试结果、合入提交,以及构建验收留给用户。'),
}
def _quiet(root, *args):
try:
return git(root, *args)
except InstallError:
return None
def _git_facts(root):
porcelain = _quiet(root, 'status', '--porcelain')
return {
'branch': _quiet(root, 'rev-parse', '--abbrev-ref', 'HEAD'),
'head': _quiet(root, 'rev-parse', 'HEAD'),
'dirty': None if porcelain is None else bool(porcelain),
}
def _current(root, owner, issue):
if owner:
state = WorkerQueue(root, owner=owner).status()
current = state.get('current') or {}
if issue is not None and current.get('number') not in (None, issue):
raise InstallError('owner current issue does not match --issue')
number = issue if issue is not None else current.get('number')
if current.get('number') == number:
return number, current.get('stage'), current.get('commit'), None
return number, None, None, None
if issue is not None:
return issue, None, None, None
currents = []
for state in WorkerQueue(root).status().get('workers', []):
current = state.get('current')
if current:
currents.append((state.get('owner'), current))
if len(currents) > 1:
return None, None, None, [item[0] for item in currents]
if len(currents) == 1:
owner_name, current = currents[0]
return current.get('number'), current.get('stage'), current.get('commit'), None
return None, None, None, None
def task_next(root, owner=None, issue=None):
root = main_project(root)
facts = _git_facts(root)
number, stage, commit, owners = _current(root, owner, issue)
base = {
'status': 'ok', 'issue': number, 'step': stage, 'owner': owner,
'can_merge': False, 'missing': [], 'git': facts,
'ask': ASK, 'merge': INTEGRATION_NOTE,
}
if owners:
base.update(status='ambiguous', step='claim', missing=['owner'],
next='多个会话各有当前工单。指定 --owner 后再看下一步。',
read='references/issue-claims.md', owners=owners)
return base
if number is None:
base.update(status='idle', step='claim', missing=['issue'],
next='没有当前工单。用户给出编号后领取;没有编号时先创建工单。',
read='references/issue-claims.md')
return base
try:
_, _, repository, branch = configuration(root)
delivery = Delivery(root, repository, branch).status(number)
except InstallError as exc:
base.update(status='needs_config', missing=['project configuration'],
next='先补齐工程的工单仓库和集成分支配置,再继续这个工单。',
read='references/planning.md', config_error=str(exc))
return base
record = delivery.get('record') or {}
steps = record.get('steps') or {}
missing = list(delivery.get('missing') or [])
evidence_commit = record.get('commit')
if commit and evidence_commit and commit != evidence_commit:
missing = ['evidence_commit_mismatch', *missing]
covered = bool(steps.get('accepted') and steps.get('integration_verified')
and evidence_commit and 'evidence_commit_mismatch' not in missing
and stage not in ('waiting_user', 'blocked', 'done'))
if stage in GUIDE:
read, nxt = GUIDE[stage]
else:
read, nxt = DELIVERY_GUIDE['issue_updated']
if stage in (None, 'testing', 'reviewing') or (stage in ('ready_to_merge', 'merging') and not covered):
focus = next((DELIVERY_GUIDE[item] for item in missing if item in DELIVERY_GUIDE), None)
if focus:
read, nxt = focus
if stage == 'waiting_user':
base['ask'] = '等待已经展示的确认。不重复播放提示音。'
base.update(step=stage or 'delivery', next=nxt, read=read, missing=missing,
can_merge=covered, evidence_commit=evidence_commit)
return base
@@ -9,7 +9,7 @@ import stat
import tempfile
RUNTIME = ('scripts/pda.py',) + tuple('scripts/pda/' + name + '.py' for name in (
'__init__', 'config', 'install', 'transaction', 'source', 'remote', 'updates', 'issue_queue', 'issue_tracker', 'issue_watch', 'notifications', 'issue_claims', 'issue_workers', 'worker_dispatch', 'permissions'))
'__init__', 'config', 'install', 'transaction', 'source', 'remote', 'updates', 'issue_queue', 'issue_tracker', 'issue_watch', 'notifications', 'issue_claims', 'issue_workers', 'worker_dispatch', 'permissions', 'task_next'))
ASSETS = ('assets/question-pending.wav',)
JOURNAL = ".local/agent/install/transaction.json"
+3 -1
View File
@@ -3,4 +3,6 @@ description: 个人开发体系的工程任务入口
alwaysApply: true
---
工程建设、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,先读根 AGENTS.md,再读取工程根目录的 .agents/skills/personal-dev/SKILL.md 进行任务分级。仅咨询概念时直接回答。业务约束仍按目标目录的 AGENTS.md 查阅。
工程建设、界面设计或原型、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,先读根 AGENTS.md,再读取工程根目录的 .agents/skills/personal-dev/SKILL.md 进行任务分级。仅咨询概念时直接回答。业务约束仍按目标目录的 AGENTS.md 查阅。
需要用户确认时,先在当前回复写出「待你确认」清单,再播放提示音。清单的写法以该 Skill 的问题展示规则为准。回复里没有这份清单时,不播放提示音。
+7
View File
@@ -0,0 +1,7 @@
{
"plugins": {
"figma": {
"enabled": true
}
}
}
+1 -1
View File
@@ -62,5 +62,5 @@
- 更改规则应同步唯一的对应入口,不在多个文件复制完整细则。
<!-- personal-dev:begin -->
工程建设、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,读取 [个人开发入口](.agents/skills/personal-dev/SKILL.md),并按需查阅 [工程配置](docs/agents/README.md)。业务约束继续遵循目标目录规则;纯咨询无需启动开发流程。
工程建设、界面设计或原型、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,读取 [个人开发入口](.agents/skills/personal-dev/SKILL.md),并按需查阅 [工程配置](docs/agents/README.md)。业务约束继续遵循目标目录规则;纯咨询无需启动开发流程。
<!-- personal-dev:end -->
+33
View File
@@ -49,3 +49,36 @@ SQL 位于 [docs/sql](../docs/sql/)。当前初始化基线不完整,部分历
- 运行受影响测试;测试数量随代码变化,不把旧报告视为当前通过证明。
- CRUD 修改从根目录运行 `python backend/scripts/check_field_consistency.py`,同时人工核对新增或未注册实体;已知覆盖限制见[字段一致性指南](../docs/dev-field-consistency.md)。
- `scripts/` 中的 migration、seed、reset、clear、import 工具可能写库,先读用途和数据范围,不批量执行。
## 项目归属启用
项目组的区域、医院仅作资料记录;不参与细胞存储和科研捐赠的业务地点、权限校验。
切换到项目统计版本前,先在已授权数据库执行增量结构脚本
[2026-11-02-project_management.sql](../docs/sql/migrations/2026-11-02-project_management.sql),
再依次执行[菜单种子](../docs/sql/migrations/2026-11-02-project_management_menu_seed.sql)与
[项目一级菜单迁移](../docs/sql/migrations/2026-11-02-project_top_menu.sql),
再执行[全部项目权限种子](../docs/sql/migrations/2026-11-02-project_all_permission.sql),
最后执行[项目管理分组迁移](../docs/sql/migrations/2026-11-04-project_management_group.sql)。
已执行前四个脚本的数据库只需追加分组迁移;完整重跑时也须将分组迁移放在最后。
“项目”与“市场、生产、质检”平级,下设“项目管理”目录,其下包含项目组管理、项目管理、业务统计、历史归属维护。
分组迁移保留四个页面的 ID 和权限,仅为已授权子页面/编辑按钮的角色补齐祖先;
旧 `/project/{页面}` 和 `/market/project-management/{页面}` 地址跳转至 `/project/management/{页面}`。
菜单种子不会给角色新增业务权限,应在现有角色管理分配项目管理权限及祖先菜单;
一级菜单迁移保留已有叶子 ID/授权,仅给已授权项目子菜单的角色补齐新祖先。
“所属项目”默认只列出本人担任负责人或成员的项目组内项目(多组取并集)。
需要跨组选择时,在“系统管理 → 角色管理 → 菜单权限 → 项目 → 全部项目”显式授予
`project:project:all`;多角色与职位继承权限取有效角色并集。已有归属维护权限、普通管理员身份不会自动获得该权限。
超级管理员沿用系统全部权限语义,但所有候选仍限当前租户。新建仅列启用且支持该业务的项目;
历史筛选叠加原订单数据权限,可保留组内停用项目。全部项目权限不授予订单查看、项目资料编辑或归属维护权限。
授权配置入口同时加固:用户、角色、职位和菜单变更分别需要已有的 `system:user:list`、
`system:role:list`、`system:post:list`、`system:menu:list` 权限(超级管理员保留全权限),
避免普通业务管理员自行绑定角色或修改权限码。质检金额专用角色授权的三个写入口也校验
`biz:qcMaterialAmountAuth:list`,防止经专用角色绕过上述限制。配置保存校验当前租户,角色菜单自动补齐祖先,
不自动勾选其他功能。升级前应核对实际配置人员已获对应系统管理权限。
脚本不回填历史业务,不代表空库初始化;执行前备份并在隔离 MySQL 环境验证。
`PROJECT_ASSIGNMENT_REQUIRED` 默认 `false`,便于先配置项目组和项目、兼容历史客户端。
人员和启用项目配置齐全后由运维将其设为 `true`;新建预签约及四类正式业务必须选择项目。
普通编辑保留历史归属,历史补录/调整须通过归属维护并填写原因;受控业务转换继承原归属。
该开关同时返回给 Web 和移动表单,调整配置后的服务重启由用户执行。
+3
View File
@@ -46,7 +46,10 @@ from app.api.customer_service_dict import router as customer_service_dict_router
from app.api.sample_cert_insurance import router as sample_cert_insurance_router
from app.api.sample_code_correction import router as sample_code_correction_router
from app.api.project_management import router as project_management_router
__all__ = [
"project_management_router",
"public_router",
"menu_router",
"admin_router",
+44 -24
View File
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings
from app.core.data_scope import apply_data_scope, _get_child_dept_ids
from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code
from app.core.menu_guard import require_admin_or_super_admin, require_menu_code, require_strict_menu_code
from app.core.response import success_response, error_response
from app.core.storage import storage
from app.crud import tenant as tenant_svc
@@ -27,7 +27,8 @@ from app.crud import menu as menu_svc
from app.crud import role as role_svc
from app.crud import user as user_svc
from app.crud.collection_protocol import _lookup_attachment_by_url
from app.models.models import BizAttachment, SysDept, SysRole, SysUser
from app.models.models import BizAttachment, SysDept, SysRole, SysUser, SysMenu, SysPost
from app.services.permission_administration import validate_tenant_references
from app.services.web_auth_roles import (
_map_role_name_to_web,
list_user_ids_with_effective_role_name,
@@ -238,12 +239,12 @@ def _get_tenant_no(db: Session, user: dict) -> str:
def _assert_user_tenant_access(db: Session, user: dict, target_user_id: str):
"""同租户或 super_admin 才可写操作用户;返回目标用户行。"""
"""用户管理始终在当前认证租户内操作;返回目标用户行。"""
row = user_svc.get_user(db, target_user_id)
if not row:
raise ValueError("用户不存在")
tenant_no = _get_tenant_no(db, user)
if row.tenant_no != tenant_no and user.get("role") != "super_admin":
if row.tenant_no != tenant_no:
raise ValueError("无权操作其他租户的用户")
return row
@@ -383,11 +384,13 @@ async def get_user_by_id(
@router.post("/users")
async def create_user(
body: UserCreateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:user:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
validate_tenant_references(db, SysRole, body.role_ids, tenant_no)
validate_tenant_references(db, SysPost, body.post_ids, tenant_no)
if user_svc.user_no_exists(db, body.user_no, tenant_no):
return error_response(400, "用户账号已存在")
obj = user_svc.create_user(
@@ -421,11 +424,14 @@ async def create_user(
async def update_user(
user_id: str,
body: UserUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:user:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
_assert_user_tenant_access(db, user, user_id)
tenant_no = _get_tenant_no(db, user)
validate_tenant_references(db, SysRole, body.role_ids, tenant_no)
validate_tenant_references(db, SysPost, body.post_ids, tenant_no)
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
update_kwargs: dict[str, Any] = {
"nick_name": payload.get("nick_name"),
@@ -457,7 +463,7 @@ async def update_user(
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:user:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -471,7 +477,7 @@ async def delete_user(
@router.post("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: str,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:user:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -971,7 +977,7 @@ async def list_posts(
@router.post("/posts")
async def create_post(
body: PostCreateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -995,10 +1001,11 @@ async def create_post(
async def update_post(
post_id: str,
body: PostUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysPost, [post_id], _get_tenant_no(db, user))
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
obj = post_svc.update_post(
db,
@@ -1018,10 +1025,11 @@ async def update_post(
@router.delete("/posts/{post_id}")
async def delete_post(
post_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysPost, [post_id], _get_tenant_no(db, _user))
post_svc.soft_delete_post(db, post_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -1052,7 +1060,7 @@ async def list_post_bound_users(
async def update_post_bound_users(
post_id: str,
body: PostUsersUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
@@ -1092,7 +1100,7 @@ async def list_post_bound_roles(
async def update_post_bound_roles(
post_id: str,
body: PostRolesUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:post:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
@@ -1353,11 +1361,13 @@ async def list_menus(
@router.post("/menus")
async def create_menu(
body: MenuCreateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:menu:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
tenant_no = _get_tenant_no(db, user)
if body.parent_id and body.parent_id != "0":
validate_tenant_references(db, SysMenu, [body.parent_id], _get_tenant_no(db, user))
obj = menu_svc.create_menu(
db,
menu_name=body.menu_name,
@@ -1383,11 +1393,14 @@ async def create_menu(
async def update_menu(
menu_id: str,
body: MenuUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:menu:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysMenu, [menu_id], _get_tenant_no(db, user))
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
if body.parent_id and body.parent_id != "0":
validate_tenant_references(db, SysMenu, [body.parent_id], _get_tenant_no(db, user))
obj = menu_svc.update_menu(
db,
menu_id,
@@ -1412,10 +1425,11 @@ async def update_menu(
@router.delete("/menus/{menu_id}")
async def delete_menu(
menu_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:menu:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysMenu, [menu_id], _get_tenant_no(db, _user))
menu_svc.soft_delete_menu(db, menu_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -1475,7 +1489,7 @@ async def list_role_management(
@router.post("/role-management")
async def create_role_management(
body: RoleCreateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -1499,10 +1513,11 @@ async def create_role_management(
async def update_role_management(
role_id: str,
body: RoleUpdateBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysRole, [role_id], _get_tenant_no(db, user))
payload: dict[str, Any] = body.model_dump(exclude_unset=True, by_alias=False)
obj = role_svc.update_role(
db,
@@ -1522,10 +1537,11 @@ async def update_role_management(
@router.delete("/role-management/{role_id}")
async def delete_role_management(
role_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysRole, [role_id], _get_tenant_no(db, _user))
role_svc.soft_delete_role(db, role_id)
return success_response(None, message="删除成功")
except ValueError as e:
@@ -1535,9 +1551,12 @@ async def delete_role_management(
@router.get("/role-management/{role_id}/menus")
async def get_role_menus(
role_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
role = role_svc.get_role(db, role_id)
if not role or role.tenant_no != _get_tenant_no(db, _user):
return error_response(404, "角色不存在")
menu_ids = role_svc.get_role_menu_ids(db, role_id)
return success_response({"menuIds": menu_ids})
@@ -1546,7 +1565,7 @@ async def get_role_menus(
async def update_role_menus(
role_id: str,
body: RoleMenuBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
@@ -1560,11 +1579,11 @@ async def update_role_menus(
@router.get("/role-management/{role_id}/data-scope")
async def get_role_data_scope(
role_id: str,
_user: dict = Depends(require_admin_or_super_admin),
_user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
role = role_svc.get_role(db, role_id)
if not role:
if not role or role.tenant_no != _get_tenant_no(db, _user):
return error_response(404, "角色不存在")
details = role_svc.get_role_data_scope(db, role_id)
return success_response({"dataScope": role.data_scope or "1", "scopeDetails": details})
@@ -1574,10 +1593,11 @@ async def get_role_data_scope(
async def update_role_data_scope(
role_id: str,
body: RoleDataScopeBody,
user: dict = Depends(require_admin_or_super_admin),
user: dict = Depends(require_strict_menu_code("system:role:list", allow_super_admin=True)),
db: Session = Depends(get_db),
):
try:
validate_tenant_references(db, SysRole, [role_id], _get_tenant_no(db, user))
tenant_no = _get_tenant_no(db, user)
role_svc.update_role_data_scope(
db,
+89 -2
View File
@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from sqlalchemy import and_ as sa_and, func as sa_func
from app.services.project_assignment import create_assignment, guard_project_edit
from app.core.database import get_db
from app.core.data_scope import get_user_dept_id
from app.core.menu_guard import require_admin_or_super_admin
@@ -15,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,
@@ -118,6 +120,8 @@ def _parse_datetime_fields(d: dict, camel_keys: list) -> None:
@router.get("/collection-protocols")
async def list_collection_protocols(
projectId: Optional[str] = Query(None, alias="projectId"),
teamId: Optional[str] = Query(None, alias="teamId"),
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
@@ -132,6 +136,8 @@ async def list_collection_protocols(
tenant_no = _get_tenant_no(db, user)
items, total = cp_svc.list_collection_protocols(
db,
project_id=projectId,
team_id=teamId,
page=page,
page_size=pageSize,
keyword=keyword,
@@ -172,6 +178,7 @@ async def get_collection_protocol_detail(
class ProtocolCreateBody(BaseModel):
project_id: Optional[str] = Field(None, alias="projectId")
model_config = ConfigDict(populate_by_name=True)
protocol_no: Optional[str] = Field(None, alias="protocolNo")
@@ -199,6 +206,7 @@ class ProtocolCreateBody(BaseModel):
class ProtocolUpdateBody(BaseModel):
project_id: Optional[str] = Field(None, alias="projectId")
model_config = ConfigDict(populate_by_name=True)
protocol_no: Optional[str] = Field(None, alias="protocolNo")
@@ -377,6 +385,8 @@ async def create_collection_protocol(
user_id = _get_current_user_id(user)
protocol_id: Optional[str] = None
try:
if body.source_presign_order_id:
raise ValueError("请通过预签约转正式入口创建关联协议")
protocol_attachments = _resolve_protocol_attachments(
body.protocol_attachments,
body.attachments,
@@ -394,6 +404,7 @@ async def create_collection_protocol(
collector = user_id
obj = cp_svc.create_collection_protocol(
db,
project_id=create_assignment(db, tenant_no, user, body.project_id, "cell_storage"),
create_dept_id=get_user_dept_id(db, user_id) or "",
protocol_no=body.protocol_no or "",
protocol_no_id=body.protocol_no_id,
@@ -432,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,
@@ -443,7 +464,7 @@ async def create_collection_protocol(
else None
),
)
return success_response(cp_svc._protocol_to_dict(obj), message="创建成功")
return success_response(cp_svc._protocol_to_dict(obj, db), message="创建成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@@ -463,6 +484,13 @@ async def update_collection_protocol(
return error_response(404, "协议单不存在或无权操作")
try:
payload = body.model_dump(exclude_unset=True, by_alias=False)
current = db.query(BizCollectionProtocol).filter(
BizCollectionProtocol.id == protocol_id, BizCollectionProtocol.tenant_no == tenant_no,
BizCollectionProtocol.del_flag == 0).with_for_update().first()
guard_project_edit(current, payload)
if "source_presign_order_id" in payload and (payload["source_presign_order_id"] or None) != current.source_presign_order_id:
raise ValueError("来源预签约不可修改")
payload.pop("project_id", None)
sub_keys = {
"party_customer", "collection_info", "samples",
"payment", "payment_details", "sample_payments", "attachments",
@@ -493,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,
@@ -523,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,
@@ -534,7 +575,7 @@ async def update_collection_protocol(
else None
),
)
return success_response(cp_svc._protocol_to_dict(obj), message="更新成功")
return success_response(cp_svc._protocol_to_dict(obj, db), message="更新成功")
except ValueError as e:
db.rollback()
return error_response(400, str(e))
@@ -551,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))
@@ -610,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,
@@ -618,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="审核已提交")
@@ -642,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})
# ---------------------------------------------------------------------------
# 收款确认
# ---------------------------------------------------------------------------
+32
View File
@@ -68,6 +68,8 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None:
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
project_id: Optional[str] = Query(None, alias='projectId'),
team_id: Optional[str] = Query(None, alias='teamId'),
order_status: Optional[str] = Query(None, alias="orderStatus"),
user: dict = Depends(permission_guard),
db: Session = Depends(get_db),
@@ -80,6 +82,8 @@ def _register_presign_routes(scope_kind: ScopeKind) -> None:
page=page,
page_size=page_size,
keyword=keyword,
project_id=project_id,
team_id=team_id,
order_status=order_status,
)
items = [
@@ -331,6 +335,8 @@ def _register_protocol_routes(scope_kind: ScopeKind) -> None:
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100, alias='pageSize'),
keyword: Optional[str] = None,
project_id: Optional[str] = Query(None, alias='projectId'),
team_id: Optional[str] = Query(None, alias='teamId'),
source: Optional[Literal['cell_storage', 'research_donation']] = None,
protocol_status: Optional[str] = Query(None, alias='protocolStatus'),
create_time_start: Optional[str] = Query(None, alias='createTimeStart'),
@@ -352,6 +358,8 @@ def _register_protocol_routes(scope_kind: ScopeKind) -> None:
page=page,
page_size=page_size,
keyword=keyword,
project_id=project_id,
team_id=team_id,
source=source,
protocol_status=protocol_status,
create_time_start=create_time_start,
@@ -1093,3 +1101,27 @@ def _register_attachment_routes(scope_kind: ScopeKind) -> None:
_register_attachment_routes('specialist')
_register_attachment_routes('manager')
def _register_project_filter_routes(scope_kind: ScopeKind) -> None:
from app.core.auth import get_current_user
from fastapi import HTTPException
from app.services import project_management
from app.services.market_field_scope import apply_presign_scope, apply_cell_protocol_scope, apply_donation_scope
@router.get(f'/mini/market/{scope_kind}/project-options')
def project_filter_options(bizType: Literal['presign', 'cell_storage', 'research_donation'],
user: dict = Depends(get_current_user), db: Session = Depends(get_db)):
module = 'presign' if bizType == 'presign' else 'protocol'
if not _has_menu(db, user, f'mini:market:{scope_kind}:{module}'):
raise HTTPException(403, '无移动端菜单权限')
model = project_management.BUSINESS[bizType][0]
apply_scope = {'presign': apply_presign_scope, 'cell_storage': apply_cell_protocol_scope,
'research_donation': apply_donation_scope}[bizType]
query = apply_scope(db.query(model), db, _context(user, scope_kind), user_info=user)
return success_response(project_management.filter_options(
db, user['tenant_no'], user, bizType, business_scope=query))
_register_project_filter_routes('specialist')
_register_project_filter_routes('manager')
+29 -2
View File
@@ -1,4 +1,4 @@
"""运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐,5 个一级菜单分组)"""
"""运营端菜单接口(与 web-admin 前端静态路由 sqxp.ts 对齐)"""
from fastapi import APIRouter, Depends
from app.core.menu_guard import require_admin_or_super_admin
@@ -6,9 +6,36 @@ from app.core.response import success_response
router = APIRouter(prefix="/web/menu", tags=["菜单管理"])
# 与 web-admin/src/router/modules/sqxp.ts 一致:5 个一级菜单分组
# 与 web-admin/src/router/modules/sqxp.ts 一致:6 个一级菜单分组
# 市场二级顺序:存储服务、科研捐赠、市场基础信息、中试服务、基础实验
STATIC_MENU_TREE = [
{
"id": "TOP00000000000000000000000000006",
"name": "Project",
"path": "/project",
"component": "/index/index",
"meta": {
"title": "项目", "icon": "ri:folder-chart-line", "keepAlive": False,
"isTopMenu": True, "menuCode": "menu:project", "roles": ["admin"],
},
"children": [
{
"name": "ProjectManagement", "path": "management", "component": "",
"meta": {"title": "项目管理", "icon": "ri:folder-chart-line",
"keepAlive": False, "menuCode": "menu:project:management"},
"children": [
{"name": "ProjectManagementTeams", "path": "teams", "component": "/project-management/teams/index",
"meta": {"title": "项目组管理", "keepAlive": True, "menuCode": "project:team:list"}},
{"name": "ProjectManagementProjects", "path": "projects", "component": "/project-management/projects/index",
"meta": {"title": "项目管理", "keepAlive": True, "menuCode": "project:project:list"}},
{"name": "ProjectManagementStatistics", "path": "statistics", "component": "/project-management/statistics/index",
"meta": {"title": "业务统计", "keepAlive": True, "menuCode": "project:statistics:list"}},
{"name": "ProjectManagementAssignments", "path": "assignments", "component": "/project-management/assignments/index",
"meta": {"title": "历史归属维护", "keepAlive": True, "menuCode": "project:assignment:edit"}},
],
},
],
},
# ────────────────────────────────────────────
# 1. 市场 (menu:market)
# ────────────────────────────────────────────
+27 -2
View File
@@ -1,15 +1,16 @@
"""中试服务申请单管理 API(申请单、患者信息、输注方案、输注计划、出库联动)"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.services.project_assignment import create_assignment, accessible_order
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 pilot_application
from app.models.models import SysUser
from app.models.models import SysUser, BizPilotApplication
router = APIRouter(prefix="/web/admin", tags=["中试服务申请单管理"])
@@ -42,6 +43,7 @@ class PlanIdBody(BaseModel):
class ApplicationCreateBody(BaseModel):
project_id: Optional[str] = Field(None, alias="projectId")
model_config = ConfigDict(populate_by_name=True)
channel_service_order_id: Optional[str] = Field(None, alias="channelServiceOrderId")
@@ -54,6 +56,7 @@ class ApplicationCreateBody(BaseModel):
class ApplicationUpdateBody(BaseModel):
project_id: Optional[str] = Field(None, alias="projectId")
model_config = ConfigDict(populate_by_name=True)
id: str
@@ -190,6 +193,8 @@ class InfusionPlanNestedBody(BaseModel):
class ApplicationSaveBody(BaseModel):
"""一揽子保存:申请单 + 患者信息 + 输注方案 + 输注计划(含细胞产品)"""
project_id: Optional[str] = Field(None, alias="projectId")
model_config = ConfigDict(populate_by_name=True)
id: Optional[str] = None
@@ -221,6 +226,8 @@ class ApplicationSaveBody(BaseModel):
@router.get("/pilot-application/list")
async def api_list_applications(
projectId: Optional[str] = Query(None, alias="projectId"),
teamId: Optional[str] = Query(None, alias="teamId"),
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = Query(None, alias="keyword"),
@@ -235,6 +242,8 @@ async def api_list_applications(
result = pilot_application.list_applications(
db,
tenant_no=tenant_no,
project_id=projectId,
team_id=teamId,
page=page,
page_size=pageSize,
channel_id=channelId,
@@ -242,6 +251,7 @@ async def api_list_applications(
channel_service_order_id=channelServiceOrderId,
keyword=keyword,
biz_type=bizType,
user_info=user,
)
return success_response(result)
@@ -253,6 +263,7 @@ async def api_get_application(
db: Session = Depends(get_db),
):
tenant_no = _get_tenant_no(db, user)
accessible_order(db, BizPilotApplication, tenant_no, user, id)
result = pilot_application.get_application(db, app_id=id, tenant_no=tenant_no)
if result is None:
return error_response(404, "申请单不存在")
@@ -268,6 +279,7 @@ async def api_create_application(
try:
tenant_no = _get_tenant_no(db, user)
data = {
"projectId": create_assignment(db, tenant_no, user, body.project_id, body.biz_type or "pilot_service"),
"channelServiceOrderId": body.channel_service_order_id or "",
"remark": body.remark,
"bizType": body.biz_type or "pilot_service",
@@ -296,7 +308,10 @@ async def api_update_application(
):
try:
tenant_no = _get_tenant_no(db, user)
accessible_order(db, BizPilotApplication, tenant_no, user, body.id, lock=True)
data = {}
if "project_id" in body.model_fields_set:
data["projectId"] = body.project_id
if body.remark is not None:
data["remark"] = body.remark
if body.project_title is not None:
@@ -334,6 +349,11 @@ async def api_save_application(
"bizType": biz_type,
"remark": body.remark,
}
if is_edit:
if "project_id" in body.model_fields_set:
app_data["projectId"] = body.project_id
else:
app_data["projectId"] = create_assignment(db, tenant_no, user, body.project_id, biz_type)
if body.application_name is not None:
app_data["applicationName"] = body.application_name
if body.project_title is not None:
@@ -363,6 +383,8 @@ async def api_save_application(
if not is_edit and biz_type == "clinical_application" and not app_data["channelId"]:
return error_response(400, "请选择渠道")
if is_edit:
accessible_order(db, BizPilotApplication, tenant_no, user, body.id, lock=True)
if is_edit:
app_result = pilot_application.update_application(
db,
@@ -446,6 +468,9 @@ async def api_save_application(
return success_response(app_result, message="保存成功")
except ValueError as e:
return error_response(400, str(e))
except HTTPException:
db.rollback()
raise
except Exception as e:
return error_response(500, str(e))
+7
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy.orm import Session
from app.services.project_assignment import create_assignment
from app.core.database import get_db
from app.core.data_scope import get_user_dept_id
from app.core.menu_guard import require_admin_or_super_admin
@@ -37,6 +38,7 @@ def _get_current_user_id(user: dict) -> str:
class PresignOrderBody(BaseModel):
project_id: Optional[str] = Field(None, alias="projectId")
model_config = ConfigDict(extra="ignore", populate_by_name=True)
order_date: Optional[str] = Field(None, alias="orderDate")
@@ -92,6 +94,8 @@ class FinanceConfirmBody(BaseModel):
@router.get("/presign-orders")
async def list_presign_orders(
projectId: Optional[str] = Query(None, alias="projectId"),
teamId: Optional[str] = Query(None, alias="teamId"),
page: int = Query(1, ge=1),
pageSize: int = Query(10, ge=1, le=100, alias="pageSize"),
keyword: Optional[str] = None,
@@ -104,6 +108,8 @@ async def list_presign_orders(
items, total = po_svc.list_presign_orders(
db,
tenant_no=tenant_no,
project_id=projectId,
team_id=teamId,
page=page,
page_size=pageSize,
keyword=keyword,
@@ -144,6 +150,7 @@ async def create_presign_order(
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
dumped = body.model_dump(by_alias=False)
dumped["project_id"] = create_assignment(db, tenant_no, user, body.project_id, "presign")
obj = po_svc.create_presign_order(
db,
create_dept_id=get_user_dept_id(db, user_id) or "",
+205
View File
@@ -0,0 +1,205 @@
"""项目管理;所有管理路由显式校验精确菜单权限。"""
from typing import Optional, List, Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.core.auth import get_current_user
from app.core.database import get_db
from app.core.menu_guard import require_admin_or_super_admin
from app.core.response import success_response
from app.services import project_management as svc
from app.services import project_assignment_records
router = APIRouter(tags=['项目管理'])
PREFIX = '/web/admin/project-management'
class Body(BaseModel):
model_config = ConfigDict(extra='forbid')
class Member(Body):
id: Optional[str] = None
userId: str
class Region(Body):
id: Optional[str] = None
regionId: str
class Hospital(Body):
id: Optional[str] = None
hospitalId: str
class TeamBody(Body):
teamName: str = Field(min_length=1, max_length=200)
leaderUserId: str
enabled: bool = True
members: List[Member] = Field(default_factory=list)
regions: List[Region] = Field(default_factory=list)
hospitals: List[Hospital] = Field(default_factory=list)
remark: Optional[str] = Field(None, max_length=512)
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]
startDate: Optional[str] = None
endDate: Optional[str] = None
status: Literal['pending', 'active', 'completed', 'disabled'] = 'pending'
remark: Optional[str] = Field(None, max_length=512)
class PreviewBody(Body):
bizType: Literal['presign', 'cell_storage', 'research_donation', 'pilot_service', 'basic_experiment']
id: str
projectId: str
class Snapshot(BaseModel):
bizType: str
id: str
projectId: Optional[str] = None
class AssignmentBody(PreviewBody):
reason: str = Field(min_length=1, max_length=512)
expectedAssignments: List[Snapshot]
def identity(user=Depends(get_current_user)):
tenant = user.get('tenant_no')
if not tenant or not user.get('user_id'):
raise HTTPException(403, '缺少认证租户')
return tenant, user
def guard(*codes):
def dependency(ctx=Depends(identity), db: Session=Depends(get_db)):
svc.check_permission(db, ctx[0], ctx[1], *codes)
return ctx
return dependency
@router.get('/project-management/options')
def options(bizType: str, usage: Literal['assignment', 'filter']='assignment', keyword: Optional[str]=None, currentProjectId: Optional[str]=None, ctx=Depends(identity), db: Session=Depends(get_db)):
# Arbitrary currentProjectId cannot authorize reading historical project metadata.
if usage == 'filter':
require_admin_or_super_admin(ctx[1])
method = svc.filter_options if usage == 'filter' else svc.options
return success_response(method(db, *ctx, bizType, keyword))
@router.get(PREFIX + '/references')
def references(ctx=Depends(guard('project:team:list', 'project:team:edit', 'project:project:list', 'project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.references(db, ctx[0]))
@router.get(PREFIX + '/teams')
def teams(page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), keyword: Optional[str]=None, leaderUserId: Optional[str]=None, enabled: Optional[bool]=None, ctx=Depends(guard('project:team:list', 'project:project:list', 'project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.list_entities(db, ctx[0], svc.ProjectTeam, page, pageSize, keyword=keyword, leaderUserId=leaderUserId, enabled=enabled))
@router.get(PREFIX + '/projects')
def projects(page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), keyword: Optional[str]=None, teamId: Optional[str]=None, status: Optional[str]=None, ctx=Depends(guard('project:project:list')), db: Session=Depends(get_db)):
return success_response(svc.list_entities(db, ctx[0], svc.Project, page, pageSize, keyword=keyword, teamId=teamId, status=status))
@router.post(PREFIX + '/teams')
def create_team(body: TeamBody, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)):
return success_response(svc.save_entity(db, *ctx, svc.ProjectTeam, body.model_dump()))
@router.post(PREFIX + '/projects')
def create_project(body: ProjectBody, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.save_entity(db, *ctx, svc.Project, body.model_dump()))
@router.get(PREFIX + '/teams/{row_id}')
def get_team(row_id: str, ctx=Depends(guard('project:team:list')), db: Session=Depends(get_db)):
return success_response(svc.serialize_entity(db, ctx[0], svc.get_row(db, svc.ProjectTeam, ctx[0], row_id)))
@router.get(PREFIX + '/projects/{row_id}')
def get_project(row_id: str, ctx=Depends(guard('project:project:list')), db: Session=Depends(get_db)):
return success_response(svc.serialize_entity(db, ctx[0], svc.get_row(db, svc.Project, ctx[0], row_id)))
@router.put(PREFIX + '/teams/{row_id}')
def update_team(row_id: str, body: TeamBody, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)):
return success_response(svc.save_entity(db, *ctx, svc.ProjectTeam, body.model_dump(), row_id))
@router.put(PREFIX + '/projects/{row_id}')
def update_project(row_id: str, body: ProjectBody, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.save_entity(db, *ctx, svc.Project, body.model_dump(), row_id))
@router.delete(PREFIX + '/teams/{row_id}')
def delete_team(row_id: str, ctx=Depends(guard('project:team:edit')), db: Session=Depends(get_db)):
return success_response(svc.delete_entity(db, *ctx, svc.ProjectTeam, row_id))
@router.delete(PREFIX + '/projects/{row_id}')
def delete_project(row_id: str, ctx=Depends(guard('project:project:edit')), db: Session=Depends(get_db)):
return success_response(svc.delete_entity(db, *ctx, svc.Project, row_id))
@router.get(PREFIX + '/statistics')
def statistics(groupBy: str='project', teamId: Optional[str]=None, projectId: Optional[str]=None, startDate: Optional[str]=None, endDate: Optional[str]=None, ctx=Depends(guard('project:statistics:list')), db: Session=Depends(get_db)):
return success_response(svc.statistics(db, *ctx, groupBy=groupBy, teamId=teamId, projectId=projectId, startDate=startDate, endDate=endDate))
@router.get(PREFIX + '/records')
def records(bizType: str, page: int=Query(1, ge=1), pageSize: int=Query(20, ge=1, le=200), teamId: Optional[str]=None, projectId: Optional[str]=None, unassigned: bool=False, status: Optional[str]=None, keyword: Optional[str]=None, startDate: Optional[str]=None, endDate: Optional[str]=None, ctx=Depends(guard('project:statistics:list', 'project:assignment:edit')), db: Session=Depends(get_db)):
return success_response(svc.records(db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId, unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate))
@router.get(PREFIX + '/statistics/records')
def statistics_records(
bizType: Literal['cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'],
page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200),
teamId: Optional[str] = None, projectId: Optional[str] = None,
unassigned: bool = False, status: Optional[str] = None, keyword: Optional[str] = None,
startDate: Optional[str] = None, endDate: Optional[str] = None,
ctx=Depends(guard('project:statistics:list')), db: Session=Depends(get_db),
):
return success_response(project_assignment_records.statistics_records(
db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId,
unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate,
))
@router.post(PREFIX + '/assignments/preview')
def preview(body: PreviewBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)):
return success_response(svc.preview_assignment(db, *ctx, body.bizType, body.id, body.projectId))
@router.get(PREFIX + '/assignments/records')
def assignment_records(
bizType: Literal['cell_storage', 'research_donation', 'pilot_service', 'basic_experiment'],
page: int = Query(1, ge=1), pageSize: int = Query(20, ge=1, le=200),
teamId: Optional[str] = None, projectId: Optional[str] = None,
unassigned: bool = False, status: Optional[str] = None, keyword: Optional[str] = None,
startDate: Optional[str] = None, endDate: Optional[str] = None,
ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db),
):
return success_response(project_assignment_records.records(
db, *ctx, bizType, page, pageSize, teamId=teamId, projectId=projectId,
unassigned=unassigned, status=status, keyword=keyword, startDate=startDate, endDate=endDate,
))
@router.post(PREFIX + '/assignments')
def assign(body: AssignmentBody, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)):
return success_response(svc.assign(db, *ctx, body.bizType, body.id, body.projectId, body.reason, [r.model_dump() for r in body.expectedAssignments]))
@router.get(PREFIX + '/assignments/history')
def history(bizType: str, id: str, ctx=Depends(guard('project:assignment:edit')), db: Session=Depends(get_db)):
return success_response(svc.assignment_history(db, *ctx, bizType, id))
+4 -3
View File
@@ -13,6 +13,7 @@ from app.core.menu_guard import (
require_admin_or_super_admin,
require_any_menu_code,
require_menu_code,
require_strict_menu_code,
strip_row_fields,
user_has_menu_perm,
)
@@ -2650,7 +2651,7 @@ async def list_qc_material_amount_auth_users(
async def grant_qc_material_amount_auth(
body: QcMaterialAmountAuthGrantBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
user: dict = Depends(require_strict_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, allow_super_admin=True)),
):
"""为用户绑定「质检物料金额查看」专用角色。"""
try:
@@ -2674,7 +2675,7 @@ async def grant_qc_material_amount_auth(
async def revoke_qc_material_amount_auth(
user_id: str,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
user: dict = Depends(require_strict_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, allow_super_admin=True)),
):
"""解除用户的「质检物料金额查看」专用角色。"""
try:
@@ -2699,7 +2700,7 @@ async def revoke_qc_material_amount_auth(
async def batch_revoke_qc_material_amount_auth(
body: QcMaterialAmountAuthBatchBody,
db: Session = Depends(get_db),
user: dict = Depends(require_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)),
user: dict = Depends(require_strict_menu_code(QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, allow_super_admin=True)),
):
"""批量解除「质检物料金额查看」授权。"""
try:
+131 -1
View File
@@ -5,10 +5,14 @@ from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.models.models import BizCollectionProtocol
from app.models.research_donation import BizResearchDonation
from app.services.project_assignment import create_assignment, accessible_order
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 (
@@ -44,6 +48,7 @@ def _get_current_user_id(user: dict) -> str:
class DonationCreateBody(BaseModel):
project_id: Optional[str] = Field(None, alias="projectId")
model_config = ConfigDict(populate_by_name=True)
donation_no: Optional[str] = Field(None, alias="donationNo")
@@ -71,6 +76,7 @@ class DonationCreateBody(BaseModel):
class DonationUpdateBody(BaseModel):
project_id: Optional[str] = Field(None, alias="projectId")
model_config = ConfigDict(populate_by_name=True)
donation_no: Optional[str] = Field(None, alias="donationNo")
@@ -143,6 +149,8 @@ def assert_donation_sensitive_fields(
@router.get("/research-donations")
async def list_research_donations(
projectId: Optional[str] = Query(None, alias="projectId"),
teamId: Optional[str] = Query(None, alias="teamId"),
page: int = Query(1, ge=1),
pageSize: int = Query(20, ge=1, le=200, alias="pageSize"),
keyword: Optional[str] = Query(None, alias="keyword"),
@@ -158,6 +166,8 @@ async def list_research_donations(
result = rd_svc.list_donations(
db,
tenant_no=tenant_no,
project_id=projectId,
team_id=teamId,
page=page,
page_size=pageSize,
keyword=keyword,
@@ -250,7 +260,10 @@ async def create_research_donation(
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
if body.source_protocol_id or body.source_type not in (None, "", "direct"):
raise ValueError("请通过存储转科研入口创建关联捐赠")
data = body.model_dump(exclude_none=False, by_alias=True)
data["projectId"] = create_assignment(db, tenant_no, user, body.project_id, "research_donation")
collector = (data.get("collectorUserId") or data.get("collector_user_id") or "").strip()
if not collector:
data["collectorUserId"] = user_id
@@ -260,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,
@@ -295,10 +318,14 @@ async def update_research_donation(
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
accessible_order(db, BizResearchDonation, tenant_no, user, donation_id, lock=True)
data = body.model_dump(exclude_unset=True, by_alias=True)
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,
@@ -307,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,
@@ -340,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))
@@ -390,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,
@@ -398,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)
@@ -424,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,
@@ -434,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="状态变更成功",
@@ -468,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. 细胞存储 → 科研捐赠
# ---------------------------------------------------------------------------
@@ -482,12 +588,36 @@ async def convert_from_storage(
tenant_no = _get_tenant_no(db, user)
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="转换成功",
@@ -495,7 +625,6 @@ async def convert_from_storage(
except ValueError as e:
return error_response(400, str(e))
# ---------------------------------------------------------------------------
# 11. 科研捐赠 → 细胞存储
# ---------------------------------------------------------------------------
@@ -510,6 +639,7 @@ async def convert_to_storage(
tenant_no = _get_tenant_no(db, user)
user_id = _get_current_user_id(user)
try:
accessible_order(db, BizResearchDonation, tenant_no, user, donation_id, lock=True)
obj = rd_svc.convert_to_storage(
db,
donation_id,
+3
View File
@@ -22,6 +22,9 @@ _MIN_JWT_SECRET_KEY_LEN = 32
class Settings(BaseSettings):
"""应用配置"""
# 完成项目配置后启用新建单据必填;历史编辑不受影响。
PROJECT_ASSIGNMENT_REQUIRED: bool = False
# 应用基础配置
APP_NAME: str = "zkxw 管理骨架"
DEBUG: bool = True
+3 -1
View File
@@ -113,7 +113,7 @@ def assert_strict_menu_code(
raise HTTPException(status_code=403, detail="无对应菜单权限")
def require_strict_menu_code(menu_code: str) -> Callable:
def require_strict_menu_code(menu_code: str, *, allow_super_admin: bool = False) -> Callable:
"""租户化的精确菜单权限依赖;与历史宽松守卫并存。"""
required = str(menu_code or "").strip()
@@ -121,6 +121,8 @@ def require_strict_menu_code(menu_code: str) -> Callable:
user: dict = Depends(get_current_user),
db: Session = Depends(get_db),
) -> dict:
if allow_super_admin and user.get('role') == SUPER_ADMIN_ROLE:
return user
assert_strict_menu_code(db, user, required)
return user
@@ -53,6 +53,7 @@ _DONATION_STATUS_LABELS = {
_VOID_TYPE_FALLBACK_LABELS = {
"khhc": "客户毁存",
"jcbhg": "检测不合格",
"sysfq": "实验室废弃",
}
# 非法来源状态视为终态,不允许再转出
+41 -5
View File
@@ -11,6 +11,7 @@ from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.services.project_assignment import assignment_brief, filter_assignment, guard_project_edit
from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
from app.crud.child_row_sync import sync_child_rows
from app.crud.base import camel_to_snake
@@ -435,9 +436,10 @@ def _user_nick_name_map(db: Session, user_ids: List[str]) -> Dict[str, str]:
return {u.id: u.nick_name or "" for u in rows}
def _protocol_to_dict(p: BizCollectionProtocol) -> Dict[str, Any]:
def _protocol_to_dict(p: BizCollectionProtocol, db=None) -> Dict[str, Any]:
return {
"id": p.id,
**assignment_brief(db, p.tenant_no, p.project_id),
"protocolNoId": p.protocol_no_id or "",
"protocolNo": p.protocol_no or "",
"protocolStatus": p.protocol_status or "",
@@ -1085,6 +1087,8 @@ def _storage_in_date_to_yyyymmdd(value: str) -> str:
def list_collection_protocols(
db: Session,
*,
project_id: Optional[str] = None,
team_id: Optional[str] = None,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
@@ -1100,6 +1104,7 @@ def list_collection_protocols(
q0 = db.query(BizCollectionProtocol).filter(BizCollectionProtocol.del_flag == 0)
if tenant_no:
q0 = q0.filter(BizCollectionProtocol.tenant_no == tenant_no)
q0 = filter_assignment(q0, BizCollectionProtocol, db, tenant_no, project_id, team_id)
if protocol_status:
q0 = q0.filter(BizCollectionProtocol.protocol_status == protocol_status.strip())
if create_time_start:
@@ -1286,15 +1291,28 @@ def list_collection_protocols(
)
from app.services.qc_work_order_fee_summary import build_protocol_payment_summaries
from app.crud import sample_freight as sample_freight_svc
payment_summary_map = build_protocol_payment_summaries(
db, protocol_ids, tenant_no, include_sample_payment_modes=True
)
all_list_sample_ids = [
s.id
for samples in sample_map.values()
for s in samples
if getattr(s, "id", None)
]
freight_by_sample = sample_freight_svc.freight_map_for_samples(
db,
tenant_no=tenant_no,
sample_source=sample_freight_svc.SAMPLE_SOURCE_CELL,
sample_ids=all_list_sample_ids,
)
# 合并主表 + 子表数据
result = []
for r in rows:
d = _protocol_to_dict(r)
d = _protocol_to_dict(r, db)
pc = pc_map.get(r.id)
ci = ci_map.get(r.id)
d["collectorUserName"] = user_name_map.get(d.get("collectorUserId", ""), "")
@@ -1371,11 +1389,13 @@ def list_collection_protocols(
}
sample_items.append(
{
"id": s.id,
"sampleTypeName": type_name,
"sampleCode": code,
"sampleStatus": (s.sample_status or "").strip(),
"labStorageCode": lab_code_map.get(code, "") if code else "",
"transport": transport_dict,
"freight": freight_by_sample.get(s.id),
**sample_special_to_dict(s),
**build_sample_item_reception_qc_fields(
reception_summary_map.get(code) if code else None,
@@ -1443,7 +1463,7 @@ def get_protocol_detail_bundle(
ci = get_collection_info(db, protocol_id, tenant_no)
s_rows = list_samples(db, protocol_id, tenant_no)
sc = get_storage_cert(db, protocol_id, tenant_no)
protocol_dict = _protocol_to_dict(p)
protocol_dict = _protocol_to_dict(p, db)
ci_dict = _collection_info_to_dict(ci) if ci else None
user_ids: List[str] = []
if protocol_dict.get("collectorUserId"):
@@ -2540,6 +2560,7 @@ def list_installment_warning(
def create_collection_protocol(
db: Session,
*,
project_id: Optional[str] = None,
protocol_no_id: Optional[str] = None,
protocol_no: str = "",
protocol_status: str = "dsx",
@@ -2577,6 +2598,7 @@ def create_collection_protocol(
if protocol_total_amount not in (None, ""):
total_amt = Decimal(str(protocol_total_amount))
obj = BizCollectionProtocol(
project_id=project_id,
id=generate_uuid(),
protocol_no_id=(protocol_no_id or "").strip() or None,
protocol_no=pn,
@@ -2635,6 +2657,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
@@ -2643,6 +2666,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 ""):
@@ -2653,6 +2677,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("请选择作废类型")
@@ -2722,8 +2748,8 @@ def update_collection_protocol(
obj.protocol_total_amount = None
else:
obj.protocol_total_amount = Decimal(str(protocol_total_amount))
if source_presign_order_id is not None:
obj.source_presign_order_id = (source_presign_order_id or "").strip() or None
if source_presign_order_id is not None and (source_presign_order_id.strip() or None) != obj.source_presign_order_id:
raise ValueError("来源预签约不可修改,请使用受控转换入口")
if remark is not None:
obj.remark = remark.strip() or None
obj.update_by = user_id
@@ -2742,6 +2768,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)
+11
View File
@@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.services.project_assignment import assignment_brief, filter_assignment, guard_project_edit
from app.models.models import (
BizChannel,
BizChannelServiceOrder,
@@ -117,6 +118,7 @@ def _row_to_dict_application(row, db=None, tenant_no=None) -> Dict[str, Any]:
tn = tenant_no or row.tenant_no
d: Dict[str, Any] = {
"id": row.id,
**assignment_brief(db, tn, row.project_id),
"applicationNo": row.application_no or "",
"applicationName": row.application_name or "",
"projectTitle": row.project_title or "",
@@ -319,6 +321,9 @@ def _row_to_dict_infusion_schedule(row, cell_products: Optional[List[Dict[str, A
def list_applications(
db: Session,
tenant_no: str,
user_info=None,
project_id: Optional[str] = None,
team_id: Optional[str] = None,
page: int = 1,
page_size: int = 10,
channel_id: Optional[str] = None,
@@ -331,6 +336,10 @@ def list_applications(
BizPilotApplication.tenant_no == tenant_no,
BizPilotApplication.del_flag == 0,
)
q = filter_assignment(q, BizPilotApplication, db, tenant_no, project_id, team_id)
if user_info is not None:
from app.core.data_scope import apply_data_scope
q = apply_data_scope(q, BizPilotApplication, user_info, db, tenant_no)
if channel_id:
q = q.filter(BizPilotApplication.channel_id == channel_id)
if application_status:
@@ -510,6 +519,7 @@ def create_application(
application_no = _generate_application_no(db, tenant_no, biz_type=biz_type)
row = BizPilotApplication(
project_id=data.get("projectId", data.get("project_id")) or None,
id=str(uuid.uuid4()),
application_no=application_no,
application_name=data.get("applicationName") or "",
@@ -558,6 +568,7 @@ def update_application(db: Session, app_id: str, data: dict, tenant_no: str) ->
)
if not row:
raise ValueError("申请单不存在")
guard_project_edit(row, data)
if row.application_status not in ("dks", "jxz"):
raise ValueError("当前状态不允许编辑")
+111 -3
View File
@@ -10,13 +10,16 @@ from sqlalchemy import and_, func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.services.project_assignment import assignment_brief, filter_assignment, guard_project_edit
from app.core.utils import generate_uuid, parse_datetime
from app.crud.child_row_sync import sync_child_rows
from app.crud.biz_no_generator import allocate_next_biz_no
from app.crud.collection_protocol import urls_to_attachment_list
from app.models.models import (
BizCellSampleType,
BizCollectionHospital,
BizCollectionProtocol,
BizCollectionRegion,
BizPresignOrder,
BizPresignOrderSampleType,
BizPresignTransfer,
@@ -414,6 +417,7 @@ def order_to_dict(
)
data: Dict[str, Any] = {
"id": o.id,
**assignment_brief(db, tenant_no, o.project_id),
"orderDate": o.order_date or "",
"protocolNo": o.protocol_no or "",
"motherName": o.mother_name or "",
@@ -463,6 +467,8 @@ def list_presign_orders(
db: Session,
*,
tenant_no: str,
project_id: Optional[str] = None,
team_id: Optional[str] = None,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
@@ -473,6 +479,7 @@ def list_presign_orders(
BizPresignOrder.tenant_no == tenant_no,
BizPresignOrder.del_flag == 0,
)
q = filter_assignment(q, BizPresignOrder, db, tenant_no, project_id, team_id)
st = (order_status or "").strip()
if st:
q = q.filter(BizPresignOrder.order_status == st)
@@ -498,9 +505,107 @@ def list_presign_orders(
.limit(page_size)
.all()
)
return [
order_to_dict(db, o, tenant_no, with_children=False) for o in rows
], int(total)
items = [order_to_dict(db, o, tenant_no, with_children=False) for o in rows]
attach_presign_ledger_fields(db, items, tenant_no)
return items, int(total)
def attach_presign_ledger_fields(
db: Session, items: List[Dict[str, Any]], tenant_no: str
) -> None:
"""补列表导出需要的地区、医院、样本类型和转账时间。含已停用主数据名称。"""
if not items:
return
order_ids = [str(item.get("id") or "") for item in items if item.get("id")]
region_ids = {str(item.get("regionId") or "") for item in items if item.get("regionId")}
hospital_ids = {
str(item.get("hospitalId") or "") for item in items if item.get("hospitalId")
}
region_names: Dict[str, str] = {}
if region_ids:
region_names = {
str(row.id): str(row.region_name or "").strip()
for row in db.query(BizCollectionRegion).filter(
BizCollectionRegion.id.in_(region_ids),
BizCollectionRegion.tenant_no == tenant_no,
)
}
hospital_names: Dict[str, str] = {}
if hospital_ids:
hospital_names = {
str(row.id): str(row.hospital_name or "").strip()
for row in db.query(BizCollectionHospital).filter(
BizCollectionHospital.id.in_(hospital_ids),
BizCollectionHospital.tenant_no == tenant_no,
)
}
type_ids_by_order: Dict[str, List[str]] = {order_id: [] for order_id in order_ids}
if order_ids:
links = (
db.query(BizPresignOrderSampleType)
.filter(
BizPresignOrderSampleType.presign_order_id.in_(order_ids),
BizPresignOrderSampleType.tenant_no == tenant_no,
BizPresignOrderSampleType.del_flag == 0,
)
.order_by(
BizPresignOrderSampleType.order_num.asc(),
BizPresignOrderSampleType.create_time.asc(),
BizPresignOrderSampleType.id.asc(),
)
.all()
)
for link in links:
if link.sample_type_id:
type_ids_by_order.setdefault(link.presign_order_id, []).append(
str(link.sample_type_id)
)
type_ids = {
type_id
for order_type_ids in type_ids_by_order.values()
for type_id in order_type_ids
}
type_names: Dict[str, str] = {}
if type_ids:
type_names = {
str(row.id): str(row.type_name or "").strip()
for row in db.query(BizCellSampleType).filter(
BizCellSampleType.id.in_(type_ids),
BizCellSampleType.tenant_no == tenant_no,
)
}
times_by_order: Dict[str, List[str]] = {}
if order_ids:
transfers = (
db.query(BizPresignTransfer)
.filter(
BizPresignTransfer.presign_order_id.in_(order_ids),
BizPresignTransfer.tenant_no == tenant_no,
BizPresignTransfer.del_flag == 0,
)
.order_by(
BizPresignTransfer.transfer_time.asc(),
BizPresignTransfer.create_time.asc(),
BizPresignTransfer.id.asc(),
)
.all()
)
for transfer in transfers:
if not transfer.transfer_time:
continue
times_by_order.setdefault(transfer.presign_order_id, []).append(
transfer.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
)
for item in items:
order_id = str(item.get("id") or "")
item["regionName"] = region_names.get(str(item.get("regionId") or ""), "")
item["hospitalName"] = hospital_names.get(str(item.get("hospitalId") or ""), "")
names = [
type_names.get(type_id, "")
for type_id in type_ids_by_order.get(order_id, [])
]
item["sampleTypeNames"] = [name for name in names if name]
item["transferTimes"] = times_by_order.get(order_id, [])
def get_presign_order(
@@ -548,6 +653,7 @@ def create_presign_order(
db, tenant_no=tenant_no, kind="collection", user_id=user_id
)
obj = BizPresignOrder(
project_id=fields.get("project_id", fields.get("projectId")) or None,
id=generate_uuid(),
order_date=(fields.get("order_date") or fields.get("orderDate") or "").strip()
or None,
@@ -633,6 +739,7 @@ def update_presign_order(
commit: bool = True,
) -> BizPresignOrder:
obj = _get_order_or_raise(db, order_id=order_id, tenant_no=tenant_no)
guard_project_edit(obj, fields)
if (obj.order_status or "") == "converted":
raise ValueError("已转正式的预签约订单不可修改")
mapping = {
@@ -1095,6 +1202,7 @@ def convert_to_protocol(
register_date=order.order_date,
collector_user_id=staff["collector_user_id"],
source_presign_order_id=order.id,
project_id=order.project_id,
payment_model_version=2,
protocol_total_amount=None,
remark=order.remark,
+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]
@@ -409,6 +409,8 @@ def list_protocol_sample_collections(
item["newbornSex"] = (party.newborn_sex or "") if party else ""
item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else ""
item["protocolRemark"] = (proto.remark or "") if proto else ""
item["voidType"] = (proto.void_type or "") if proto else ""
item["voidDesc"] = (proto.void_desc or "") if proto else ""
item["protocolAttachments"] = (proto.protocol_attachments or "") if proto else ""
item["consentFormUrls"] = (proto.consent_form_urls or "") if proto else ""
item["healthSurveyFormUrls"] = (proto.health_survey_form_urls or "") if proto else ""
+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(
+40
View File
@@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session
from app.services.project_assignment import assignment_brief, filter_assignment, guard_project_edit
from app.core.protocol_status_display import assert_protocol_status_transition
from app.core.utils import generate_uuid, parse_datetime, format_date, optional_date
from app.crud.child_row_sync import sync_child_rows
@@ -410,6 +411,7 @@ def _donation_to_dict(
tn = tenant_no or d.tenant_no
return {
"id": d.id,
**assignment_brief(db, tn, d.project_id),
"donationNo": d.donation_no or "",
"monthlyVerifyNo": d.monthly_verify_no or "",
"donationStatus": d.donation_status or "",
@@ -649,6 +651,8 @@ def list_donations(
db: Session,
*,
tenant_no: str,
project_id: Optional[str] = None,
team_id: Optional[str] = None,
page: int = 1,
page_size: int = 10,
keyword: Optional[str] = None,
@@ -666,6 +670,7 @@ def list_donations(
)
if tenant_no:
q = q.filter(BizResearchDonation.tenant_no == tenant_no)
q = filter_assignment(q, BizResearchDonation, db, tenant_no, project_id, team_id)
if donation_status:
q = q.filter(BizResearchDonation.donation_status == donation_status.strip())
if create_time_start:
@@ -874,6 +879,20 @@ def list_donations(
if t.express_company_id
],
)
from app.crud import sample_freight as sample_freight_svc
all_list_sample_ids = [
s.id
for samples in sample_map.values()
for s in samples
if getattr(s, "id", None)
]
freight_by_sample = sample_freight_svc.freight_map_for_samples(
db,
tenant_no=tenant_no,
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
sample_ids=all_list_sample_ids,
)
result = []
for r in rows:
@@ -952,11 +971,13 @@ def list_donations(
}
sample_items.append(
{
"id": s.id,
"sampleTypeName": type_name,
"sampleCode": code,
"sampleStatus": (s.sample_status or "").strip(),
"labStorageCode": lab_code_map.get(code, "") if code else "",
"transport": transport_dict,
"freight": freight_by_sample.get(s.id),
**sample_special_to_dict(s),
**build_sample_item_reception_qc_fields(
reception_summary_map.get(code) if code else None,
@@ -1159,6 +1180,7 @@ def create_donation(
db, tenant_no=tenant_no, create_time=now
)
obj = BizResearchDonation(
project_id=raw.get("project_id") or None,
id=generate_uuid(),
donation_no=donation_no,
monthly_verify_no=monthly_verify_no,
@@ -1269,7 +1291,11 @@ def update_donation(
if not obj:
raise ValueError("捐赠单不存在或无权操作")
guard_project_edit(obj, data)
raw = _normalize_fields(data)
for source_field in ("source_type", "source_protocol_id"):
if source_field in raw and (raw[source_field] or None) != (getattr(obj, source_field) or None):
raise ValueError("转换来源不可修改,请使用受控转换入口")
_resolve_donation_payload(raw)
now = datetime.now()
@@ -1676,6 +1702,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
@@ -1704,6 +1740,7 @@ def convert_from_storage(
BizCollectionProtocol.del_flag == 0,
)
)
.populate_existing().with_for_update()
.first()
)
if not proto:
@@ -1772,6 +1809,7 @@ def convert_from_storage(
# 创建主记录
donation = BizResearchDonation(
project_id=proto.project_id,
id=generate_uuid(),
donation_no=donation_no,
monthly_verify_no=monthly_verify_no,
@@ -1975,6 +2013,7 @@ def convert_to_storage(
BizResearchDonation.del_flag == 0,
)
)
.populate_existing().with_for_update()
.first()
)
if not donation:
@@ -2033,6 +2072,7 @@ def convert_to_storage(
# 创建协议单
proto = BizCollectionProtocol(
project_id=donation.project_id,
id=generate_uuid(),
protocol_no_id=protocol_no_id,
protocol_no=protocol_no,
+3
View File
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.crud.child_row_sync import sync_child_rows, sync_link_rows
from app.services.permission_administration import menu_ids_with_ancestors, validate_tenant_references
from app.models.models import (
SysPost,
SysPostRole,
@@ -378,6 +379,8 @@ def update_role_menus(
tenant_no: str,
user_id: Optional[str] = None,
) -> None:
validate_tenant_references(db, SysRole, [role_id], tenant_no)
menu_ids = menu_ids_with_ancestors(db, menu_ids, tenant_no)
sync_link_rows(
db,
SysRoleMenu,
+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
+3 -1
View File
@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
from app.core.api_crypto_middleware import ApiCryptoMiddleware
from app.models import models # noqa: F401
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, protocol_sample_payment_router, protocol_refund_record_router, presign_order_router, sample_freight_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router, mini_menu_router, market_mobile_router, sample_storage_cert_qc_router, customer_service_dict_router, sample_cert_insurance_router, sample_code_correction_router, project_management_router
@asynccontextmanager
@@ -157,6 +157,8 @@ app.include_router(sample_storage_cert_qc_router, prefix=settings.API_V1_PREFIX)
app.include_router(customer_service_dict_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_cert_insurance_router, prefix=settings.API_V1_PREFIX)
app.include_router(sample_code_correction_router, prefix=settings.API_V1_PREFIX)
app.include_router(project_management_router, prefix=settings.API_V1_PREFIX)
def _mask_secret(value: str) -> str:
+7
View File
@@ -73,7 +73,14 @@ from app.models.research_donation import ( # noqa: F401
User = SysUser
ApiRequestLog = SysApiRequestLog
from app.models.project_management import (
Project, ProjectTeam, ProjectTeamMember, ProjectTeamRegion,
ProjectTeamHospital, ProjectAssignmentAudit, ProjectTeamHistory,
)
__all__ = [
"Project", "ProjectTeam", "ProjectTeamMember", "ProjectTeamRegion",
"ProjectTeamHospital", "ProjectAssignmentAudit", "ProjectTeamHistory",
"BaseModel",
"SoftDeleteModel",
"SysUser",
+30
View File
@@ -1071,6 +1071,7 @@ class BizCollectionProtocol(Base):
)
id = Column(String(64), primary_key=True, comment="主键ID")
project_id = Column(String(64), nullable=True, index=True, comment="所属统计项目ID")
protocol_no_id = Column(String(64), nullable=True, comment="采集协议号表ID")
protocol_no = Column(String(32), nullable=False, comment="采集协议号展示")
protocol_status = Column(String(16), nullable=False, default="dsx", comment="协议状态")
@@ -1148,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"""
@@ -1361,6 +1389,7 @@ class BizPresignOrder(Base):
__tablename__ = "biz_presign_order"
id = Column(String(64), primary_key=True, comment="主键ID")
project_id = Column(String(64), nullable=True, index=True, comment="所属统计项目ID")
order_date = Column(CHAR(8), nullable=True, comment="业务日期YYYYMMDD")
protocol_no = Column(String(64), nullable=True, comment="协议编号")
mother_name = Column(String(64), nullable=True, comment="母亲姓名")
@@ -4036,6 +4065,7 @@ class BizPilotApplication(Base):
__tablename__ = "biz_pilot_application"
id = Column(String(64), primary_key=True, comment="主键ID")
project_id = Column(String(64), nullable=True, index=True, comment="所属统计项目ID")
application_no = Column(String(64), nullable=True, comment="申请单号(自动生成,前缀ZSSQ)")
application_name = Column(String(200), nullable=True, comment="申请单名称")
project_title = Column(String(200), nullable=True, comment="课题名称(基础实验)")
+80
View File
@@ -0,0 +1,80 @@
"""租户项目及归属审计;区域医院关联只记录组织资料。"""
from datetime import datetime
from sqlalchemy import Column, String, Integer, SmallInteger, DateTime, Date, Text, UniqueConstraint
from app.models.base import Base
class ProjectFields:
id = Column(String(64), primary_key=True)
tenant_no = Column(String(64), nullable=False, index=True)
order_num = Column(Integer)
remark = Column(String(512))
create_by = Column(String(64))
create_dept_id = Column(String(64), nullable=False, default='')
create_time = Column(DateTime, nullable=False, default=datetime.now)
update_by = Column(String(64))
update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
del_flag = Column(SmallInteger, nullable=False, default=0)
class ProjectTeam(ProjectFields, Base):
__tablename__ = 'biz_project_team'
team_name = Column(String(200), nullable=False)
leader_user_id = Column(String(64), nullable=False)
enabled = Column(SmallInteger, nullable=False, default=1)
class ProjectTeamMember(ProjectFields, Base):
__tablename__ = 'biz_project_team_member'
team_id = Column(String(64), nullable=False, index=True)
user_id = Column(String(64), nullable=False)
class ProjectTeamRegion(ProjectFields, Base):
__tablename__ = 'biz_project_team_region'
team_id = Column(String(64), nullable=False, index=True)
region_id = Column(String(64), nullable=False)
class ProjectTeamHospital(ProjectFields, Base):
__tablename__ = 'biz_project_team_hospital'
team_id = Column(String(64), nullable=False, index=True)
hospital_id = Column(String(64), nullable=False)
class Project(ProjectFields, Base):
__tablename__ = 'biz_project'
__table_args__ = (UniqueConstraint('tenant_no', 'project_no', name='uk_project_tenant_no'),)
project_no = Column(String(64), nullable=False)
project_name = Column(String(200), nullable=False)
team_id = Column(String(64), nullable=False, index=True)
leader_user_id = Column(String(64), nullable=False)
biz_types = Column(String(255), nullable=False)
start_date = Column(Date)
end_date = Column(Date)
status = Column(String(16), nullable=False, default='pending')
class ProjectAssignmentAudit(ProjectFields, Base):
__tablename__ = 'biz_project_assignment_audit'
biz_type = Column(String(32), nullable=False)
biz_id = Column(String(64), nullable=False, index=True)
old_project_id = Column(String(64))
new_project_id = Column(String(64))
old_project_name = Column(String(200))
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)
+1
View File
@@ -12,6 +12,7 @@ class BizResearchDonation(Base):
__tablename__ = "biz_research_donation"
id = Column(String(64), primary_key=True, comment="主键ID")
project_id = Column(String(64), nullable=True, index=True, comment="所属统计项目ID")
donation_no = Column(String(64), nullable=False, comment="捐赠协议编号")
monthly_verify_no = Column(String(16), nullable=True, comment="当月核量号 YYYYMM+3位序号")
donation_status = Column(String(16), nullable=False, default="dsx", comment="状态 dsx/sxz/ygq/yzf/sdfq/zckc")
+4
View File
@@ -116,6 +116,8 @@ class MarketPresignOrderBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
project_id: Optional[str] = Field(None, max_length=64, alias='projectId')
order_date: Optional[str] = Field(None, alias="orderDate")
protocol_no: Optional[str] = Field(
None, max_length=64, alias="protocolNo"
@@ -441,6 +443,8 @@ class MarketProtocolBody(BaseModel):
model_config = ConfigDict(extra='forbid', populate_by_name=True)
project_id: Optional[str] = Field(None, max_length=64, alias='projectId')
source: Literal['cell_storage', 'research_donation']
protocol_status: Optional[
Literal['dsx', 'sxz', 'ygq', 'yzf', 'sdfq', 'zky']
+15
View File
@@ -9,6 +9,11 @@ from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.services.project_assignment import (
create_assignment,
guard_project_edit,
filter_assignment,
)
from app.crud import presign_order as presign_crud
from app.models.models import (
BizCellSampleType,
@@ -327,10 +332,15 @@ def list_presign_orders(
page_size: int = 10,
keyword: Optional[str] = None,
order_status: Optional[str] = None,
project_id: Optional[str] = None,
team_id: Optional[str] = None,
) -> Tuple[List[dict], int]:
query = apply_presign_scope(
db.query(BizPresignOrder), db, ctx, user_info=user_info
)
query = filter_assignment(
query, BizPresignOrder, db, ctx.tenant_no, project_id, team_id
)
status = (order_status or "").strip()
if status:
query = query.filter(BizPresignOrder.order_status == status)
@@ -417,6 +427,9 @@ def create_presign_order(
user_info: dict,
) -> dict:
fields = _dump_body(body)
fields["project_id"] = create_assignment(
db, ctx.tenant_no, user_info, body.project_id, "presign"
)
_reject_direct_conversion(fields)
sample_type_ids = fields.pop("sample_type_ids", None)
transfers = fields.pop("transfers", None)
@@ -513,6 +526,8 @@ def update_presign_order(
if (current.order_status or '').strip() == 'converted':
raise ValueError('当前状态的预签约订单不可修改')
fields = _dump_body(body)
guard_project_edit(current, fields)
fields.pop("project_id", None)
_reject_direct_conversion(fields)
sample_type_ids = fields.pop("sample_type_ids", None)
fields.pop("transfers", None)
+127 -7
View File
@@ -9,6 +9,12 @@ from fastapi import HTTPException
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from app.services.project_assignment import (
create_assignment,
guard_project_edit,
assignment_brief,
filter_assignment,
)
from app.core.utils import parse_datetime
from app.core.protocol_status_display import (
assert_protocol_status_transition,
@@ -16,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
@@ -853,6 +860,8 @@ def list_protocols(
create_time_end: Optional[str] = None,
sample_collect_time_start: Optional[str] = None,
sample_collect_time_end: Optional[str] = None,
project_id: Optional[str] = None,
team_id: Optional[str] = None,
) -> Tuple[List[dict], int]:
if source and source not in SOURCES:
raise ValueError('不支持的协议来源')
@@ -880,6 +889,9 @@ def list_protocols(
query = apply_cell_protocol_scope(
db.query(BizCollectionProtocol), db, ctx, user_info=user_info
)
query = filter_assignment(
query, BizCollectionProtocol, db, ctx.tenant_no, project_id, team_id
)
if status:
query = query.filter(BizCollectionProtocol.protocol_status == status)
if created_from:
@@ -952,6 +964,7 @@ def list_protocols(
rows.extend(
{
'id': row.id,
**assignment_brief(db, ctx.tenant_no, row.project_id),
'source': SOURCE_CELL,
'code': row.protocol_no or '',
'protocolNo': row.protocol_no or '',
@@ -971,19 +984,28 @@ 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(
db.query(BizResearchDonation), db, ctx, user_info=user_info
)
if query is not None:
query = filter_assignment(
query, BizResearchDonation, db, ctx.tenant_no, project_id, team_id
)
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:
@@ -1036,6 +1058,7 @@ def list_protocols(
rows.extend(
{
'id': row.id,
**assignment_brief(db, ctx.tenant_no, row.project_id),
'source': SOURCE_DONATION,
'code': row.donation_no or '',
'protocolNo': row.donation_no or '',
@@ -1458,7 +1481,7 @@ def _cell_detail(db: Session, row: BizCollectionProtocol, tenant_no: str) -> dic
collection_protocol_id=row.id,
tenant_no=tenant_no,
)
basic_dict = cell_crud._protocol_to_dict(row)
basic_dict = cell_crud._protocol_to_dict(row, db)
basic_dict['protocolTotalAmount'] = _fixed_money(row.protocol_total_amount)
payment_dict = cell_crud._payment_to_dict(payment) if payment else None
if payment_dict is not None:
@@ -1514,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 '',
@@ -1532,7 +1561,7 @@ def _donation_detail(
from app.crud import qc_task as _qc_task_svc # noqa: F401
web_bundle = donation_crud.get_donation_detail(db, row.id, tenant_no) or {}
basic = dict(web_bundle.get('donation') or donation_crud._donation_to_dict(row))
basic = dict(web_bundle.get('donation') or donation_crud._donation_to_dict(row, db, tenant_no))
basic['convertTime'] = basic.get('convertTime') or basic.get('toStorageTime') or ''
basic['convertDesc'] = basic.get('convertDesc') or basic.get('toStorageDesc') or ''
audits = donation_crud.list_audits(
@@ -1554,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 '',
@@ -1824,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('不支持的协议来源')
@@ -1852,6 +1915,9 @@ def create_protocol(
or body.payment_details is not None
):
raise ValueError('科研捐赠不支持缴费方案')
project_id = create_assignment(
db, ctx.tenant_no, user_info, body.project_id, body.source
)
customer, collection, samples, attachments = _normalize_write(
db, body=body, ctx=ctx
)
@@ -1868,6 +1934,7 @@ def create_protocol(
)
row = cell_crud.create_collection_protocol(
db,
project_id=project_id,
protocol_no=body.protocol_no or '',
# 对齐 Web 新增弹窗:未传时默认生效中
protocol_status=(body.protocol_status or '').strip() or 'sxz',
@@ -1937,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,
@@ -1949,6 +2027,7 @@ def create_protocol(
if body.sample_payments:
raise ValueError('科研捐赠不支持样本缴费方案')
data = {
'project_id': project_id,
'donation_no': body.donation_no or '',
# 对齐 Web 新增弹窗:未传时默认生效中
'donation_status': (body.donation_status or '').strip() or 'sxz',
@@ -1991,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,
@@ -2027,6 +2117,10 @@ def update_protocol(
row = _lock_cell_protocol_access(
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
)
@@ -2155,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,
@@ -2171,6 +2276,10 @@ def update_protocol(
row = _lock_donation_access(
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
)
@@ -2254,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,
@@ -0,0 +1,35 @@
"""Validate authorization targets before any role/menu relationship is changed."""
from app.models.models import SysMenu
def validate_tenant_references(db, model, ids, tenant_no):
"""Reject deleted, foreign or missing targets without writing partial grants."""
ids = list(dict.fromkeys(ids or []))
if not ids:
return
found = {row.id for row in db.query(model.id).filter(
model.id.in_(ids), model.tenant_no == tenant_no, model.del_flag == 0)}
if found != set(ids):
raise ValueError('关联记录不存在或不属于当前租户')
def menu_ids_with_ancestors(db, menu_ids, tenant_no):
"""Include only each selected node's same-tenant ancestors, up to its own root."""
menus = {row.id: row for row in db.query(SysMenu).filter(
SysMenu.tenant_no == tenant_no, SysMenu.del_flag == 0)}
result, included = [], set()
for selected in menu_ids:
current, path = selected, set()
while current and current != '0':
if current in path:
raise ValueError('菜单存在循环父级,不能授权')
if current not in menus:
raise ValueError('菜单或祖先不存在或不属于当前租户')
path.add(current)
if current not in included:
result.append(current)
included.add(current)
current = menus[current].parent_id
if not path:
raise ValueError('菜单不存在或不属于当前租户')
return result
@@ -0,0 +1,51 @@
"""Shared attribution boundary for existing business write and read paths."""
from fastapi import HTTPException
from app.core.config import settings
def create_assignment(db, tenant_no, user, project_id, biz_type):
from app.services.project_management import validate_assignment
if biz_type == 'clinical_application':
if project_id:
raise HTTPException(400, '临床应用不支持项目归属')
return None
return validate_assignment(db, tenant_no, user, project_id, biz_type,
required=settings.PROJECT_ASSIGNMENT_REQUIRED)
def guard_project_edit(row, payload):
"""Omission preserves attribution; explicit changes require audited maintenance."""
key = 'projectId' if 'projectId' in payload else 'project_id'
if key in payload and (payload[key] or None) != (row.project_id or None):
raise HTTPException(400, '请通过项目归属维护调整')
def assignment_brief(db, tenant_no, project_id):
if not db or not project_id:
return dict(projectId=project_id, projectName=None, teamId=None, teamName=None)
from app.services.project_management import project_brief
return project_brief(db, tenant_no, project_id)
def filter_assignment(query, model, db, tenant_no, project_id=None, team_id=None):
if project_id:
query = query.filter(model.project_id == project_id)
if team_id:
from app.models.project_management import Project
projects = db.query(Project.id).filter(Project.tenant_no == tenant_no,
Project.team_id == team_id, Project.del_flag == 0)
query = query.filter(model.project_id.in_(projects))
return query
def accessible_order(db, model, tenant_no, user, record_id, *, lock=False):
from app.core.data_scope import apply_data_scope
query = db.query(model).filter(model.id == record_id, model.tenant_no == tenant_no,
model.del_flag == 0)
query = apply_data_scope(query, model, user, db, tenant_no)
if lock:
query = query.populate_existing().with_for_update()
row = query.first()
if row is None:
raise HTTPException(404, '单据不存在或无权访问')
return row
@@ -0,0 +1,197 @@
"""项目业务明细检索;统计读取与归属维护分别校验菜单权限。"""
from collections import defaultdict
from fastapi import HTTPException
from sqlalchemy import and_, func, or_
from app.models.models import (
BizChannelServiceOrder, BizCollectionHospital,
BizPilotPatient, BizProtocolCollectionInfo, BizProtocolPartyCustomer, BizProtocolSample, SysUser,
)
from app.models.research_donation import (
BizResearchDonationCollection, BizResearchDonationParty, BizResearchDonationSample,
)
from app.models.project_management import Project, ProjectTeam
from app.services import project_management as projects
PARTY_MODELS = {
'cell_storage': (BizProtocolPartyCustomer, BizProtocolCollectionInfo, 'collection_protocol_id'),
'research_donation': (BizResearchDonationParty, BizResearchDonationCollection, 'research_donation_id'),
}
SAMPLE_MODELS = {
'cell_storage': BizProtocolSample,
'research_donation': BizResearchDonationSample,
}
def keyword_condition(db, tenant, kind, keyword):
"""用 EXISTS 检索子表,避免多条患者/采集信息放大主单计数。"""
model, number, _ = projects.BUSINESS[kind]
conditions = [getattr(model, number).contains(keyword, autoescape=True)]
if kind in PARTY_MODELS:
party, collection, parent_key = PARTY_MODELS[kind]
sample = SAMPLE_MODELS[kind]
conditions.append(projects.scoped(db, sample, tenant).filter(
getattr(sample, parent_key) == model.id,
sample.sample_code.contains(keyword, autoescape=True),
).exists())
conditions.append(projects.scoped(db, party, tenant).filter(
getattr(party, parent_key) == model.id,
or_(party.mother_name.contains(keyword, autoescape=True),
party.mother_phone.contains(keyword, autoescape=True)),
).exists())
hospitals = projects.scoped(db, BizCollectionHospital, tenant).filter(
BizCollectionHospital.hospital_name.contains(keyword, autoescape=True),
).with_entities(BizCollectionHospital.id)
sales_users = projects.scoped(db, SysUser, tenant).filter(
SysUser.nick_name.contains(keyword, autoescape=True),
).with_entities(SysUser.id)
conditions.append(projects.scoped(db, collection, tenant).filter(
getattr(collection, parent_key) == model.id,
or_(collection.hospital_id.in_(hospitals), collection.sales_user_id.in_(sales_users)),
).exists())
elif kind == 'pilot_service':
conditions.extend(column.contains(keyword, autoescape=True) for column in (
model.application_name, model.channel_name,
))
conditions.append(projects.scoped(db, BizPilotPatient, tenant).filter(
BizPilotPatient.pilot_application_id == model.id,
or_(BizPilotPatient.patient_name.contains(keyword, autoescape=True),
BizPilotPatient.phone_no.contains(keyword, autoescape=True)),
).exists())
conditions.append(projects.scoped(db, BizChannelServiceOrder, tenant).filter(
BizChannelServiceOrder.id == model.channel_service_order_id,
BizChannelServiceOrder.order_name.contains(keyword, autoescape=True),
).exists())
else:
conditions.extend(column.contains(keyword, autoescape=True) for column in (
model.project_title, model.customer_name, model.customer_contact, model.customer_phone,
))
return or_(*conditions)
def child_rows(db, tenant, model, parent_key, ids, fields):
"""仅批量读取当前页所需字段;稳定排序保留多条有效子记录。"""
result = defaultdict(list)
if ids:
parent = getattr(model, parent_key)
rows = projects.scoped(db, model, tenant).filter(parent.in_(ids)).with_entities(
parent, *(getattr(model, field) for field in fields),
).order_by(model.create_time, model.id).all()
for row in rows:
result[getattr(row, parent_key)].append(row)
return result
def joined_values(rows, field):
return '、'.join(dict.fromkeys(str(getattr(row, field)) for row in rows if getattr(row, field)))
def project_metadata(db, tenant, rows):
ids = {row.project_id for row in rows if row.project_id}
if not ids:
return {}
values = projects.scoped(db, Project, tenant).outerjoin(ProjectTeam, and_(
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant, ProjectTeam.del_flag == 0,
)).filter(Project.id.in_(ids)).with_entities(
Project.id, Project.project_name, Project.team_id, ProjectTeam.team_name,
).all()
return {row.id: dict(projectName=row.project_name, teamId=row.team_id, teamName=row.team_name)
for row in values}
def business_details(db, tenant, kind, rows):
ids = [row.id for row in rows]
result = {row.id: {} for row in rows}
if not ids:
return result
if kind in PARTY_MODELS:
party, collection, parent_key = PARTY_MODELS[kind]
parties = child_rows(db, tenant, party, parent_key, ids, ('mother_name', 'mother_phone'))
samples = child_rows(db, tenant, SAMPLE_MODELS[kind], parent_key, ids, ('sample_code',))
collections = child_rows(db, tenant, collection, parent_key, ids,
('hospital_id', 'sales_user_id', 'sample_collect_time'))
all_collections = [item for values in collections.values() for item in values]
hospital_ids = {item.hospital_id for item in all_collections if item.hospital_id}
sales_ids = {item.sales_user_id for item in all_collections if item.sales_user_id}
hospitals = dict(projects.scoped(db, BizCollectionHospital, tenant).filter(
BizCollectionHospital.id.in_(hospital_ids),
).with_entities(BizCollectionHospital.id, BizCollectionHospital.hospital_name).all()) if hospital_ids else {}
sales_users = dict(projects.scoped(db, SysUser, tenant).filter(
SysUser.id.in_(sales_ids),
).with_entities(SysUser.id, SysUser.nick_name).all()) if sales_ids else {}
for row in rows:
info = collections[row.id]
details = dict(
sampleCodes=joined_values(samples[row.id], 'sample_code'),
motherName=joined_values(parties[row.id], 'mother_name'),
motherPhone=joined_values(parties[row.id], 'mother_phone'),
hospitalName='、'.join(dict.fromkeys(hospitals[item.hospital_id] for item in info
if hospitals.get(item.hospital_id))),
salesUserName='、'.join(dict.fromkeys(sales_users[item.sales_user_id] for item in info
if sales_users.get(item.sales_user_id))),
)
if kind == 'cell_storage':
details['collectionDate'] = '、'.join(dict.fromkeys(
item.sample_collect_time.strftime('%Y-%m-%d') for item in info if item.sample_collect_time))
else:
value = row.donation_date or ''
details['donationDate'] = f'{value[:4]}-{value[4:6]}-{value[6:]}' if len(value) == 8 else value
result[row.id] = details
elif kind == 'pilot_service':
patients = child_rows(db, tenant, BizPilotPatient, 'pilot_application_id', ids,
('patient_name', 'phone_no'))
order_ids = {row.channel_service_order_id for row in rows if row.channel_service_order_id}
orders = dict(projects.scoped(db, BizChannelServiceOrder, tenant).filter(
BizChannelServiceOrder.id.in_(order_ids),
).with_entities(BizChannelServiceOrder.id, BizChannelServiceOrder.order_name).all()) if order_ids else {}
for row in rows:
result[row.id] = dict(
applicationName=row.application_name, channelName=row.channel_name,
serviceOrderName=orders.get(row.channel_service_order_id),
patientName=joined_values(patients[row.id], 'patient_name'),
patientPhone=joined_values(patients[row.id], 'phone_no'),
)
else:
for row in rows:
result[row.id] = dict(projectTitle=row.project_title, customerName=row.customer_name,
customerContact=row.customer_contact, customerPhone=row.customer_phone)
return result
def records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
projects.check_permission(db, tenant, user, 'project:assignment:edit')
return _records(db, tenant, user, biz_type, page, pageSize, **filters)
def statistics_records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
projects.check_permission(db, tenant, user, 'project:statistics:list')
return _records(db, tenant, user, biz_type, page, pageSize, **filters)
def _records(db, tenant, user, biz_type, page, pageSize, **filters):
if biz_type not in projects.COUNT_TYPES:
raise HTTPException(400, '业务明细仅支持四类正式业务')
model, number, status = projects.BUSINESS[biz_type]
# 继续使用现有租户、主单数据范围、项目/项目组和日期条件。
query = projects.filtered_query(db, tenant, user, biz_type, {**filters, 'keyword': None})
keyword = (filters.get('keyword') or '').strip()
if keyword:
query = query.filter(keyword_condition(db, tenant, biz_type, keyword))
status_column = getattr(model, status)
status_counts = {str(value or ''): count for value, count in query.with_entities(
status_column, func.count(model.id),
).group_by(status_column).all()}
if filters.get('status'):
query = query.filter(status_column == filters['status'])
total = query.count()
rows = query.order_by(model.create_time.desc(), model.id).offset((page - 1) * pageSize).limit(pageSize).all()
metadata = project_metadata(db, tenant, rows)
details = business_details(db, tenant, biz_type, rows)
return dict(list=[dict(
id=row.id, bizType=biz_type, bizNo=getattr(row, number), status=getattr(row, status),
createTime=row.create_time, projectId=row.project_id,
**metadata.get(row.project_id, dict(projectName=None, teamId=None, teamName=None)),
**details[row.id],
) for row in rows], total=total, statusCounts=status_counts)
+467
View File
@@ -0,0 +1,467 @@
"""项目资料、权限内统计和关联归属事务。"""
import json
from datetime import date, datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import or_, and_, func
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, ProjectTeamHistory)
from app.models.models import (SysUser, BizCollectionRegion, BizCollectionHospital,
BizPresignOrder, BizCollectionProtocol, BizPilotApplication)
from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert
from app.services.web_auth_roles import iter_user_menu_perm_codes
BUSINESS = {
'presign': (BizPresignOrder, 'protocol_no', 'order_status'),
'cell_storage': (BizCollectionProtocol, 'protocol_no', 'protocol_status'),
'research_donation': (BizResearchDonation, 'donation_no', 'donation_status'),
'pilot_service': (BizPilotApplication, 'application_no', 'application_status'),
'basic_experiment': (BizPilotApplication, 'application_no', 'application_status'),
}
COUNT_TYPES = tuple(k for k in BUSINESS if k != 'presign')
ALL_PROJECTS_PERMISSION = 'project:project:all'
def scoped(db, model, tenant):
return db.query(model).filter(model.tenant_no == tenant, model.del_flag == 0)
def get_row(db, model, tenant, row_id, lock=False):
query = scoped(db, model, tenant).filter(model.id == row_id)
row = (query.populate_existing().with_for_update() if lock else query).first()
if not row:
raise HTTPException(404, '记录不存在或无权访问')
return row
def has_permission(db, tenant, user, code):
return user.get('role') == 'super_admin' or code in iter_user_menu_perm_codes(db, user.get('user_id', ''), tenant_no=tenant)
def check_permission(db, tenant, user, *codes):
if not any(has_permission(db, tenant, user, code) for code in codes):
raise HTTPException(403, '无对应菜单权限')
def project_brief(db, tenant_no, project_id):
result = dict(projectId=project_id, projectName=None, teamId=None, teamName=None)
if project_id:
row = scoped(db, Project, tenant_no).filter(Project.id == project_id).first()
if row:
team = scoped(db, ProjectTeam, tenant_no).filter(ProjectTeam.id == row.team_id).first()
result.update(projectName=row.project_name, teamId=row.team_id, teamName=team.team_name if team else None)
return result
def validate_assignment(db, tenant_no, user, project_id, biz_type, *, current_project_id=None, required=False):
project_id = project_id or None
if current_project_id:
if project_id != current_project_id:
raise HTTPException(400, '请通过项目归属维护调整')
return current_project_id
if not project_id:
if required:
raise HTTPException(400, '请选择项目')
return None
row = get_row(db, Project, tenant_no, project_id, lock=True)
team = get_row(db, ProjectTeam, tenant_no, row.team_id, lock=True)
if row.status != 'active' or not team.enabled:
raise HTTPException(400, '项目或项目组已停用或尚未启用')
effective_type = 'cell_storage' if biz_type == 'presign' else biz_type
if effective_type not in row.biz_types.split(','):
raise HTTPException(400, '项目不支持该业务类型')
uid = user.get('user_id')
member = uid == team.leader_user_id or scoped(db, ProjectTeamMember, tenant_no).filter(ProjectTeamMember.team_id == team.id, ProjectTeamMember.user_id == uid).with_for_update().first()
if not member and not has_permission(db, tenant_no, user, ALL_PROJECTS_PERMISSION):
raise HTTPException(403, '仅可选择所在项目组的项目,跨组选择需全部项目权限')
return project_id
def business_query(db, tenant, user, kind):
if kind not in BUSINESS:
raise HTTPException(400, '业务类型不支持')
model = BUSINESS[kind][0]
query = scoped(db, model, tenant)
if model is BizPilotApplication:
query = query.filter(model.biz_type == kind)
return apply_data_scope(query, model, user, db, tenant)
def filtered_query(db, tenant, user, kind, filters):
model, number, status = BUSINESS[kind]
q = business_query(db, tenant, user, kind)
if filters.get('projectId'):
q = q.filter(model.project_id == filters['projectId'])
if filters.get('teamId'):
q = q.filter(model.project_id.in_(scoped(db, Project, tenant).filter(Project.team_id == filters['teamId']).with_entities(Project.id)))
if filters.get('unassigned'):
q = q.filter(model.project_id.is_(None))
if filters.get('keyword'):
q = q.filter(getattr(model, number).contains(filters['keyword']))
for key, op in [('startDate', 'start'), ('endDate', 'end')]:
if filters.get(key):
try:
day = date.fromisoformat(str(filters[key]))
except ValueError:
raise HTTPException(400, '日期格式应为 YYYY-MM-DD')
q = q.filter(model.create_time >= datetime.combine(day, datetime.min.time())) if op == 'start' else q.filter(model.create_time < datetime.combine(day + timedelta(days=1), datetime.min.time()))
return q
def record_dict(db, tenant, kind, row):
return dict(id=row.id, bizType=kind, bizNo=getattr(row, BUSINESS[kind][1]), status=getattr(row, BUSINESS[kind][2]), createTime=row.create_time, **project_brief(db, tenant, row.project_id))
def records(db, tenant, user, biz_type, page=1, pageSize=20, **filters):
if biz_type not in BUSINESS:
raise HTTPException(400, '业务类型不支持')
model, _, status = BUSINESS[biz_type]
q = filtered_query(db, tenant, user, biz_type, filters)
status_column = getattr(model, status)
status_counts = {str(value or ''): count for value, count in
q.with_entities(status_column, func.count(model.id)).group_by(status_column).all()}
if filters.get('status'):
q = q.filter(getattr(model, status) == filters['status'])
total = q.count()
rows = q.order_by(model.create_time.desc(), model.id).offset((page-1)*pageSize).limit(pageSize).all()
return dict(list=[record_dict(db, tenant, biz_type, r) for r in rows], total=total, statusCounts=status_counts)
def statistics(db, tenant, user, groupBy='project', **filters):
if groupBy not in ('project', 'team'):
raise HTTPException(400, '分组方式不支持')
statistics_types = ('presign', *COUNT_TYPES)
totals = dict.fromkeys(statistics_types, 0)
unassigned = dict.fromkeys(statistics_types, 0)
counts = {}
for kind in statistics_types:
model = BUSINESS[kind][0]
counts[kind] = filtered_query(db, tenant, user, kind, filters).with_entities(
model.project_id, func.count(model.id)).group_by(model.project_id).all()
project_ids = {pid for values in counts.values() for pid, _ in values if pid}
metadata = {}
if project_ids:
rows = scoped(db, Project, tenant).outerjoin(ProjectTeam, and_(
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant,
ProjectTeam.del_flag == 0)).filter(Project.id.in_(project_ids)).with_entities(
Project.id, Project.project_name, Project.team_id, ProjectTeam.team_name).all()
metadata = {r.id: r for r in rows}
items = {}
for kind, values in counts.items():
for project_id, count in values:
totals[kind] += count
if not project_id:
unassigned[kind] += count
continue
info = metadata.get(project_id)
team_id = info.team_id if info else None
team_name = info.team_name if info else None
key = team_id if groupBy == 'team' else project_id
item = items.setdefault(key, dict(id=key,
name=team_name if groupBy == 'team' else (info.project_name if info else None),
teamName=team_name, **dict.fromkeys(statistics_types, 0)))
item[kind] += count
return dict(items=list(items.values()), totals=totals, unassignedTotals=unassigned,
presignTotal=totals['presign'])
def linked_records(db, tenant, user, kind, row_id, lock=False, current_read=False):
"""构建双向来源链;无权关联记录导致整次操作拒绝。"""
def graph_query(model):
query = scoped(db, model, tenant).populate_existing()
return query.with_for_update() if current_read else query
seen, pending, rows = set(), [(kind, row_id)], []
while pending:
current, rid = pending.pop()
if (current, rid) in seen:
continue
seen.add((current, rid))
model = BUSINESS[current][0]
row = get_row(db, model, tenant, rid, lock=current_read)
if not business_query(db, tenant, user, current).filter(model.id == rid).first():
raise HTTPException(403, '关联记录不存在或无权访问')
rows.append((current, row))
if current == 'presign':
pending += [('cell_storage', r.id) for r in graph_query(BizCollectionProtocol).filter(BizCollectionProtocol.source_presign_order_id == rid).all()]
if row.converted_protocol_id:
pending.append(('cell_storage', row.converted_protocol_id))
if current == 'cell_storage':
if row.source_presign_order_id:
pending.append(('presign', row.source_presign_order_id))
pending += [('presign', r.id) for r in graph_query(BizPresignOrder).filter(BizPresignOrder.converted_protocol_id == rid).all()]
pending += [('research_donation', r.id) for r in graph_query(BizResearchDonation).filter(BizResearchDonation.source_protocol_id == rid).all()]
if current == 'research_donation' and row.source_protocol_id:
pending.append(('cell_storage', row.source_protocol_id))
if current in ('cell_storage', 'research_donation'):
col = BizProtocolDonationConvert.protocol_id if current == 'cell_storage' else BizProtocolDonationConvert.research_donation_id
for link in graph_query(BizProtocolDonationConvert).filter(col == rid).all():
pending.extend([('cell_storage', link.protocol_id), ('research_donation', link.research_donation_id)])
rows.sort(key=lambda pair: (pair[0], pair[1].id))
if lock:
rows = [(k, get_row(db, BUSINESS[k][0], tenant, r.id, lock=True)) for k, r in rows]
return rows
def preview_assignment(db, tenant, user, kind, row_id, project_id):
check_permission(db, tenant, user, 'project:assignment:edit')
rows = linked_records(db, tenant, user, kind, row_id)
for k, row in rows:
validate_assignment(db, tenant, user, project_id, k, required=True)
return {'records': [record_dict(db, tenant, k, r) for k, r in rows]}
def assign(db, tenant, user, kind, row_id, project_id, reason, expected):
check_permission(db, tenant, user, 'project:assignment:edit')
if not reason or not reason.strip():
raise HTTPException(400, '请填写调整原因')
rows = linked_records(db, tenant, user, kind, row_id, lock=True)
actual = {(k, r.id, r.project_id) for k, r in rows}
refreshed = linked_records(db, tenant, user, kind, row_id, current_read=True)
snapshot = {(r['bizType'], r['id'], r.get('projectId')) for r in expected}
if actual != snapshot or actual != {(k, r.id, r.project_id) for k, r in refreshed} or len(expected) != len(actual):
raise HTTPException(409, '关联记录或归属已变更,请重新预览')
for k, row in rows:
validate_assignment(db, tenant, user, project_id, k, required=True)
new_name = project_brief(db, tenant, project_id)['projectName']
operator = scoped(db, SysUser, tenant).filter(SysUser.id == user.get('user_id')).first()
for k, row in rows:
db.add(ProjectAssignmentAudit(id=generate_uuid(), tenant_no=tenant, biz_type=k, biz_id=row.id, old_project_id=row.project_id, new_project_id=project_id, old_project_name=project_brief(db, tenant, row.project_id)['projectName'], new_project_name=new_name, reason=reason.strip(), operator_name=operator.nick_name if operator else user.get('user_id'), create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or ''))
row.project_id = project_id
row.update_by = user.get('user_id')
row.update_time = datetime.now()
db.commit()
return {'updated': len(rows)}
def assignment_history(db, tenant, user, kind, row_id):
model = BUSINESS.get(kind, (None,))[0]
if model is None or not business_query(db, tenant, user, kind).filter(model.id == row_id).first():
raise HTTPException(404, '记录不存在或无权访问')
rows = scoped(db, ProjectAssignmentAudit, tenant).filter(ProjectAssignmentAudit.biz_type == kind, ProjectAssignmentAudit.biz_id == row_id).order_by(ProjectAssignmentAudit.create_time.desc()).all()
return {'list': [dict(id=r.id, oldProjectName=r.old_project_name, newProjectName=r.new_project_name, reason=r.reason, operatorName=r.operator_name, createTime=r.create_time) for r in rows]}
CHILDREN = (
('members', ProjectTeamMember, 'userId', 'user_id', SysUser, 'nick_name'),
('regions', ProjectTeamRegion, 'regionId', 'region_id', BizCollectionRegion, 'region_name'),
('hospitals', ProjectTeamHospital, 'hospitalId', 'hospital_id', BizCollectionHospital, 'hospital_name'),
)
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)
if isinstance(row, ProjectTeam):
result.update(teamName=row.team_name, enabled=bool(row.enabled))
for key, model, public, attr, ref_model, name in CHILDREN:
children = scoped(db, model, tenant).filter(model.team_id == row.id).order_by(model.order_num).all()
result[key] = []
for child in children:
ref = scoped(db, ref_model, tenant).filter(ref_model.id == getattr(child, attr)).first()
result[key].append(dict(id=child.id, **{public: getattr(child, attr)}, name=getattr(ref, name) if ref else ''))
else:
result.update(projectNo=row.project_no, projectName=row.project_name, teamId=row.team_id, teamName=project_brief(db, tenant, row.id)['teamName'], bizTypes=row.biz_types.split(','), startDate=row.start_date, endDate=row.end_date, status=row.status)
return result
def list_entities(db, tenant, model, page=1, pageSize=20, **filters):
q = scoped(db, model, tenant)
name = model.team_name if model is ProjectTeam else model.project_name
if filters.get('keyword'):
q = q.filter(name.contains(filters['keyword']))
for public, attr in [('leaderUserId', 'leader_user_id'), ('teamId', 'team_id'), ('status', 'status'), ('enabled', 'enabled')]:
if filters.get(public) not in (None, '') and hasattr(model, attr):
q = q.filter(getattr(model, attr) == filters[public])
return dict(total=q.count(), list=[serialize_entity(db, tenant, r) for r in q.order_by(model.create_time.desc(), model.id).offset((page-1)*pageSize).limit(pageSize).all()])
def references(db, tenant):
return dict(users=[dict(id=r.id, name=r.nick_name) for r in scoped(db, SysUser, tenant).filter(SysUser.status == '1').all()], regions=[dict(id=r.id, name=r.region_name) for r in scoped(db, BizCollectionRegion, tenant).all()], hospitals=[dict(id=r.id, name=r.hospital_name, regionId=r.region_id) for r in scoped(db, BizCollectionHospital, tenant).all()])
def has_business(db, tenant, project_id):
# Deleted business is still a historical reference and prevents structural changes.
return any(db.query(model.id).filter(model.tenant_no == tenant, model.project_id == project_id).with_for_update().first() for model in {v[0] for v in BUSINESS.values()})
def save_entity(db, tenant, user, model, body, row_id=None):
row = get_row(db, model, tenant, row_id, lock=True) if row_id else model(id=generate_uuid(), tenant_no=tenant, create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or '')
name_key = 'teamName' if model is ProjectTeam else 'projectName'
if not str(body.get(name_key) or '').strip():
raise HTTPException(400, '名称不能为空')
leader = get_row(db, SysUser, tenant, body.get('leaderUserId'))
if leader.status != '1':
raise HTTPException(400, '负责人已停用')
row.leader_user_id = leader.id
row.remark = body.get('remark')
row.update_by = user.get('user_id')
row.update_time = datetime.now()
if model is ProjectTeam:
row.team_name = body[name_key].strip()
row.enabled = int(bool(body.get('enabled', True)))
db.add(row)
for key, child_model, public, attr, ref_model, _ in CHILDREN:
children = body.get(key, [])
seen = set()
for child in children:
ref_id = child.get(public)
if ref_id in seen:
raise HTTPException(400, '关联资料不能重复')
seen.add(ref_id)
get_row(db, ref_model, tenant, ref_id)
if child.get('id'):
existing = db.query(child_model).filter(child_model.id == child['id']).first()
if existing and (existing.tenant_no != tenant or existing.team_id != row.id or existing.del_flag):
raise HTTPException(400, '子行不属于当前项目组')
try:
sync_child_rows(db, child_model, parent_attr='team_id', parent_id=row.id, tenant_no=tenant, user_id=user.get('user_id'), items=children,
build_row=lambda rid, cm=child_model: cm(id=rid, tenant_no=tenant, team_id=row.id, create_by=user.get('user_id'), create_dept_id=user.get('dept_id') or ''),
apply_fields=lambda child, item, index, now, a=attr, p=public: (setattr(child, a, item[p]), setattr(child, 'order_num', index)),
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):
raise HTTPException(400, '项目已有业务,不可更换项目组')
kinds = body.get('bizTypes') or []
if not kinds or any(k not in COUNT_TYPES for k in kinds):
raise HTTPException(400, '请选择有效业务类型')
status = body.get('status', 'pending')
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
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
except ValueError:
raise HTTPException(400, '日期格式应为 YYYY-MM-DD')
if row.start_date and row.end_date and row.start_date > row.end_date:
raise HTTPException(400, '结束日期不能早于开始日期')
db.add(row)
db.commit()
return serialize_entity(db, tenant, row)
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}
def apply_project_choice_scope(query, db, tenant, user):
"""Apply team membership or the explicit tenant-wide project-choice permission.
The caller joins the tenant-scoped, non-deleted ProjectTeam table. Business
data scope and project availability remain separate caller constraints.
"""
if has_permission(db, tenant, user, ALL_PROJECTS_PERMISSION):
return query
memberships = scoped(db, ProjectTeamMember, tenant).filter(
ProjectTeamMember.user_id == user.get('user_id')).with_entities(ProjectTeamMember.team_id)
return query.filter(or_(ProjectTeam.leader_user_id == user.get('user_id'),
Project.team_id.in_(memberships)))
def options(db, tenant, user, biz_type, keyword=None):
if biz_type not in BUSINESS:
raise HTTPException(400, '业务类型不支持')
effective_type = 'cell_storage' if biz_type == 'presign' else biz_type
q = scoped(db, Project, tenant).join(ProjectTeam, and_(
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant,
ProjectTeam.del_flag == 0, ProjectTeam.enabled == 1)).filter(Project.status == 'active')
q = apply_project_choice_scope(q, db, tenant, user)
if keyword:
q = q.filter(or_(Project.project_name.contains(keyword), Project.project_no.contains(keyword)))
rows = q.with_entities(Project.id, Project.project_name, Project.project_no,
Project.team_id, Project.biz_types, ProjectTeam.team_name).order_by(
Project.create_time.desc(), Project.id).all()
result = [dict(id=r.id, projectName=r.project_name, projectNo=r.project_no,
teamId=r.team_id, teamName=r.team_name, disabled=False)
for r in rows if effective_type in r.biz_types.split(',')]
from app.core.config import settings
return {'list': result, 'required': settings.PROJECT_ASSIGNMENT_REQUIRED}
def filter_options(db, tenant, user, biz_type, keyword=None, *, business_scope=None):
"""Intersect project-choice scope with visible history; include stopped projects."""
if biz_type not in BUSINESS:
raise HTTPException(400, '业务类型不支持')
model = BUSINESS[biz_type][0]
visible = business_scope if business_scope is not None else business_query(db, tenant, user, biz_type)
ids = visible.with_entities(model.project_id).filter(model.project_id.isnot(None)).distinct()
q = scoped(db, Project, tenant).join(ProjectTeam, and_(
ProjectTeam.id == Project.team_id, ProjectTeam.tenant_no == tenant,
ProjectTeam.del_flag == 0)).filter(Project.id.in_(ids))
q = apply_project_choice_scope(q, db, tenant, user)
if keyword:
q = q.filter(or_(Project.project_name.contains(keyword), Project.project_no.contains(keyword)))
rows = q.with_entities(Project.id, Project.project_name, Project.project_no,
Project.team_id, ProjectTeam.team_name).order_by(Project.create_time.desc(), Project.id).all()
return {'list': [dict(id=r.id, projectName=r.project_name, projectNo=r.project_no,
teamId=r.team_id, teamName=r.team_name, disabled=False) for r in rows],
'required': False}
@@ -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
@@ -127,6 +127,7 @@ def _payment_detail_row(d: BizProtocolPaymentDetail, pay_mode: str, idx: int) ->
"installmentDueDate": _fmt_ymd(d.installment_due_date),
"dueAmount": _dec(d.due_amount),
"transferAmount": _dec(d.transfer_amount),
"refundAmount": _dec(getattr(d, "refund_amount", None)),
"payStatus": st,
"payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"),
"transferTime": _fmt_dt(getattr(d, "transfer_time", None)),
@@ -156,6 +157,14 @@ def build_protocol_payment_summary(
detail_rows = [_payment_detail_row(d, pay_mode, i) for i, d in enumerate(sorted_details)]
received = _received_amount_from_details(sorted_details)
pending = max(contract - received, 0) if contract is not None else None
refund_total = 0.0
has_refund = False
for d in sorted_details:
amt = _dec(getattr(d, "refund_amount", None))
if amt is None:
continue
has_refund = True
refund_total += float(amt)
statuses = [(d.pay_status or "").strip() for d in sorted_details]
overall = _protocol_overall_status(statuses, contract, pending)
hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"]
@@ -165,6 +174,7 @@ def build_protocol_payment_summary(
"contractAmount": contract,
"receivedAmount": received,
"pendingAmount": pending,
"refundAmount": refund_total if has_refund else None,
"statusHint": hint,
"installmentCount": len(detail_rows) if pay_mode in ("fq", "djfq") else None,
"paymentDetails": detail_rows,
+1 -1
View File
@@ -24,7 +24,7 @@ from app.models.models import (
NO_ROLE_WEB_ROLE = "guest"
# 一级菜单权限码(用于顶部菜单权限控制)
TOP_MENU_PERMS = frozenset({"menu:market", "menu:production", "menu:quality", "menu:operations", "menu:system-manage"})
TOP_MENU_PERMS = frozenset({"menu:project", "menu:market", "menu:production", "menu:quality", "menu:operations", "menu:system-manage"})
def _map_role_name_to_web(role_name: str) -> str:
@@ -0,0 +1,583 @@
#!/usr/bin/env python3
"""质检参数模板变更后,刷新历史任务结果(只改已有结果行,不给缺行任务插数)。
对应 2026-11-09 配置变更中的第 1–10 条:
1. `ZKXW-QC-000-1`:历史值「阴/阳」→「阴性/阳性」
2–10. 文本改阴阳性单选:已有结果行若值为空 → 写成模板默认「阴性」;
已有「阴/阳」同样归一;定量等异形值(如 0.01)保留不动。
不处理第 11 条 HLA(单选改文本),也不为「从未录入」的任务 INSERT 结果行。
等价 SQL(推荐在客户端直接跑预览/事务)见:
docs/sql/migrations/2026-11-09-refresh-qc-task-result-yin-yang-1-10.sql
用法(读取 backend/.env 的 DATABASE_URL,默认 dry-run):
# 推荐:按第 1–10 思路预览
python backend/scripts/refresh_qc_task_result_after_param_change.py --preset items-1-10
# 测试库实际写入
python backend/scripts/refresh_qc_task_result_after_param_change.py --preset items-1-10 --apply
# 生产库需显式放行库名
python backend/scripts/refresh_qc_task_result_after_param_change.py --preset items-1-10 --apply --allow-db zkxw
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.parse
from collections import Counter, defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
# 第 1–10 条(阴阳性类);不含第 11 条 HLA、不含仅重存项
ITEM_CODES_1_TO_10 = (
"ZKXW-QC-000-1", # 1 文案:阴/阳 → 阴性/阳性
"ZKXW-QC-003-18", # 2
"ZKXW-QC-003-19", # 3
"ZKXW-QC-003-17", # 4
"ZKXW-QC-001-12", # 5
"ZKXW-QC-001-11", # 6
"ZKXW-QC-001-10", # 7 可能有定量历史,异形值跳过
"ZKXW-QC-001-06", # 8
"ZKXW-QC-001-18", # 9
"ZKXW-QC-002-02", # 10
)
# 完整变更清单(含 HLA,供手工 --item-codes 对照;preset 不用)
ALL_CHANGED_ITEM_CODES = ITEM_CODES_1_TO_10 + ("ZKXW-QC-002-01",)
LABEL_MAP = {"阴": "阴性", "阳": "阳性"}
# 默认拒绝直连的库名(生产库 Docker 名);测试库 zkxw-sc 可写
DEFAULT_BLOCKED_DBS = {"zkxw"}
PRESETS = {
"items-1-10": {
"item_codes": ITEM_CODES_1_TO_10,
"normalize_labels": True,
"fill_empty_default": True,
"sync_param_type": True,
"description": "第1–10条:阴/阳归一 + 空结果行补默认阴性 + 兼容时同步类型快照;不插缺行",
}
}
def _load_database_url() -> str:
env_url = (os.environ.get("DATABASE_URL") or "").strip()
if env_url:
return env_url
root = Path(__file__).resolve().parents[2]
env_path = root / "backend" / ".env"
if not env_path.is_file():
env_path = Path(__file__).resolve().parents[1] / ".env"
if not env_path.is_file():
raise SystemExit("未找到 DATABASE_URL 或 backend/.env")
for line in env_path.read_text(encoding="utf-8").splitlines():
if line.startswith("DATABASE_URL="):
return line.split("=", 1)[1].strip().strip('"').strip("'")
raise SystemExit(f"{env_path} 中无 DATABASE_URL")
def _db_name(url: str) -> str:
return (urllib.parse.urlparse(url).path or "").lstrip("/")
def _parse_options(raw: Any) -> List[str]:
if not raw:
return []
if isinstance(raw, list):
return [str(x).strip() for x in raw if str(x).strip()]
try:
arr = json.loads(str(raw))
except json.JSONDecodeError:
return []
if not isinstance(arr, list):
return []
return [str(x).strip() for x in arr if str(x).strip()]
def _fetch_params(engine: Engine, codes: Sequence[str]) -> List[Dict[str, Any]]:
sql = text(
"""
SELECT
p.id AS param_id,
p.item_source,
p.item_id,
COALESCE(l.item_code, t.item_code) AS item_code,
COALESCE(l.item_name, t.item_name) AS item_name,
p.param_name,
p.param_code,
p.param_unit,
p.param_type,
p.options,
p.default_value,
p.tenant_no
FROM biz_qc_item_param p
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
WHERE p.del_flag = 0
AND COALESCE(l.item_code, t.item_code) IN :codes
ORDER BY COALESCE(l.item_code, t.item_code), p.order_num
"""
)
with engine.connect() as conn:
return [dict(r) for r in conn.execute(sql, {"codes": tuple(codes)}).mappings()]
def _scan_existing_results(
engine: Engine, param_ids: Sequence[str]
) -> List[Dict[str, Any]]:
if not param_ids:
return []
sql = text(
"""
SELECT
r.id AS result_id,
r.task_id,
r.param_id,
r.param_name,
r.param_code,
r.param_type,
r.param_value,
r.tenant_no
FROM biz_qc_task_result r
WHERE r.del_flag = 0
AND r.param_id IN :param_ids
"""
)
with engine.connect() as conn:
return [dict(r) for r in conn.execute(sql, {"param_ids": tuple(param_ids)}).mappings()]
def _count_tasks_missing_result(
engine: Engine, params: Sequence[Dict[str, Any]]
) -> Dict[str, int]:
"""只读统计:任务已关联项目但无对应结果行(本脚本不会 INSERT)。"""
out: Dict[str, int] = {}
lab = [p for p in params if p["item_source"] == "lab"]
third = [p for p in params if p["item_source"] == "third_party"]
with engine.connect() as conn:
if lab:
rows = conn.execute(
text(
"""
SELECT lti.item_code, COUNT(*) AS c
FROM biz_qc_task task
JOIN biz_qc_work_order_lab_item wi
ON wi.id = task.source_line_id AND wi.del_flag = 0
JOIN biz_qc_lab_test_item lti
ON lti.id = wi.lab_test_item_id AND lti.del_flag = 0
JOIN biz_qc_item_param p
ON p.item_source = 'lab' AND p.item_id = lti.id AND p.del_flag = 0
WHERE task.del_flag = 0
AND task.task_type = 'sysz'
AND p.id IN :param_ids
AND NOT EXISTS (
SELECT 1 FROM biz_qc_task_result r
WHERE r.task_id = task.id AND r.del_flag = 0
AND (
r.param_id = p.id
OR (p.param_code <> '' AND r.param_code = p.param_code)
)
)
GROUP BY lti.item_code
"""
),
{"param_ids": tuple(p["param_id"] for p in lab)},
).mappings()
for r in rows:
out[str(r["item_code"])] = int(r["c"])
if third:
rows = conn.execute(
text(
"""
SELECT tpi.item_code, COUNT(*) AS c
FROM biz_qc_task task
JOIN biz_qc_work_order_third_item wi
ON wi.id = task.source_line_id AND wi.del_flag = 0
JOIN biz_qc_third_party_item tpi
ON tpi.id = wi.third_party_item_id AND tpi.del_flag = 0
JOIN biz_qc_item_param p
ON p.item_source = 'third_party' AND p.item_id = tpi.id
AND p.del_flag = 0
WHERE task.del_flag = 0
AND task.task_type = 'dsfjc'
AND p.id IN :param_ids
AND NOT EXISTS (
SELECT 1 FROM biz_qc_task_result r
WHERE r.task_id = task.id AND r.del_flag = 0
AND (
r.param_id = p.id
OR (p.param_code <> '' AND r.param_code = p.param_code)
)
)
GROUP BY tpi.item_code
"""
),
{"param_ids": tuple(p["param_id"] for p in third)},
).mappings()
for r in rows:
code = str(r["item_code"])
out[code] = out.get(code, 0) + int(r["c"])
return out
def _inventory(
params: List[Dict[str, Any]], rows: List[Dict[str, Any]]
) -> Dict[str, Dict[str, int]]:
by_param = {p["param_id"]: p for p in params}
inv: Dict[str, Dict[str, int]] = defaultdict(
lambda: {
"result_rows": 0,
"empty": 0,
"old_label": 0,
"standard_label": 0,
"other_value": 0,
}
)
for row in rows:
param = by_param.get(row["param_id"])
if not param:
continue
code = param["item_code"]
inv[code]["result_rows"] += 1
value = (row.get("param_value") or "").strip()
if not value:
inv[code]["empty"] += 1
elif value in LABEL_MAP:
inv[code]["old_label"] += 1
elif value in ("阴性", "阳性"):
inv[code]["standard_label"] += 1
else:
inv[code]["other_value"] += 1
return dict(inv)
def _plan_updates(
params: List[Dict[str, Any]],
rows: List[Dict[str, Any]],
*,
normalize_labels: bool,
fill_empty_default: bool,
sync_param_type: bool,
) -> Tuple[List[Dict[str, Any]], Dict[str, int]]:
by_param = {p["param_id"]: p for p in params}
planned: List[Dict[str, Any]] = []
stats = {
"scanned": len(rows),
"normalize_labels": 0,
"fill_empty_default": 0,
"sync_param_type": 0,
"skip_incompatible_value": 0,
"unchanged": 0,
}
for row in rows:
param = by_param.get(row["param_id"])
if not param:
continue
old_value = (row.get("param_value") or "").strip()
new_value = old_value
old_type = (row.get("param_type") or "text").strip() or "text"
new_type = (param.get("param_type") or "text").strip() or "text"
opts = _parse_options(param.get("options"))
default = (param.get("default_value") or "").strip()
reasons: List[str] = []
if normalize_labels and old_value in LABEL_MAP:
new_value = LABEL_MAP[old_value]
reasons.append("normalize_labels")
stats["normalize_labels"] += 1
if fill_empty_default and not new_value and new_type == "select" and default:
new_value = default
reasons.append("fill_empty_default")
stats["fill_empty_default"] += 1
type_to_write = old_type
if sync_param_type and old_type != new_type:
# 值空或已在新选项内才同步类型;定量等异形值跳过
if (not new_value) or (new_type != "select") or (new_value in opts):
type_to_write = new_type
reasons.append("sync_param_type")
stats["sync_param_type"] += 1
else:
stats["skip_incompatible_value"] += 1
if not reasons or (new_value == old_value and type_to_write == old_type):
if not reasons:
stats["unchanged"] += 1
elif new_value == old_value and type_to_write == old_type:
stats["unchanged"] += 1
continue
planned.append(
{
"result_id": row["result_id"],
"task_id": row["task_id"],
"item_code": param["item_code"],
"param_name": param["param_name"],
"old_value": old_value,
"new_value": new_value,
"old_type": old_type,
"new_type": type_to_write,
"reasons": reasons,
}
)
return planned, stats
def _apply_updates(
engine: Engine,
planned: List[Dict[str, Any]],
*,
update_by: str,
) -> int:
if not planned:
return 0
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sql = text(
"""
UPDATE biz_qc_task_result
SET param_value = :param_value,
param_type = :param_type,
update_by = :update_by,
update_time = :update_time
WHERE id = :id AND del_flag = 0
"""
)
with engine.begin() as conn:
for item in planned:
conn.execute(
sql,
{
"id": item["result_id"],
"param_value": item["new_value"] or None,
"param_type": item["new_type"],
"update_by": update_by,
"update_time": now,
},
)
return len(planned)
def _print_by_item(planned: List[Dict[str, Any]]) -> None:
grouped: Dict[str, Counter] = defaultdict(Counter)
for item in planned:
for reason in item["reasons"]:
grouped[item["item_code"]][reason] += 1
grouped[item["item_code"]]["rows"] += 1
if not grouped:
print(" (none)")
return
for code in sorted(grouped):
c = grouped[code]
print(
f" {code}: rows={c['rows']} "
f"normalize={c['normalize_labels']} "
f"fill_empty={c['fill_empty_default']} "
f"sync_type={c['sync_param_type']}"
)
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(
description="刷新质检历史结果(默认 dry-run)。推荐 --preset items-1-10"
)
parser.add_argument(
"--preset",
choices=sorted(PRESETS.keys()),
help="使用约定刷新策略(推荐 items-1-10)",
)
parser.add_argument(
"--apply",
action="store_true",
help="实际写入;默认只预览",
)
parser.add_argument(
"--normalize-labels",
action=argparse.BooleanOptionalAction,
default=None,
help="阴/阳 → 阴性/阳性;preset 默认开",
)
parser.add_argument(
"--fill-empty-default",
action=argparse.BooleanOptionalAction,
default=None,
help="已有空结果行写成模板默认值;preset items-1-10 默认开;绝不 INSERT 缺行",
)
parser.add_argument(
"--sync-param-type",
action=argparse.BooleanOptionalAction,
default=None,
help="值兼容时同步 param_type 快照;preset 默认开",
)
parser.add_argument(
"--item-codes",
nargs="*",
default=None,
help="手工指定项目编码;与 --preset 同时给时以本参数为准",
)
parser.add_argument(
"--allow-db",
action="append",
default=[],
help="额外允许写入的数据库名;生产库名默认拒绝",
)
parser.add_argument(
"--update-by",
default="d3000000000000000000000000000001",
help="写入 update_by",
)
parser.add_argument(
"--limit-print",
type=int,
default=50,
help="明细最多打印条数",
)
parser.add_argument(
"--skip-missing-stats",
action="store_true",
help="跳过「缺结果行任务」只读统计",
)
args = parser.parse_args(argv)
if args.preset:
preset = PRESETS[args.preset]
item_codes = list(args.item_codes) if args.item_codes is not None else list(
preset["item_codes"]
)
normalize_labels = (
preset["normalize_labels"]
if args.normalize_labels is None
else args.normalize_labels
)
fill_empty_default = (
preset["fill_empty_default"]
if args.fill_empty_default is None
else args.fill_empty_default
)
sync_param_type = (
preset["sync_param_type"]
if args.sync_param_type is None
else args.sync_param_type
)
print(f"preset={args.preset}: {preset['description']}")
else:
item_codes = (
list(args.item_codes)
if args.item_codes is not None
else list(ITEM_CODES_1_TO_10)
)
# 无 preset 时保持保守:只开文案归一与类型同步,空值需显式打开
normalize_labels = True if args.normalize_labels is None else args.normalize_labels
fill_empty_default = (
False if args.fill_empty_default is None else args.fill_empty_default
)
sync_param_type = True if args.sync_param_type is None else args.sync_param_type
print(
"preset=(none); 默认仅 normalize+sync_type。"
"完整第1–10策略请加 --preset items-1-10"
)
url = _load_database_url()
db = _db_name(url)
print(f"database={db}")
print(
f"flags: normalize_labels={normalize_labels} "
f"fill_empty_default={fill_empty_default} sync_param_type={sync_param_type}"
)
print(f"item_codes({len(item_codes)}): {', '.join(item_codes)}")
blocked = DEFAULT_BLOCKED_DBS - set(args.allow_db)
if args.apply and db in blocked:
raise SystemExit(
f"拒绝写入疑似生产库 `{db}`。"
f"测试库确认后重试;确需写入请加 --allow-db {db}"
)
engine = create_engine(url, pool_pre_ping=True)
params = _fetch_params(engine, item_codes)
if not params:
print("未找到匹配的参数模板,退出")
return 1
found_codes = {p["item_code"] for p in params}
missing_codes = [c for c in item_codes if c not in found_codes]
print(f"matched_params={len(params)}")
for p in params:
print(
f" {p['item_code']} | {p['param_name']} | type={p['param_type']} "
f"| options={p['options']} | default={p['default_value']}"
)
if missing_codes:
print(f"WARNING missing item templates: {', '.join(missing_codes)}")
rows = _scan_existing_results(engine, [p["param_id"] for p in params])
inv = _inventory(params, rows)
print("--- inventory (existing result rows only) ---")
if not inv:
print(" (no existing result rows for these params)")
for code in item_codes:
if code not in inv:
print(f" {code}: result_rows=0")
continue
s = inv[code]
print(
f" {code}: rows={s['result_rows']} empty={s['empty']} "
f"old_label(阴/阳)={s['old_label']} "
f"standard(阴性/阳性)={s['standard_label']} "
f"other={s['other_value']}"
)
if not args.skip_missing_stats:
missing = _count_tasks_missing_result(engine, params)
print("--- tasks with NO result row (info only; will NOT insert) ---")
if not missing:
print(" (none)")
else:
for code in sorted(missing):
print(f" {code}: {missing[code]}")
planned, stats = _plan_updates(
params,
rows,
normalize_labels=normalize_labels,
fill_empty_default=fill_empty_default,
sync_param_type=sync_param_type,
)
print("--- plan stats ---")
for k, v in stats.items():
print(f" {k}: {v}")
print("--- plan by item ---")
_print_by_item(planned)
print(f"planned_updates: {len(planned)}")
for item in planned[: args.limit_print]:
print(
f" {item['item_code']} task={item['task_id']} "
f"value {item['old_value']!r}->{item['new_value']!r} "
f"type {item['old_type']}->{item['new_type']} "
f"reasons={item['reasons']}"
)
if len(planned) > args.limit_print:
print(f" ... and {len(planned) - args.limit_print} more")
if not args.apply:
print("dry-run only;加 --apply 才会写入")
return 0
n = _apply_updates(engine, planned, update_by=args.update_by)
print(f"applied={n}")
return 0
if __name__ == "__main__":
sys.exit(main())
+38
View File
@@ -2550,3 +2550,41 @@ process.stdout.write(JSON.stringify([
'¥9,999,999,999,999,999.99',
'¥9,999,999,999,999,999.99',
]
def test_web_presign_list_includes_ledger_fields():
from datetime import datetime
from app.crud.presign_order import list_presign_orders
db = _session()
_add_master_data(db)
order = _order('ledger-1', region_id='region-a', sales_user_id='u1')
order.hospital_id = 'hospital-a'
order.mother_name = '陈影'
transfer = _transfer('ledger-transfer', order_id='ledger-1', pay_status='yjf')
transfer.transfer_time = datetime(2026, 4, 2, 16, 49, 42)
db.add_all(
[
order,
transfer,
BizPresignOrderSampleType(
id='ledger-type',
presign_order_id='ledger-1',
sample_type_id='sample-a',
tenant_no='tenant-a',
create_dept_id='',
order_num=1,
del_flag=0,
),
]
)
db.commit()
items, total = list_presign_orders(db, tenant_no='tenant-a', page_size=10)
assert total == 1
assert items[0]['regionName'] == 'A区'
assert items[0]['hospitalName'] == 'A医院'
assert items[0]['sampleTypeNames'] == ['样本A']
assert items[0]['transferTimes'] == ['2026-04-02 16:49:42']
@@ -0,0 +1,201 @@
"""Mobile attribution writes and scoped reads against isolated SQLite records."""
import pytest
from fastapi import HTTPException
from app.core.config import settings
from app.models.project_management import Project, ProjectTeam
from app.models.models import BizPresignOrder, BizCollectionProtocol
from app.models.research_donation import BizResearchDonation
from app.schemas.market_mobile import MarketPresignOrderBody, MarketProtocolBody
from app.services import market_presign, market_protocol
from test_market_protocol_api import _session, _masters, _ctx, _user, _body, _cell, _donation
@pytest.fixture
def db():
session = _session()
_masters(session)
session.add_all([
ProjectTeam(id='team', tenant_no='tenant-a', team_name='团队', leader_user_id='u1'),
Project(id='project', tenant_no='tenant-a', project_no='P', project_name='项目', team_id='team', leader_user_id='u1', status='active', biz_types='presign,cell_storage,research_donation'),
])
session.commit()
yield session
session.close()
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_create_persists_and_returns_project(db, kind):
if kind == 'presign':
result = market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info=_user())
basic = result
else:
result = market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user())
basic = result['basic']
model = {'presign': BizPresignOrder, 'cell_storage': BizCollectionProtocol, 'research_donation': BizResearchDonation}[kind]
db.expire_all()
assert db.get(model, basic['id']).project_id == 'project'
assert basic['projectId'] == 'project'
assert basic['projectName'] == '项目'
assert basic['teamId'] == 'team'
assert basic['teamName'] == '团队'
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_creation_obeys_required_configuration(db, monkeypatch, kind):
monkeypatch.setattr(settings, 'PROJECT_ASSIGNMENT_REQUIRED', True)
with pytest.raises(HTTPException):
if kind == 'presign':
market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲'), ctx=_ctx('specialist'), user_info=_user())
else:
market_protocol.create_protocol(db, body=_body(kind, samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user())
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_scoped_lists_and_immutable_edits(db, kind):
if kind == 'presign':
own = BizPresignOrder(id='own', tenant_no='tenant-a', sales_user_id='u1', order_status='active', project_id='project')
hidden = BizPresignOrder(id='hidden', tenant_no='tenant-a', sales_user_id='u2', order_status='active', project_id='project')
db.add_all([own, hidden])
else:
factory = _cell if kind == 'cell_storage' else _donation
own = factory(db, 'own')
hidden = factory(db, 'hidden', sales='u2')
own.project_id = hidden.project_id = 'project'
db.commit()
service = market_presign if kind == 'presign' else market_protocol
listing = service.list_presign_orders if kind == 'presign' else service.list_protocols
params = {} if kind == 'presign' else {'source': kind}
rows, total = listing(db, ctx=_ctx('specialist'), user_info=_user(), project_id='project', team_id='team', **params)
assert total == 1
assert rows[0]['id'] == 'own'
assert rows[0]['projectName'] == '项目'
assert listing(db, ctx=_ctx('specialist'), user_info=_user(), project_id='missing', **params)[1] == 0
db.get(Project, 'project').status = 'disabled'
db.commit()
kwargs = {'order_id': 'own'} if kind == 'presign' else {'protocol_id': 'own', 'source': kind}
update = service.update_presign_order if kind == 'presign' else service.update_protocol
body = MarketPresignOrderBody(remark='保留历史') if kind == 'presign' else MarketProtocolBody(source=kind, remark='保留历史')
update(db, ctx=_ctx('specialist'), user_info=_user(), body=body, **kwargs)
db.refresh(own)
assert own.project_id == 'project'
body = MarketPresignOrderBody(projectId=None) if kind == 'presign' else MarketProtocolBody(source=kind, projectId=None)
with pytest.raises(HTTPException, match='项目归属维护'):
update(db, ctx=_ctx('specialist'), user_info=_user(), body=body, **kwargs)
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_rejects_foreign_project_and_legacy_backfill(db, kind):
db.get(Project, 'project').tenant_no = 'tenant-other'
db.commit()
with pytest.raises(HTTPException):
if kind == 'presign':
market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info=_user())
else:
market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info=_user())
if kind == 'presign':
row = BizPresignOrder(id='legacy', tenant_no='tenant-a', sales_user_id='u1', order_status='active')
db.add(row)
else:
row = (_cell if kind == 'cell_storage' else _donation)(db, 'legacy')
db.commit()
with pytest.raises(HTTPException, match='项目归属维护'):
if kind == 'presign':
market_presign.update_presign_order(db, order_id='legacy', body=MarketPresignOrderBody(projectId='project'), ctx=_ctx('specialist'), user_info=_user())
else:
market_protocol.update_protocol(db, source=kind, protocol_id='legacy', body=MarketProtocolBody(source=kind, projectId='project'), ctx=_ctx('specialist'), user_info=_user())
db.refresh(row)
assert row.project_id is None
@pytest.mark.parametrize('role', ['specialist', 'manager'])
@pytest.mark.parametrize('resource', ['protocols', 'presign-orders'])
def test_mobile_http_list_binds_project_and_team_aliases(db, role, resource):
import asyncio
import httpx
from fastapi import FastAPI
from app.api.market_mobile import router
from app.core.database import get_db
row = _cell(db, 'visible')
row.project_id = 'project'
hidden = _cell(db, 'hidden', sales='u2', manager='manager-other')
hidden.project_id = 'project'
db.add_all([
BizPresignOrder(id='presign-own', tenant_no='tenant-a', sales_user_id='u1', region_manager_user_id='manager-a', order_status='active', project_id='project'),
BizPresignOrder(id='presign-hidden', tenant_no='tenant-a', sales_user_id='u2', region_manager_user_id='manager-other', order_status='active', project_id='project'),
])
db.commit()
app = FastAPI()
app.include_router(router)
async def database():
yield db
async def user():
return _user('manager-a' if role == 'manager' else 'u1')
app.dependency_overrides[get_db] = database
for route in router.routes:
for dependency in route.dependant.dependencies:
if dependency.name == 'user':
app.dependency_overrides[dependency.call] = user
async def check():
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url='http://test') as client:
url = f'/mini/market/{role}/{resource}'
response = await client.get(url, params={'source': 'cell_storage', 'projectId': 'project', 'teamId': 'team'})
assert response.status_code == 200
data = response.json()['data']
assert data['total'] == 1
assert data['list'][0]['projectName'] == '项目'
response = await client.get(url, params={'source': 'cell_storage', 'projectId': 'missing'})
assert response.json()['data']['total'] == 0
response = await client.get(url, params={'source': 'cell_storage', 'teamId': 'missing'})
assert response.json()['data']['total'] == 0
asyncio.run(check())
@pytest.mark.parametrize('role', ['specialist', 'manager'])
def test_mobile_history_filter_options_keep_field_scope(db, role):
from app.services.project_management import filter_options
from app.services.market_field_scope import apply_presign_scope
db.get(Project, 'project').status = 'disabled'
db.add(Project(id='hidden-project', tenant_no='tenant-a', project_no='H', project_name='隐藏', team_id='team', leader_user_id='u2', status='active', biz_types='cell_storage'))
db.add_all([
BizPresignOrder(id='own', tenant_no='tenant-a', sales_user_id='u1', region_manager_user_id='manager-a', project_id='project'),
BizPresignOrder(id='hidden', tenant_no='tenant-a', sales_user_id='u2', region_manager_user_id='manager-other', project_id='hidden-project'),
])
db.commit()
user = _user('manager-a' if role == 'manager' else 'u1')
query = apply_presign_scope(db.query(BizPresignOrder), db, _ctx(role, user['user_id']), user_info=user)
result = filter_options(db, 'tenant-a', user, 'presign', business_scope=query)
assert [row['id'] for row in result['list']] == ['project']
@pytest.mark.parametrize('kind', ['presign', 'cell_storage', 'research_donation'])
def test_mobile_cross_team_assignment_requires_explicit_all_permission(db, kind):
from app.models.models import SysMenu, SysRole, SysRoleMenu, SysUserRole
# Becoming a project leader does not make the user a member of its team.
db.get(ProjectTeam, 'team').leader_user_id = 'u2'
db.add_all([
SysRole(id='assignment-role', tenant_no='tenant-a', role_name='归属维护', data_scope='4'),
SysUserRole(id='assignment-user-role', tenant_no='tenant-a', user_id='u1', role_id='assignment-role'),
SysMenu(id='assignment-menu', tenant_no='tenant-a', menu_name='归属维护', perms='project:assignment:edit'),
SysRoleMenu(id='assignment-role-menu', tenant_no='tenant-a', role_id='assignment-role', menu_id='assignment-menu'),
])
db.commit()
def create():
if kind == 'presign':
return market_presign.create_presign_order(db, body=MarketPresignOrderBody(motherName='母亲', projectId='project'), ctx=_ctx('specialist'), user_info={**_user(), 'role': 'admin'})
return market_protocol.create_protocol(db, body=_body(kind, projectId='project', samples=[], attachments={}), ctx=_ctx('specialist'), user_info={**_user(), 'role': 'admin'})
with pytest.raises(HTTPException) as caught:
create()
assert caught.value.status_code == 403
db.add_all([
SysMenu(id='all-menu', tenant_no='tenant-a', menu_name='全部项目', menu_type='F', visible='0', perms='project:project:all'),
SysRoleMenu(id='all-role-menu', tenant_no='tenant-a', role_id='assignment-role', menu_id='all-menu'),
])
db.commit()
result = create()
assert (result if kind == 'presign' else result['basic'])['projectId'] == 'project'
+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
@@ -0,0 +1,148 @@
"""Existing order write paths retain project attribution without changing business fields."""
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models.base import Base
from app.models.models import BizCollectionProtocol, BizPresignOrder
from app.crud import collection_protocol, presign_order
class ProjectAssignmentIntegrationTest(unittest.TestCase):
def setUp(self):
self.engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
def tearDown(self):
self.db.close()
self.engine.dispose()
def test_protocol_creation_persists_assignment(self):
row = collection_protocol.create_collection_protocol(self.db, protocol_no='CP1', tenant_no='t', project_id='p')
self.assertEqual(self.db.get(BizCollectionProtocol, row.id).project_id, 'p')
def test_presign_creation_persists_assignment_and_region(self):
row = presign_order.create_presign_order(self.db, tenant_no='t', user_id='u', fields={'protocol_no':'PS1', 'project_id':'p', 'region_id':'region'})
self.assertEqual(self.db.get(BizPresignOrder, row.id).project_id, 'p')
self.assertEqual(row.region_id, 'region')
def test_edit_rejects_assigning_legacy_order_and_preserves_omitted(self):
from app.services.project_assignment import guard_project_edit
from fastapi import HTTPException
old = BizPresignOrder(project_id=None)
guard_project_edit(old, {})
with self.assertRaises(HTTPException):
guard_project_edit(old, {'projectId':'p'})
old.project_id = 'p'
guard_project_edit(old, {'project_id':'p'})
with self.assertRaises(HTTPException):
guard_project_edit(old, {'project_id':None})
def test_conversions_inherit_existing_assignment(self):
from app.crud import research_donation
from app.models.research_donation import BizResearchDonation
row = collection_protocol.create_collection_protocol(self.db, protocol_no='CONVERT1', tenant_no='t', project_id='p', protocol_status='sxz')
donation = research_donation.convert_from_storage(self.db, row.id, tenant_no='t')
self.assertEqual(donation.project_id, 'p')
# A direct donation may convert into a new storage protocol.
direct = BizResearchDonation(id='direct', tenant_no='t', donation_no='DIRECT1', donation_status='sxz', project_id='p')
self.db.add(direct)
self.db.commit()
research_donation.convert_to_storage(self.db, direct.id, tenant_no='t')
converted = self.db.query(BizCollectionProtocol).filter_by(protocol_no='DIRECT1').one()
self.assertEqual(converted.project_id, 'p')
def test_basic_experiment_creation_and_read_preserve_project_title(self):
from app.crud import pilot_application
data = dict(bizType='basic_experiment', projectId='p', projectTitle='课题', customerName='客户', customerContact='联系人', customerPhone='13800000000')
result = pilot_application.create_application(self.db, data=data, tenant_no='t', user_id='u', dept_id='')
self.assertEqual(result['projectId'], 'p')
self.assertEqual(result['projectTitle'], '课题')
def test_web_schema_keeps_explicit_null_for_edit_guard(self):
from app.api.presign_order import PresignOrderBody
from app.api.collection_protocol import ProtocolUpdateBody
from app.api.research_donation import DonationUpdateBody
from app.api.pilot_application import ApplicationSaveBody
for cls in (PresignOrderBody, ProtocolUpdateBody, DonationUpdateBody, ApplicationSaveBody):
self.assertNotIn('project_id', cls().model_dump(exclude_unset=True))
self.assertIn('project_id', cls(projectId=None).model_dump(exclude_unset=True))
def test_statistics_aggregates_before_loading_project_metadata(self):
from sqlalchemy import event
from app.models.project_management import Project, ProjectTeam
from app.services.project_management import statistics
self.db.add(ProjectTeam(id='g', tenant_no='t', team_name='组', leader_user_id='u'))
self.db.add(Project(id='p', tenant_no='t', project_no='P', project_name='项目', team_id='g', leader_user_id='u', biz_types='cell_storage', status='active'))
self.db.add_all([BizCollectionProtocol(id=str(i), tenant_no='t', protocol_no=str(i), project_id='p') for i in range(30)])
self.db.commit()
selects = []
def count(conn, cursor, statement, params, context, many):
if statement.lstrip().upper().startswith('SELECT'):
selects.append(statement)
event.listen(self.engine, 'before_cursor_execute', count)
result = statistics(self.db, 't', {'user_id':'u', 'role':'super_admin'})
self.assertEqual(result['totals']['cell_storage'], 30)
self.assertLessEqual(len(selects), 10)
def test_ordinary_edit_cannot_rewire_conversion_source(self):
from app.crud import research_donation
from app.models.research_donation import BizResearchDonation
row = collection_protocol.create_collection_protocol(self.db, protocol_no='LINK1', tenant_no='t', source_presign_order_id='original')
with self.assertRaises(ValueError):
collection_protocol.update_collection_protocol(self.db, row.id, tenant_no='t', source_presign_order_id='other')
self.db.rollback()
donation = BizResearchDonation(id='rd', tenant_no='t', donation_no='LINK2', source_type='from_storage', source_protocol_id='original')
self.db.add(donation)
self.db.commit()
with self.assertRaises(ValueError):
research_donation.update_donation(self.db, donation.id, tenant_no='t', data={'sourceProtocolId':'other'})
self.db.rollback()
with self.assertRaises(ValueError):
research_donation.update_donation(self.db, donation.id, tenant_no='t', data={'sourceType':'direct'})
def test_management_initial_empty_filters_do_not_hide_existing_rows(self):
from app.models.project_management import ProjectTeam
from app.services.project_management import list_entities
self.db.add(ProjectTeam(id='g', tenant_no='t', team_name='组', leader_user_id='u'))
self.db.commit()
self.assertEqual(list_entities(self.db, 't', ProjectTeam, keyword='', leaderUserId='', enabled=None)['total'], 1)
self.assertEqual(list_entities(self.db, 't', ProjectTeam, enabled=False)['total'], 0)
def test_history_filters_keep_stopped_projects_within_membership_and_business_access(self):
from app.models.project_management import Project, ProjectTeam
from app.models.models import SysRole, SysUserRole
from app.services.project_management import filter_options, options
self.db.add_all([
ProjectTeam(id='g', tenant_no='t', team_name='停用组', leader_user_id='u', enabled=0),
Project(id='p', tenant_no='t', project_no='P', project_name='历史项目', team_id='g', leader_user_id='other', biz_types='cell_storage', status='disabled'),
Project(id='hidden', tenant_no='t', project_no='H', project_name='不可见项目', team_id='g', leader_user_id='other', biz_types='cell_storage', status='active'),
SysRole(id='role', role_name='member', tenant_no='t', data_scope='4'),
SysUserRole(id='ur', user_id='u', role_id='role', tenant_no='t'),
BizCollectionProtocol(id='mine', protocol_no='M', tenant_no='t', create_by='u', project_id='p'),
BizCollectionProtocol(id='other', protocol_no='O', tenant_no='t', create_by='other', project_id='hidden'),
])
self.db.commit()
user = {'user_id':'u', 'role':'admin'}
self.assertEqual(options(self.db, 't', user, 'cell_storage')['list'], [])
self.assertEqual([r['id'] for r in filter_options(self.db, 't', user, 'cell_storage')['list']], ['p'])
def test_generic_history_options_require_existing_web_role_guard(self):
from app.api.project_management import options
from fastapi import HTTPException
with self.assertRaises(HTTPException) as caught:
options(bizType='cell_storage', usage='filter', keyword=None, ctx=('t', {'user_id':'u', 'role':'user'}), db=self.db)
self.assertEqual(caught.exception.status_code, 403)
def test_presign_conversion_inherits_assignment_after_project_stops(self):
from app.models.project_management import Project, ProjectTeam
self.db.add_all([
ProjectTeam(id='g', tenant_no='t', team_name='组', leader_user_id='other', enabled=0),
Project(id='p', tenant_no='t', project_no='P', project_name='历史项目', team_id='g', leader_user_id='other', biz_types='cell_storage', status='disabled'),
BizPresignOrder(id='ps', tenant_no='t', protocol_no='PSCONVERT', project_id='p', order_status='active'),
])
self.db.commit()
result = presign_order.convert_to_protocol(self.db, order_id='ps', tenant_no='t', user_id=None)
protocol = self.db.get(BizCollectionProtocol, result['protocolId'])
self.assertEqual(protocol.project_id, 'p')
self.assertEqual(protocol.source_presign_order_id, 'ps')
@@ -0,0 +1,248 @@
"""归属维护业务检索的权限、分页及关联资料边界。"""
import unittest
from datetime import datetime
from fastapi import HTTPException
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session
from app.models.base import Base
from app.models.models import (
BizChannelServiceOrder, BizCollectionHospital, BizCollectionProtocol,
BizPilotApplication, BizPilotPatient, BizProtocolCollectionInfo,
BizProtocolPartyCustomer, BizProtocolSample, SysMenu, SysRole, SysRoleMenu, SysUser, SysUserRole,
)
from app.models.research_donation import (
BizResearchDonation, BizResearchDonationParty, BizResearchDonationSample,
)
from app.services import project_assignment_records as svc
from app.services import project_management
class ProjectAssignmentRecordsTest(unittest.TestCase):
def setUp(self):
self.engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.user = dict(user_id='u', role='super_admin', tenant_no='t')
def tearDown(self):
self.db.close()
self.engine.dispose()
def records(self, kind='cell_storage', **filters):
return svc.records(self.db, 't', self.user, kind, **filters)
def grant(self, code):
self.db.add_all([
SysRole(id=code, tenant_no='t', role_name=code, data_scope='4'),
SysMenu(id=code, tenant_no='t', menu_name=code, menu_type='F', perms=code),
SysRoleMenu(id=code, tenant_no='t', role_id=code, menu_id=code),
SysUserRole(id=code, tenant_no='t', role_id=code, user_id='u'),
])
self.db.commit()
def test_assignment_permission_and_parent_data_scope(self):
self.user['role'] = 'admin'
self.grant('project:statistics:list')
with self.assertRaises(HTTPException) as denied:
self.records()
self.assertEqual(denied.exception.status_code, 403)
self.grant('project:assignment:edit')
self.grant('project:project:all')
for row_id, tenant, owner, deleted in [
('own', 't', 'u', 0), ('other', 't', 'other', 0),
('foreign', 'foreign', 'u', 0), ('deleted', 't', 'u', 1),
]:
self.db.add(BizCollectionProtocol(id=row_id, tenant_no=tenant, protocol_no=row_id,
create_by=owner, del_flag=deleted))
self.db.commit()
self.assertEqual([r['id'] for r in self.records()['list']], ['own'])
def test_statistics_detail_permission_is_separate_from_assignment(self):
self.user['role'] = 'admin'
self.grant('project:statistics:list')
self.db.add_all([
BizCollectionProtocol(id='own', tenant_no='t', protocol_no='OWN', create_by='u'),
BizCollectionProtocol(id='other', tenant_no='t', protocol_no='OTHER', create_by='other'),
BizCollectionProtocol(id='foreign', tenant_no='foreign', protocol_no='FOREIGN', create_by='u'),
BizCollectionProtocol(id='deleted', tenant_no='t', protocol_no='DELETED', create_by='u', del_flag=1),
BizProtocolPartyCustomer(id='p', tenant_no='t', collection_protocol_id='own', mother_name='Mother', mother_phone='13800000000'),
BizProtocolSample(id='s', tenant_no='t', collection_protocol_id='own', sample_code='SAMPLE'),
])
self.db.commit()
result = svc.statistics_records(self.db, 't', self.user, 'cell_storage', keyword='SAMPLE')
self.assertEqual(result['total'], 1)
self.assertEqual(result['list'][0]['motherName'], 'Mother')
self.assertEqual(result['list'][0]['motherPhone'], '13800000000')
self.assertEqual(result['list'][0]['sampleCodes'], 'SAMPLE')
self.assertEqual(svc.statistics_records(self.db, 't', self.user, 'cell_storage')['total'], 1)
with self.assertRaises(HTTPException) as denied:
self.records()
self.assertEqual(denied.exception.status_code, 403)
self.db.get(SysRole, 'project:statistics:list').status = '0'
self.db.commit()
self.grant('project:assignment:edit')
self.assertEqual(self.records()['total'], 1)
with self.assertRaises(HTTPException) as denied:
svc.statistics_records(self.db, 't', self.user, 'cell_storage')
self.assertEqual(denied.exception.status_code, 403)
def test_four_statistics_details_share_fields_and_match_summary_counts(self):
cases = (
('cell_storage', BizCollectionProtocol, dict(protocol_no='C'), 'sampleCodes'),
('research_donation', BizResearchDonation, dict(donation_no='D'), 'motherName'),
('pilot_service', BizPilotApplication, dict(application_no='P', channel_service_order_id='', biz_type='pilot_service', application_name='Pilot'), 'applicationName'),
('basic_experiment', BizPilotApplication, dict(application_no='B', channel_service_order_id='', biz_type='basic_experiment', project_title='Study'), 'projectTitle'),
)
for kind, model, fields, detail_key in cases:
self.db.add_all([
model(id=kind + '-assigned', tenant_no='t', project_id='project', create_time=datetime(2026, 11, 4), **fields),
model(id=kind + '-empty', tenant_no='t', create_time=datetime(2026, 11, 4), **fields),
model(id=kind + '-old', tenant_no='t', create_time=datetime(2026, 11, 1), **fields),
])
self.db.commit()
filters = dict(startDate='2026-11-04', endDate='2026-11-04')
summary = project_management.statistics(self.db, 't', self.user, **filters)
for kind, _, _, detail_key in cases:
with self.subTest(kind=kind):
details = svc.statistics_records(self.db, 't', self.user, kind, **filters)
self.assertEqual(details, self.records(kind, **filters))
self.assertEqual(details['total'], summary['totals'][kind])
self.assertEqual(details['total'], 2)
self.assertIn(detail_key, details['list'][0])
unassigned = svc.statistics_records(self.db, 't', self.user, kind, unassigned=True, **filters)
self.assertEqual(unassigned['total'], summary['unassignedTotals'][kind])
self.assertEqual(unassigned['list'][0]['id'], kind + '-empty')
assigned = svc.statistics_records(self.db, 't', self.user, kind, projectId='project', **filters)
self.assertEqual(assigned['total'], 1)
self.assertEqual(assigned['list'][0]['id'], kind + '-assigned')
def test_children_do_not_duplicate_counts_or_leak_foreign_deleted_data(self):
self.db.add_all([
BizCollectionProtocol(id='a', tenant_no='t', protocol_no='A', protocol_status='sxz'),
BizCollectionProtocol(id='b', tenant_no='t', protocol_no='B', protocol_status='dsx'),
BizProtocolPartyCustomer(id='p1', tenant_no='t', collection_protocol_id='a', mother_name='Alice', mother_phone='13800000001'),
BizProtocolPartyCustomer(id='p2', tenant_no='t', collection_protocol_id='a', mother_name='Alice'),
BizProtocolPartyCustomer(id='foreign', tenant_no='foreign', collection_protocol_id='a', mother_name='ForeignOnly'),
BizProtocolPartyCustomer(id='deleted', tenant_no='t', collection_protocol_id='a', mother_name='DeletedOnly', del_flag=1),
BizCollectionHospital(id='h', tenant_no='t', region_id='region', hospital_name='Central'),
SysUser(id='sales', tenant_no='t', user_no='sales', nick_name='Salesperson'),
BizProtocolCollectionInfo(id='c', tenant_no='t', collection_protocol_id='a', hospital_id='h', sales_user_id='sales', sample_collect_time=datetime(2026, 11, 4, 10)),
])
self.db.commit()
for keyword in ('Alice', '13800000001', 'Central', 'Salesperson'):
result = self.records(keyword=keyword, pageSize=1)
self.assertEqual(result['total'], 1)
self.assertEqual(result['statusCounts'], {'sxz': 1})
self.assertEqual(result['list'][0]['motherName'], 'Alice')
self.assertEqual(result['list'][0]['collectionDate'], '2026-11-04')
for keyword in ('ForeignOnly', 'DeletedOnly', '%', '_'):
self.assertEqual(self.records(keyword=keyword)['total'], 0)
self.assertEqual(self.records(status='sxz')['statusCounts'], {'sxz': 1, 'dsx': 1})
self.assertEqual(self.records(status='sxz')['total'], 1)
self.assertEqual(self.records(keyword='Alice', page=2, pageSize=1)['list'], [])
self.db.get(BizCollectionHospital, 'h').tenant_no = 'foreign'
self.db.get(SysUser, 'sales').del_flag = 1
self.db.commit()
row = self.records(keyword='Alice')['list'][0]
self.assertEqual(row['hospitalName'], '')
self.assertEqual(row['salesUserName'], '')
self.assertEqual(self.records(keyword='Central')['total'], 0)
self.assertEqual(self.records(keyword='Salesperson')['total'], 0)
def test_donation_fields_and_statistics_contract(self):
self.db.add_all([
BizResearchDonation(id='d', tenant_no='t', donation_no='D1', donation_date='20261104'),
BizResearchDonationParty(id='p', tenant_no='t', research_donation_id='d', mother_name='Donor', mother_phone='13900000000'),
])
self.db.commit()
detail = self.records('research_donation', keyword='13900000000')['list'][0]
self.assertEqual(detail['donationDate'], '2026-11-04')
self.assertEqual(detail['motherName'], 'Donor')
old = project_management.records(self.db, 't', self.user, 'research_donation')['list'][0]
self.assertNotIn('motherName', old)
self.assertNotIn('motherPhone', old)
with self.assertRaises(HTTPException) as unsupported:
self.records('presign')
self.assertEqual(unsupported.exception.status_code, 400)
def test_pilot_and_basic_search_and_relation_boundaries(self):
self.db.add_all([
BizPilotApplication(id='pilot', tenant_no='t', application_no='P1', application_name='Trial', channel_name='Channel', channel_service_order_id='order', biz_type='pilot_service'),
BizPilotApplication(id='basic', tenant_no='t', application_no='B1', channel_service_order_id='', biz_type='basic_experiment', project_title='Study', customer_name='Customer', customer_contact='Contact', customer_phone='13600000000'),
BizChannelServiceOrder(id='order', tenant_no='t', order_name='Service', channel_id='channel', order_type='gm'),
BizPilotPatient(id='p1', tenant_no='t', pilot_application_id='pilot', patient_name='Patient', phone_no='13700000000'),
BizPilotPatient(id='p2', tenant_no='t', pilot_application_id='pilot', patient_name='Patient'),
BizPilotPatient(id='foreign', tenant_no='foreign', pilot_application_id='pilot', patient_name='ForeignPatient'),
BizPilotPatient(id='deleted', tenant_no='t', pilot_application_id='pilot', patient_name='DeletedPatient', del_flag=1),
])
self.db.commit()
for keyword in ('P1', 'Trial', 'Channel', 'Service', 'Patient', '13700000000'):
result = self.records('pilot_service', keyword=keyword)
self.assertEqual(result['total'], 1)
self.assertEqual(result['list'][0]['patientName'], 'Patient')
self.assertEqual(result['list'][0]['serviceOrderName'], 'Service')
for keyword in ('B1', 'Study', 'Customer', 'Contact', '13600000000'):
self.assertEqual(self.records('basic_experiment', keyword=keyword)['total'], 1)
self.assertEqual(self.records('pilot_service', keyword=keyword)['total'], 0)
for keyword in ('ForeignPatient', 'DeletedPatient'):
self.assertEqual(self.records('pilot_service', keyword=keyword)['total'], 0)
self.db.get(BizChannelServiceOrder, 'order').tenant_no = 'foreign'
self.db.commit()
self.assertEqual(self.records('pilot_service', keyword='Service')['total'], 0)
self.assertIsNone(self.records('pilot_service')['list'][0]['serviceOrderName'])
def test_batch_enrichment_does_not_query_per_row(self):
for index in range(20):
row_id = str(index)
self.db.add(BizCollectionProtocol(id=row_id, tenant_no='t', protocol_no=row_id))
self.db.add(BizProtocolPartyCustomer(id=row_id, tenant_no='t', collection_protocol_id=row_id, mother_name='Mother'))
self.db.commit()
statements = []
def count_statement(conn, cursor, statement, parameters, context, executemany):
statements.append(statement)
event.listen(self.engine, 'before_cursor_execute', count_statement)
try:
self.records(pageSize=1)
small = len(statements)
statements.clear()
self.assertEqual(len(self.records(pageSize=20)['list']), 20)
self.assertEqual(len(statements), small)
finally:
event.remove(self.engine, 'before_cursor_execute', count_statement)
def test_sample_codes_keep_one_main_row_and_respect_tenant_and_soft_delete(self):
for kind, model, parent_key in (
('cell_storage', BizProtocolSample, 'collection_protocol_id'),
('research_donation', BizResearchDonationSample, 'research_donation_id'),
):
with self.subTest(kind=kind):
if kind == 'cell_storage':
parents = [BizCollectionProtocol(id=kind + suffix, tenant_no='t', protocol_no=suffix)
for suffix in ('one', 'empty')]
else:
parents = [BizResearchDonation(id=kind + suffix, tenant_no='t', donation_no=suffix)
for suffix in ('one', 'empty')]
self.db.add_all(parents)
for row_id, code, tenant, deleted in (
('1', 'SAMPLE-A', 't', 0), ('2', 'SAMPLE-B', 't', 0),
('3', 'SAMPLE-A', 't', 0), ('4', 'FOREIGN-SAMPLE', 'foreign', 0),
('5', 'DELETED-SAMPLE', 't', 1), ('6', '', 't', 0), ('7', None, 't', 0),
):
self.db.add(model(id=kind + row_id, tenant_no=tenant, del_flag=deleted,
sample_code=code, create_time=datetime(2026, 11, 4),
**{parent_key: kind + 'one'}))
self.db.commit()
for keyword in ('SAMPLE-A', 'SAMPLE-B', 'SAMPLE-'):
result = self.records(kind, keyword=keyword, pageSize=1)
self.assertEqual(result['total'], 1)
self.assertEqual(sum(result['statusCounts'].values()), 1)
self.assertEqual(result['list'][0]['sampleCodes'], 'SAMPLE-A、SAMPLE-B')
self.assertEqual(result['list'][0]['id'], kind + 'one')
self.assertEqual(self.records(kind, keyword='SAMPLE-', page=2, pageSize=1)['list'], [])
for keyword in ('FOREIGN-SAMPLE', 'DELETED-SAMPLE'):
self.assertEqual(self.records(kind, keyword=keyword)['total'], 0)
rows = {row['id']: row for row in self.records(kind)['list']}
self.assertEqual(len(rows), 2)
self.assertEqual(rows[kind + 'empty']['sampleCodes'], '')
+293
View File
@@ -0,0 +1,293 @@
"""Project attribution isolation and history contract, using an in-memory database."""
import importlib.util
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from fastapi import HTTPException
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, 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)
self.user = {'user_id': 'u', 'tenant_no': 't', 'role': 'super_admin'}
self.db.add_all([ProjectTeam(id='g', tenant_no='t', team_name='Team', leader_user_id='u'),
Project(id='p', tenant_no='t', project_no='P1', project_name='Project', team_id='g', leader_user_id='u', biz_types='cell_storage,presign', status='active')])
self.db.commit()
def tearDown(self):
if hasattr(self, 'db'):
self.db.close()
self.engine.dispose()
def test_assignment_and_tenant_boundary(self):
self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage'), 'p')
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 'other', self.user, 'p', 'cell_storage')
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 't', self.user, 'p', 'basic_experiment')
def test_legacy_edit_keeps_disabled_project(self):
self.db.get(self.Project, 'p').status = 'disabled'
self.db.commit()
self.assertEqual(self.svc.validate_assignment(self.db, 't', self.user, 'p', 'cell_storage', current_project_id='p'), 'p')
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', current_project_id='p')
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 't', self.user, None, 'cell_storage', required=True)
def test_statistics_excludes_foreign_and_deleted(self):
from app.models.models import BizCollectionProtocol
self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p'),
BizCollectionProtocol(id='b', protocol_no='B', tenant_no='other', project_id='p'),
BizCollectionProtocol(id='c', protocol_no='C', tenant_no='t', project_id='p', del_flag=1),
BizCollectionProtocol(id='d', protocol_no='D', tenant_no='t')])
self.db.commit()
result = self.svc.statistics(self.db, 't', self.user)
self.assertEqual(result['totals']['cell_storage'], 2)
self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage')['total'], 2)
self.assertEqual(result['unassignedTotals']['cell_storage'], 1)
self.assertEqual(self.svc.records(self.db, 't', self.user, 'cell_storage', projectId='p')['total'], 1)
def test_assignment_preview_and_atomic_snapshot(self):
from app.models.models import BizCollectionProtocol, BizPresignOrder
self.db.add_all([BizPresignOrder(id='s', tenant_no='t', project_id=None, converted_protocol_id='a'),
BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')])
self.db.commit()
result = self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p')
self.assertEqual(len(result['records']), 2)
with self.assertRaises(HTTPException):
self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', [])
self.db.rollback()
self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id)
self.svc.assign(self.db, 't', self.user, 'cell_storage', 'a', 'p', 'correction', result['records'])
self.assertEqual(self.db.get(BizPresignOrder, 's').project_id, 'p')
self.assertEqual(len(self.svc.assignment_history(self.db, 't', self.user, 'cell_storage', 'a')['list']), 1)
def test_presign_statistics_column_groups_totals_and_drill_down(self):
from datetime import datetime
from app.models.models import BizCollectionProtocol, BizPresignOrder
self.db.add_all([
self.Team(id='g2', tenant_no='t', team_name='Presign only', leader_user_id='u'),
self.Project(id='p2', tenant_no='t', project_no='P2', project_name='Presign only',
team_id='g2', leader_user_id='u', biz_types='cell_storage', status='active'),
BizCollectionProtocol(id='protocol', tenant_no='t', protocol_no='CP', project_id='p',
create_time=datetime(2026, 11, 4)),
])
for row_id, project_id, status, tenant, deleted, day in (
('active', 'p', 'active', 't', 0, 4),
('converted', 'p', 'converted', 't', 0, 4),
('draft', 'p2', 'draft', 't', 0, 4),
('void', None, 'void', 't', 0, 4),
('foreign', 'p', 'active', 'foreign', 0, 4),
('deleted', 'p', 'active', 't', 1, 4),
('old', 'p', 'active', 't', 0, 1),
):
self.db.add(BizPresignOrder(
id=row_id, tenant_no=tenant, protocol_no=row_id, project_id=project_id,
order_status=status, del_flag=deleted, create_by='u',
create_time=datetime(2026, 11, day),
converted_protocol_id='protocol' if status == 'converted' else None,
))
self.db.commit()
filters = dict(startDate='2026-11-04', endDate='2026-11-04')
for group_by, expected in (('project', {'p': 2, 'p2': 1}), ('team', {'g': 2, 'g2': 1})):
with self.subTest(group_by=group_by):
result = self.svc.statistics(self.db, 't', self.user, groupBy=group_by, **filters)
self.assertEqual(result['totals']['presign'], 4)
self.assertEqual(result['presignTotal'], 4)
self.assertEqual(result['unassignedTotals']['presign'], 1)
self.assertEqual(result['totals']['cell_storage'], 1)
self.assertEqual({r['id']: r['presign'] for r in result['items']}, expected)
for row in result['items']:
drill = {('teamId' if group_by == 'team' else 'projectId'): row['id']}
self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', **drill, **filters)['total'], row['presign'])
for selection in (dict(teamId='g'), dict(projectId='p')):
result = self.svc.statistics(self.db, 't', self.user, **selection, **filters)
self.assertEqual(result['totals']['presign'], 2)
self.assertEqual(result['unassignedTotals']['presign'], 0)
self.assertEqual(self.svc.records(self.db, 't', self.user, 'presign', unassigned=True, **filters)['total'], 1)
denied = self.svc.statistics(self.db, 't', dict(user_id='outsider', role='admin'), **filters)
self.assertEqual(denied['totals']['presign'], 0)
self.assertEqual(denied['items'], [])
self.assertNotIn('presign', self.svc.COUNT_TYPES)
def test_team_child_ids_survive_edits(self):
from app.models.models import SysUser
self.db.add(SysUser(id='u', tenant_no='t', user_no='u', nick_name='User'))
self.db.commit()
body = dict(teamName='Team', leaderUserId='u', members=[{'userId': 'u'}])
first = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g')
second = self.svc.save_entity(self.db, 't', self.user, self.Team, body, 'g')
self.assertEqual(first['members'][0]['id'], second['members'][0]['id'])
with self.assertRaises(HTTPException):
self.svc.delete_entity(self.db, 't', self.user, self.Team, 'g')
def test_non_member_admin_cannot_choose_project(self):
# An ordinary admin with no enabled-role permissions is not a substitute recorder.
with self.assertRaises(HTTPException) as error:
self.svc.validate_assignment(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'p', 'cell_storage')
self.assertEqual(error.exception.status_code, 403)
def test_non_member_data_scope_does_not_expand(self):
from app.models.models import BizCollectionProtocol
self.db.add(BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', project_id='p'))
self.db.commit()
user = {'user_id': 'u', 'role': 'admin'}
self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 0)
self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 0)
def test_empty_date_input_is_optional(self):
from app.models.models import SysUser
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', 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):
from app.models.models import BizCollectionProtocol
from app.models.research_donation import BizResearchDonation, BizProtocolDonationConvert
self.db.get(self.Project, 'p').biz_types = 'cell_storage,research_donation'
self.db.add_all([BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t'),
BizResearchDonation(id='d', donation_no='D', tenant_no='t'),
BizProtocolDonationConvert(id='c', protocol_id='a', research_donation_id='d', biz_no='A', convert_type='research_to_storage', tenant_no='t')])
self.db.commit()
preview = self.svc.preview_assignment(self.db, 't', self.user, 'research_donation', 'd', 'p')
self.assertEqual({r['bizType'] for r in preview['records']}, {'cell_storage', 'research_donation'})
self.svc.assign(self.db, 't', self.user, 'research_donation', 'd', 'p', 'reason', preview['records'])
self.assertEqual(self.db.get(BizCollectionProtocol, 'a').project_id, 'p')
self.assertEqual(self.db.get(BizResearchDonation, 'd').project_id, 'p')
def test_preview_rejects_inaccessible_link_without_writes(self):
from app.models.models import BizCollectionProtocol, BizPresignOrder
self.db.add_all([BizPresignOrder(id='s', tenant_no='other', converted_protocol_id='a'),
BizCollectionProtocol(id='a', protocol_no='A', tenant_no='t', source_presign_order_id='s')])
self.db.commit()
with self.assertRaises(HTTPException):
self.svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'a', 'p')
self.assertIsNone(self.db.get(BizCollectionProtocol, 'a').project_id)
def test_date_end_inclusive_and_business_types_separate(self):
from datetime import datetime
from app.models.models import BizPilotApplication
self.db.add_all([BizPilotApplication(id='a', tenant_no='t', project_id='p', biz_type='basic_experiment', channel_service_order_id='', create_time=datetime(2026, 1, 2, 23, 59)),
BizPilotApplication(id='b', tenant_no='t', project_id='p', biz_type='pilot_service', channel_service_order_id='', create_time=datetime(2026, 1, 3)),
BizPilotApplication(id='c', tenant_no='t', project_id='p', biz_type='clinical_application', channel_service_order_id='', create_time=datetime(2026, 1, 2))])
self.db.commit()
result = self.svc.statistics(self.db, 't', self.user, startDate='2026-01-02', endDate='2026-01-02')
self.assertEqual(result['totals']['basic_experiment'], 1)
self.assertEqual(result['totals']['pilot_service'], 0)
self.assertEqual(sum(result['totals'].values()), 1)
def test_routes_exist_and_protect_management(self):
from app.api.project_management import router
paths = {r.path for r in router.routes}
self.assertIn('/project-management/options', paths)
self.assertIn('/web/admin/project-management/assignments/preview', paths)
with self.assertRaises(HTTPException) as error:
self.svc.check_permission(self.db, 't', {'user_id': 'stranger', 'role': 'admin'}, 'project:team:list')
self.assertEqual(error.exception.status_code, 403)
def test_enabled_role_union_authorizes_substitute_and_keeps_data_scope(self):
from app.models.models import SysRole, SysUserRole, SysRoleMenu, SysMenu, BizCollectionProtocol
self.db.add_all([SysRole(id='role1', role_name='reader', tenant_no='t', data_scope='4'),
SysRole(id='role2', role_name='assignment', tenant_no='t', data_scope='4'),
SysUserRole(id='ur1', user_id='outsider', role_id='role1', tenant_no='t'),
SysUserRole(id='ur2', user_id='outsider', role_id='role2', tenant_no='t'),
SysMenu(id='menu1', tenant_no='t', menu_name='全部项目', perms='project:project:all'),
SysRoleMenu(id='rm1', tenant_no='t', role_id='role2', menu_id='menu1'),
BizCollectionProtocol(id='mine', protocol_no='Mine', tenant_no='t', create_by='outsider', project_id='p'),
BizCollectionProtocol(id='foreign', protocol_no='Other', tenant_no='t', create_by='u', project_id='p')])
self.db.commit()
user = dict(user_id='outsider', role='admin')
self.assertEqual(self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage'), 'p')
self.assertEqual(self.svc.records(self.db, 't', user, 'cell_storage')['total'], 1)
self.assertEqual(self.svc.statistics(self.db, 't', user)['totals']['cell_storage'], 1)
self.db.get(SysRole, 'role2').status = '0'
self.db.commit()
with self.assertRaises(HTTPException):
self.svc.validate_assignment(self.db, 't', user, 'p', 'cell_storage')
def test_mysql_migration_has_repeat_guards_and_menu_parent_structure(self):
from pathlib import Path
root = Path(__file__).resolve().parents[2]
migration = (root / 'docs/sql/migrations/2026-11-02-project_management.sql').read_text()
self.assertEqual(migration.count('CREATE TABLE IF NOT EXISTS'), 6)
self.assertEqual(migration.count('information_schema.columns'), 9)
self.assertEqual(migration.count('information_schema.statistics'), 4)
self.assertNotIn(' BEFORE ', migration)
self.assertNotIn('JSON_TABLE', migration)
self.assertIn('INTO @project_charset, @project_collation', migration)
self.assertEqual(migration.count('ADD COLUMN project_id VARCHAR(64) CHARACTER SET'), 4)
self.assertEqual(migration.count('MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET'), 4)
menus = (root / 'docs/sql/migrations/2026-11-02-project_management_menu_seed.sql').read_text()
self.assertEqual(menus.count('AND NOT EXISTS'), 6)
self.assertEqual(menus.count("parent.perms='menu:market'"), 4)
self.assertIn("parent.perms='project:team:list'", menus)
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,180 @@
"""Users cannot self-grant project access through the permission administration APIs."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.api.admin import router
from app.core.auth import get_current_user
from app.core.database import get_db
from app.models.base import Base
from app.models.models import SysMenu, SysPost, SysRole, SysRoleMenu, SysUser, SysUserRole
@pytest.fixture
def env():
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
db = Session(engine)
user = dict(user_id='u', role='admin', tenant_no='t')
db.add_all([
SysUser(id='u', tenant_no='t', user_no='member', nick_name='Member'),
SysUser(id='foreign-user', tenant_no='foreign', user_no='foreign', nick_name='Foreign'),
SysRole(id='role', tenant_no='t', role_name='Member', data_scope='4'),
SysRole(id='foreign-role', tenant_no='foreign', role_name='Foreign'),
SysUserRole(id='ur', tenant_no='t', user_id='u', role_id='role'),
SysPost(id='post', tenant_no='t', post_name='Post', post_code='POST'),
SysPost(id='foreign-post', tenant_no='foreign', post_name='Foreign', post_code='FOREIGN'),
SysMenu(id='root', tenant_no='t', parent_id='0', menu_name='Web'),
SysMenu(id='project', tenant_no='t', parent_id='root', menu_name='项目', perms='menu:project'),
SysMenu(id='all', tenant_no='t', parent_id='project', menu_name='全部项目', menu_type='F', perms='project:project:all'),
SysMenu(id='foreign-menu', tenant_no='foreign', parent_id='0', menu_name='Foreign'),
])
db.commit()
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_current_user] = lambda: user
app.dependency_overrides[get_db] = lambda: db
with TestClient(app) as client:
yield db, user, client
db.close()
engine.dispose()
def grant(db, code):
db.add_all([SysMenu(id=code, tenant_no='t', menu_name=code, perms=code),
SysRoleMenu(id=code, tenant_no='t', role_id='role', menu_id=code)])
db.commit()
@pytest.mark.parametrize('method,path,body', [
('PUT', '/role-management/role/menus', {'menuIds': ['all']}),
('POST', '/role-management', {'roleName': '超级管理员'}),
('PUT', '/role-management/role', {'roleName': '超级管理员'}),
('DELETE', '/role-management/role', None),
('PUT', '/role-management/role/data-scope', {'dataScope': '1'}),
('POST', '/users', {'userNo': 'attacker', 'nickName': 'Attacker', 'password': 'TestOnly123!', 'roleIds': ['role']}),
('PUT', '/users/u', {'roleIds': ['foreign-role']}),
('PUT', '/users/u', {'postIds': ['post']}),
('POST', '/users/u/reset-password', None),
('DELETE', '/users/u', None),
('PUT', '/posts/post/roles', {'roleIds': ['role']}),
('PUT', '/posts/post/users', {'userIds': ['u']}),
('PUT', '/posts/post', {'status': '1'}),
('POST', '/menus', {'menuName': 'Grant', 'perms': 'project:project:all'}),
('PUT', '/menus/project', {'perms': 'project:project:all'}),
('DELETE', '/menus/all', None),
])
def test_business_admin_cannot_mutate_permission_graph(env, method, path, body):
_, _, client = env
result = client.request(method, '/web/admin' + path, json=body)
assert result.status_code == 403
def test_role_manager_grants_leaf_and_ancestors_only(env):
db, _, client = env
grant(db, 'system:role:list')
result = client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'system:role:list']})
assert result.json()['code'] == 200
rows = db.query(SysRoleMenu).filter_by(role_id='role', tenant_no='t', del_flag=0).all()
assert {r.menu_id for r in rows} == {'all', 'project', 'root', 'system:role:list'}
# Same save is idempotent and does not create duplicate active grants.
assert client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'system:role:list']}).json()['code'] == 200
assert db.query(SysRoleMenu).filter_by(role_id='role', tenant_no='t', del_flag=0).count() == 4
@pytest.mark.parametrize('super_admin', [False, True])
@pytest.mark.parametrize('path,body', [
('/role-management/foreign-role/menus', {'menuIds': ['all']}),
('/role-management/role/menus', {'menuIds': ['all', 'foreign-menu']}),
('/role-management/foreign-role', {'roleName': 'Changed'}),
('/role-management/foreign-role/data-scope', {'dataScope': '1'}),
('/menus/foreign-menu', {'perms': 'project:project:all'}),
('/menus/project', {'parentId': 'foreign-menu'}),
('/posts/foreign-post', {'status': '1'}),
('/users/foreign-user', {'roleIds': ['role']}),
('/users/u', {'roleIds': ['foreign-role']}),
('/users/u', {'postIds': ['foreign-post']}),
])
def test_permission_admin_cannot_write_foreign_targets_or_links(env, path, body, super_admin):
db, user, client = env
for code in ('system:role:list', 'system:menu:list', 'system:post:list', 'system:user:list'):
grant(db, code)
if super_admin:
user['role'] = 'super_admin'
before = {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)}
result = client.put('/web/admin' + path, json=body)
assert result.json().get('code', result.status_code) in (400, 403, 404)
db.expire_all()
assert {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)} == before
assert db.get(SysRole, 'foreign-role').role_name == 'Foreign'
assert db.get(SysUser, 'foreign-user').tenant_no == 'foreign'
@pytest.mark.parametrize('parent', ['missing', 'all'])
def test_role_grant_rejects_broken_or_cyclic_ancestry_atomically(env, parent):
db, user, client = env
user['role'] = 'super_admin'
db.get(SysMenu, 'project').parent_id = parent
db.commit()
result = client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all']})
assert result.json().get('code', result.status_code) == 400
assert db.query(SysRoleMenu).count() == 0
@pytest.mark.parametrize('code,path,body', [
('system:role:list', '/role-management/role', {'remark': 'authorized'}),
('system:role:list', '/role-management/role/data-scope', {'dataScope': '4'}),
('system:user:list', '/users/u', {'roleIds': ['role'], 'postIds': ['post']}),
('system:post:list', '/posts/post/roles', {'roleIds': ['role']}),
('system:post:list', '/posts/post/users', {'userIds': ['u']}),
('system:menu:list', '/menus/all', {'menuName': '全部项目'}),
])
def test_authorized_tenant_administrators_can_manage_their_module(env, code, path, body):
db, _, client = env
grant(db, code)
assert client.put('/web/admin' + path, json=body).json()['code'] == 200
def test_web_and_mobile_ancestor_roots_remain_separate(env):
db, user, client = env
user['role'] = 'super_admin'
db.add_all([
SysMenu(id='mobile-root', tenant_no='t', parent_id='0', menu_name='Mobile'),
SysMenu(id='mobile-leaf', tenant_no='t', parent_id='mobile-root', menu_name='Mobile business'),
])
db.commit()
assert client.put('/web/admin/role-management/role/menus', json={'menuIds': ['all', 'mobile-leaf']}).json()['code'] == 200
assert {r.menu_id for r in db.query(SysRoleMenu).filter_by(role_id='role', del_flag=0)} == {'all', 'project', 'root', 'mobile-leaf', 'mobile-root'}
@pytest.mark.parametrize('method,path,body', [
('POST', '/qc-material-amount-auth/users', {'userId': 'u'}),
('DELETE', '/qc-material-amount-auth/users/u', None),
('POST', '/qc-material-amount-auth/users/batch-revoke', {'userIds': ['u']}),
])
def test_business_admin_cannot_self_bind_specialized_authorization_roles(env, method, path, body):
from app.api.qc import router as qc_router
_, _, client = env
client.app.include_router(qc_router)
assert client.request(method, '/web/admin' + path, json=body).status_code == 403
def test_specialized_authorization_manager_can_grant_and_revoke(env):
from app.api.qc import router as qc_router
from app.core.qc_material_amount_auth_constants import (
QC_MATERIAL_AMOUNT_AUTH_LIST_PERM, QC_MATERIAL_AMOUNT_VIEW_ROLE_ID,
QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME, QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME,
)
from app.models.models import SysDept
db, _, client = env
client.app.include_router(qc_router)
grant(db, QC_MATERIAL_AMOUNT_AUTH_LIST_PERM)
db.add_all([
SysDept(id='qc-dept', tenant_no='t', dept_name=QC_MATERIAL_AMOUNT_AUTH_DEPT_NAME),
SysRole(id=QC_MATERIAL_AMOUNT_VIEW_ROLE_ID, tenant_no='t', role_name=QC_MATERIAL_AMOUNT_VIEW_ROLE_NAME),
])
db.get(SysUser, 'u').dept_id = 'qc-dept'
db.commit()
assert client.post('/web/admin/qc-material-amount-auth/users', json={'userId': 'u'}).json()['code'] == 200
assert client.delete('/web/admin/qc-material-amount-auth/users/u').json()['code'] == 200
@@ -0,0 +1,176 @@
"""Project choices and writes share explicit team/all-project permission boundaries."""
import unittest
from fastapi import HTTPException
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from app.models.base import Base
from app.models.models import (BizCollectionProtocol, SysMenu, SysRole, SysRoleMenu,
SysUserRole, SysPost, SysPostRole, SysUserPost)
from app.models.project_management import Project, ProjectTeam, ProjectTeamMember
from app.services import project_management as svc
from app.services.project_assignment import create_assignment
class ProjectSelectionPermissionsTest(unittest.TestCase):
def setUp(self):
self.engine = create_engine('sqlite:///:memory:')
Base.metadata.create_all(self.engine)
self.db = Session(self.engine)
self.user = dict(user_id='member', role='admin', tenant_no='t')
kinds = ','.join(svc.COUNT_TYPES)
self.db.add_all([
ProjectTeam(id='own', tenant_no='t', team_name='Own', leader_user_id='leader'),
ProjectTeam(id='other', tenant_no='t', team_name='Other', leader_user_id='other'),
ProjectTeam(id='foreign', tenant_no='foreign', team_name='Foreign', leader_user_id='member'),
ProjectTeamMember(id='member', tenant_no='t', team_id='own', user_id='member'),
Project(id='own-p', tenant_no='t', project_no='OWN', project_name='Own', team_id='own', leader_user_id='other', status='active', biz_types=kinds),
Project(id='other-p', tenant_no='t', project_no='OTHER', project_name='Other', team_id='other', leader_user_id='other', status='active', biz_types=kinds),
Project(id='foreign-p', tenant_no='foreign', project_no='FOREIGN', project_name='Foreign', team_id='foreign', leader_user_id='member', status='active', biz_types=kinds),
])
self.db.commit()
def tearDown(self):
self.db.close()
self.engine.dispose()
def grant(self, code, suffix='all', *, inherited=False):
self.db.add_all([
SysRole(id=suffix, tenant_no='t', role_name=suffix, data_scope='4'),
SysMenu(id=suffix, tenant_no='t', menu_name=suffix, menu_type='F', visible='0', perms=code),
SysRoleMenu(id=suffix, tenant_no='t', role_id=suffix, menu_id=suffix),
])
if inherited:
self.db.add_all([
SysPost(id=suffix, tenant_no='t', post_name=suffix, post_code=suffix),
SysPostRole(id=suffix, tenant_no='t', post_id=suffix, role_id=suffix),
SysUserPost(id=suffix, tenant_no='t', user_id='member', post_id=suffix),
])
else:
self.db.add(SysUserRole(id=suffix, tenant_no='t', user_id='member', role_id=suffix))
self.db.commit()
def choices(self, user=None, kind='cell_storage'):
return {r['id'] for r in svc.options(self.db, 't', user or self.user, kind)['list']}
def assert_forbidden(self, project_id='other-p', kind='cell_storage'):
with self.assertRaises(HTTPException) as caught:
create_assignment(self.db, 't', self.user, project_id, kind)
self.assertEqual(caught.exception.status_code, 403)
def test_project_leader_alone_does_not_bypass_team_membership(self):
self.db.get(Project, 'other-p').leader_user_id = 'member'
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
def test_team_leader_and_membership_union(self):
self.assertEqual(self.choices(dict(user_id='leader', role='admin')), {'own-p'})
self.db.add(ProjectTeamMember(id='second', tenant_no='t', team_id='other', user_id='member'))
self.db.commit()
self.assertEqual(self.choices(), {'own-p', 'other-p'})
self.db.get(ProjectTeamMember, 'second').del_flag = 1
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
def test_admin_with_no_team_has_no_choices(self):
self.assertEqual(self.choices(dict(user_id='outsider', role='admin')), set())
def test_assignment_edit_does_not_grant_all_projects(self):
self.grant('project:assignment:edit', 'assignment')
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
def test_all_permission_in_role_union_allows_all_business_types(self):
self.grant('project:assignment:edit', 'assignment')
self.grant('project:project:all')
for kind in svc.BUSINESS:
with self.subTest(kind=kind):
self.assertEqual(self.choices(kind=kind), {'own-p', 'other-p'})
self.assertEqual(create_assignment(self.db, 't', self.user, 'other-p', kind), 'other-p')
with self.assertRaises(HTTPException) as caught:
create_assignment(self.db, 't', self.user, 'foreign-p', 'cell_storage')
self.assertEqual(caught.exception.status_code, 404)
def test_all_permission_does_not_bypass_project_state_or_business_type(self):
self.grant('project:project:all')
self.db.get(Project, 'other-p').status = 'disabled'
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
with self.assertRaises(HTTPException):
create_assignment(self.db, 't', self.user, 'other-p', 'cell_storage')
self.db.get(Project, 'other-p').status = 'active'
self.db.get(Project, 'other-p').biz_types = 'basic_experiment'
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
with self.assertRaises(HTTPException):
create_assignment(self.db, 't', self.user, 'other-p', 'cell_storage')
def test_permission_revocation_is_effective_on_next_request(self):
self.grant('project:project:all')
self.assertEqual(self.choices(), {'own-p', 'other-p'})
for model, field, disabled, enabled in [
(SysRole, 'status', '0', '1'), (SysRoleMenu, 'del_flag', 1, 0),
(SysMenu, 'status', '0', '1'), (SysUserRole, 'del_flag', 1, 0),
]:
with self.subTest(model=model.__name__):
setattr(self.db.get(model, 'all'), field, disabled)
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
setattr(self.db.get(model, 'all'), field, enabled)
self.db.commit()
def test_post_inherited_all_permission_and_disabled_post(self):
self.grant('project:project:all', inherited=True)
self.assertEqual(self.choices(), {'own-p', 'other-p'})
self.assertEqual(create_assignment(self.db, 't', self.user, 'other-p', 'presign'), 'other-p')
self.db.get(SysPost, 'all').status = '0'
self.db.commit()
self.assertEqual(self.choices(), {'own-p'})
self.assert_forbidden()
def test_filter_options_require_team_scope_and_preserve_business_scope(self):
self.grant('project:assignment:edit', 'assignment')
self.db.add_all([
BizCollectionProtocol(id='own-record', protocol_no='OWN', tenant_no='t', create_by='member', project_id='own-p'),
BizCollectionProtocol(id='other-record', protocol_no='OTHER', tenant_no='t', create_by='member', project_id='other-p'),
BizCollectionProtocol(id='hidden-record', protocol_no='HIDDEN', tenant_no='t', create_by='other', project_id='own-p'),
])
self.db.commit()
def filters(**kwargs):
return {r['id'] for r in svc.filter_options(self.db, 't', self.user, 'cell_storage', **kwargs)['list']}
self.assertEqual(filters(), {'own-p'})
self.grant('project:project:all')
self.assertEqual(filters(), {'own-p', 'other-p'})
self.assertEqual(svc.records(self.db, 't', self.user, 'cell_storage')['total'], 2)
# Mobile passes its stricter business query to the same options service.
mobile_scope = self.db.query(BizCollectionProtocol).filter(BizCollectionProtocol.id == 'own-record')
self.assertEqual(filters(business_scope=mobile_scope), {'own-p'})
def test_cross_group_reassignment_needs_both_permissions(self):
self.grant('project:assignment:edit', 'assignment')
self.db.add(BizCollectionProtocol(id='record', protocol_no='R', tenant_no='t', create_by='member', project_id='own-p'))
self.db.commit()
with self.assertRaises(HTTPException) as caught:
svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'record', 'other-p')
self.assertEqual(caught.exception.status_code, 403)
self.grant('project:project:all')
preview = svc.preview_assignment(self.db, 't', self.user, 'cell_storage', 'record', 'other-p')
self.db.get(SysRoleMenu, 'all').del_flag = 1
self.db.commit()
with self.assertRaises(HTTPException) as caught:
svc.assign(self.db, 't', self.user, 'cell_storage', 'record', 'other-p', 'reason', preview['records'])
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(self.db.get(BizCollectionProtocol, 'record').project_id, 'own-p')
def test_all_permission_does_not_grant_maintenance_or_management_actions(self):
self.grant('project:project:all')
for code in ('project:assignment:edit', 'project:project:edit', 'project:team:edit'):
with self.subTest(code=code), self.assertRaises(HTTPException):
svc.check_permission(self.db, 't', self.user, code)
def test_super_admin_retains_tenant_boundary(self):
self.assertEqual(self.choices(dict(user_id='root', role='super_admin')), {'own-p', 'other-p'})
@@ -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()
+24 -17
View File
@@ -54,7 +54,7 @@ Agent 会核对工程仓库、集成主分支、应用目录、测试入口、
## 确认问题会有语音提醒
Agent 发出需要你答复的问题后,默认在本机播放一次“有问题待确认,请及时处理”。语音已预先生成并随包安装,后续只播放本地文件;一批多个问题提醒一次,等待期间不循环播放。
Agent 需要你做决定时,会在当前回复里直接写出「待你确认」,逐题列出问题、推荐、理由,以及 60 秒未回复会怎样。请看这段文字作答。随后默认在本机播放一次“有问题待确认,请及时处理”,提醒你去看这段清单。语音已预先生成并随包安装,后续只播放本地文件,不会念出题目;一批多个问题提醒一次,等待期间不循环播放。没有「待你确认」时不应播放。
“必须等我回复”的问题也会提醒,但仍保持等待。可以说“本次任务静音”或“本工程关闭语音提醒”,恢复时说“恢复语音提醒”;声音开关和一分钟默认选择互相独立,升级保留工程自己的选择。播放失败不影响文字提问,也不会延长或跳过 60 秒计时。
@@ -74,7 +74,11 @@ Agent 发出需要你答复的问题后,默认在本机播放一次“有问
新页面、新弹窗和视觉改版默认先做 Figma 原型。确实无需原型时明确说“本次不用生成 Figma 原型”;仍按已有界面规范实现。需要等待你亲自确认时说“这个决定必须等我回复”。
任务通常依次经过:原子领取并读取工单 → 确认范围/原型 → 任务分支与 worktree 实现 → 推送任务分支并创建/复用 PR → 测试与审查 → 合并候选检查及本机集成环境冒烟 → 按授权合并 PR → 恢复核对本机环境并关单。worktree 是独立工作目录,里面有对应任务分支;它不是集成主分支,不能只刷新原来的页面就认定正在测试修复版。
已有工程默认沿用当前界面风格,无需每张工单重复提醒。Agent 应在设计前核对同端同类页面、实际可查看的运行效果、组件和主题样式,简要说明参考与复用选择;生成原型和最终实现都要与参考对照。只做 Figma、采用其他设计工具或免除原型,也保留这项要求。无法查看运行界面时说明证据缺口,不把通用模板当成现行风格。步骤见安装目录 `references/ui-design.md`。
需要指定参考时可以说:“新增这个页面,沿用当前订单列表和编辑弹窗的风格;先查看现有界面及组件,再做设计,说明复用项与差异,不改全局主题。” 明确要改版时说明端、模块和范围,已有决定直接沿用。
任务通常依次经过:原子领取并读取工单 → 确认范围/原型 → 任务分支与 worktree 实现 → 推送任务分支并创建/复用 PR → 受影响代码的单元测试与审查 → 同一时刻只合入一个 worktree → 按授权合并 PR → 回写并关单。构建、启动和关键路径由你在合入后的集成分支上自行验收。worktree 是独立工作目录,里面有对应任务分支;它不是集成主分支。
## 仅允许本会话处理一张工单
@@ -90,42 +94,45 @@ Agent 发出需要你答复的问题后,默认在本机播放一次“有问
工程已明确选择直接合并则沿用;希望切换时可说:“本工程使用 PR 合并”,或“本工程明确采用本地合并后直接推送集成分支,保留相同测试与关单流程”。Agent 将选择记录在工程自己的 AGENTS.md 非受管区或已有工程规则中;当前 project.json 无合并方式字段,不要自行新增。升级不覆盖这些规则,也不修改仓库保护或启用 CI。
PR 合并后仍需同步本机、核对环境、回写并关闭工单,再检查清理 worktree。使用普通工单链接关联,避免自动关闭关键字使工单提前关闭。具体规则见安装目录 `references/pull-requests.md`;PR 操作由 Agent 使用平台工具完成,pda.py 不提供 PR 操作命令。
PR 合并后仍需同步本地集成分支、回写并关闭工单,再检查清理 worktree。评论里写明构建验收留给你。使用普通工单链接关联,避免自动关闭关键字使工单提前关闭。具体规则见安装目录 `references/pull-requests.md`;PR 操作由 Agent 使用平台工具完成,pda.py 不提供 PR 操作命令。
## 谁来测试,何时合并
默认由 Agent 执行适用的测试、构建和界面验证。若工程已授权常规自动收尾,通过后会合入指定集成分支、推送并关闭工单;仅安装智能体不会授予这些权限。你希望采用此方式时,可以明确发送:
默认由 Agent 在 worktree 运行受影响代码的单元测试。通过且审查没有阻塞后,串行合入指定集成分支并关闭工单;整体构建和运行验收留给你。仅安装智能体不会授予推送或合并权限。你希望采用此方式时,可以明确发送:
```text
本工程常规需求和修复默认由 Agent 测试。
本工程常规需求和修复默认由 Agent 在 worktree 跑单元测试。
长期授权本工程常规任务维护工单占用及同步流程标签。
测试与审查通过后,授权推送任务分支、创建 PR、合入已确认的集成分支,
同步本地与远端并回写、关闭对应工单。
单元测试与审查通过后,授权推送任务分支、创建 PR、合入已确认的集成分支,
同步本地与远端并回写、关闭对应工单。同一时刻只合入一个 worktree。
构建、启动和关键路径由我在合入后自行验收。
打 Tag、部署及服务启停仍按工程单独授权。
```
你自己测试时发送:
你自己跑单元测试时发送:
```text
这次我来测试。完成实现和代码审查后,给我任务分支、worktree 路径、提交号、
前后端运行入口和验证步骤。必须等我明确反馈,不要自动测试、构建或合并。
这次单元测试我来做。完成实现和代码审查后,给我任务分支、worktree 路径、
提交号和检查步骤。必须等我明确反馈,不要自动测试或合并。
```
任务版本验证通过后回复“该提交测试通过,请继续集成环境验证与收尾”;失败则发复现步骤和截图。服务实际运行的目录、端口和提交须对应待测版本,服务启停仍遵守工程规则。
该提交的检查通过后回复“该提交检查通过,请串行合入并收尾”;失败则发复现步骤。合入后的构建验收另行进行,服务启停仍遵守工程规则。
“一分钟推荐选择”默认启用且未被关闭时,没有回复可能按已展示的推荐转为 Agent 实际测试,但沉默不会被记为用户验收通过;上面的“必须等我”会要求本次保持等待。提问、原型及测试细则分别见安装目录 `references/planning.md`、`ui-design.md`、`testing.md`。
## 修复后,本机环境怎样切回主分支
## 代码完成后怎样合入主分支
任务版本先在独立分支/worktree 中验证。通过后结合最新主分支准备合并结果,把本机环境切到集成版本做启动、前后端连通和关键功能冒烟检查;通过后才更新远端主分支。默认 PR 模式先用本地等价集成候选验证,再合并远端并安全同步本地。
这里的主分支是本工程配置的集成分支,不固定叫 main。worktree 里受影响代码的单元测试通过后,Agent 先把最新集成分支合进任务分支;代码有变化就重跑受影响的单元测试,再用 Git 合入集成分支。不在 worktree 里做构建或启动验收,这部分由你在合入之后自行验收。
失败时保留现场,回原任务分支修复、重新验证和集成,不在主分支直接补丁式修改,也不关闭工单。相同代码不重复全部测试,但目录、容器、配置、数据库或产物变化仍要验证;纯文档等无运行影响时可记录不适用。
多个 worktree 可以同时写代码、同时跑单元测试。写入同一个集成分支时一次只允许一个:当前有任务正在合入,其他任务等待,合完并释放后再继续下一个。目标分支在等待期间又有新提交时,先把新提交接回来并补跑单元测试,再合入。不会用强推覆盖别人刚合进去的代码。
你选择自己测试时,任务验证和集成环境冒烟都由你完成。禁止 Agent 启停服务的工程,仍由你操作;未完成必要环境验证前暂不更新远端主分支、关单。worktree 不自动隔离端口或数据库,也不会自动重启服务。可以说:“请提供任务版本和切回主分支后的两阶段验证入口,服务由我启停。”
单元测试失败或冲突还没解决时,留在原来的任务分支修,不直接在主分支上打补丁,也不关单。你选择自己做单元测试时,确认该提交通过后才进入这条合入队列;构建验收同样留在合入之后。
完整步骤见安装目录 `references/local-validation.md`。合并后还要核对本机真正运行的是集成版本,避免仍在访问旧 worktree 或旧构建产物。
完整步骤见安装目录 `references/local-validation.md` 和 `references/issue-claims.md`。
普通工单和队列共用收尾检查。你说“验证通过,请提交并 push”时,智能体应续接原工单,在已有授权且必要验证完成后回写、关闭并重新读取确认。最终回复会列出工单、PR 状态或直接合并依据、提交、验收依据及关闭状态;只推送成功不代表工单完成。要暂缓可明确说“只推送,先不要关闭工单”。
进行中的工单可以让 Agent 先运行 `task-next`。它只报告下一步、缺的证据、能不能合入,以及该看的一份规则,不代替测试或合并。
普通工单和队列共用收尾检查。你说“验证通过,请提交并 push”时,智能体应续接原工单,在已有授权且单元测试证据有效后回写、关闭并重新读取确认。最终回复分成三行:单元测试结果、合入提交、构建与运行尚未由 Agent 验收。只推送成功不代表工单完成。要暂缓可明确说“只推送,先不要关闭工单”。
本地收尾进度保存在 Git 忽略的 `.local/agent/delivery/`,由 Agent 使用 `delivery-status`、`delivery-record`、`delivery-check`;普通工单与队列取得占用后都使用 `queue-finish`,无需你手动运行一串命令。中断后先核对已完成动作,不重复发表评论。详见安装目录 `references/completion.md`。这些命令不自动测试、关单或后台续跑。
+24 -23
View File
@@ -1,57 +1,58 @@
{
"schema_version": 1,
"files": {
"docs/agents/README.md": "cac093df683ccc202caf3424e3fbe46e5716981c400b16f3b3fb8b61dd1d35d3",
"docs/agents/README.md": "d332621b6b0d7e5d8ff7a19a9d99c633f19302c221b2767ac8f32b8e1c1c47f3",
"docs/agents/personal-dev.dependencies.json": "d4f2d0eceef8700fbadda33644e129dcc8d684cc09596db69fd19735b02b1f59",
".cursor/rules/personal-dev.mdc": "4a259ee5c3760166864300542d0cfa5d204af0c972c8da89490fdb0da2f4cae6",
".agents/skills/personal-dev/scripts/pda.py": "8120968805fb26e5376c012b9e4b2fd586e1a71dd2307a875a5b8a5d56d9e646",
".cursor/rules/personal-dev.mdc": "4e3fb8ada1ef445a3f68dec1563220d30f5617e750dc5628593584cc56155d3b",
".agents/skills/personal-dev/scripts/pda.py": "11800ff307db852c7bac4975cca81b584326e1e24b6734314405ea4a3f896190",
".agents/skills/personal-dev/scripts/pda/__init__.py": "2630d3c303b279f3b7be21f8fa0068fc51eabe2a43ab38b1c3bf0d89a21dcbe0",
".agents/skills/personal-dev/scripts/pda/config.py": "fb773c55cda8e58e8fb11d7f7ac73ccfe7970a512c3eee150b6652443d9d1ce8",
".agents/skills/personal-dev/scripts/pda/install.py": "2ff8ee9445dabb062d897c0eafeabdf42c95d471002414381cf613f79f58d507",
".agents/skills/personal-dev/scripts/pda/transaction.py": "acbf2dbf9b9b6a0f3abcc9902f0b221241b25fffaf57220cdf0d0116f04a0bff",
".agents/skills/personal-dev/scripts/pda/config.py": "c764e3c9188f414796eb2be51632753444a689d33fcf63a77bc9040b1929e352",
".agents/skills/personal-dev/scripts/pda/install.py": "508193b3659ac0062fe97ebbcaeff47f9c91a3679c6c9a52c36d65e75ac90916",
".agents/skills/personal-dev/scripts/pda/transaction.py": "5aa4da0ec8ff1c05c7b346df03dc257cde123b37a259f76e785a300ca2e82040",
".agents/skills/personal-dev/scripts/pda/source.py": "a742085aef7619fff9994709bb0fad2d32cf88c97da6f32469a7922feec28f4e",
".agents/skills/personal-dev/scripts/pda/remote.py": "5dd2e211d46690c36853e4668514631e3aecef56aea5b319dcd245328eb77cd4",
".agents/skills/personal-dev/scripts/pda/updates.py": "24e4575305458dd5e28f2cfb23d20ad2843c18cf8a39181f25968d61e8e38cd3",
".agents/skills/personal-dev/scripts/pda/issue_queue.py": "00384fd79bf711c9140431489f33daef3440616293bd80b97e1536375b3dd30a",
".agents/skills/personal-dev/scripts/pda/issue_tracker.py": "092e0cab7ccff52c53ed8f04d0acb75a341bc38a511e3c19e0c1abb849406483",
".agents/skills/personal-dev/scripts/pda/issue_queue.py": "8ff1c956fdc88dadb88f956f07e943a77745ea67be84e2f3aae33b0a0232e565",
".agents/skills/personal-dev/scripts/pda/issue_tracker.py": "d1e6a1862c4c816bdc4f7dbfe124202816340a8b3ea8ccbfc9b1ac6252e5455f",
".agents/skills/personal-dev/scripts/pda/issue_watch.py": "7148f0d7b2715f207ae873c73754180ab3409d031299ebd661c72f34c16e1883",
".agents/skills/personal-dev/scripts/pda/notifications.py": "fd483807c38998b4dce8dd9df03867755ac26af8b213da1edf7d710698d52b2c",
".agents/skills/personal-dev/scripts/pda/issue_claims.py": "b389c1206feecc252464d61117dd5cfd8785a25a9d6caead0c229322591728a5",
".agents/skills/personal-dev/scripts/pda/issue_workers.py": "ff737da724216cf5f7fa66673c49e7e3fe1916e4a238c5521aa9f8fc40cc7b3d",
".agents/skills/personal-dev/scripts/pda/worker_dispatch.py": "0837568906a149f7a62fba25712be1b8a6db89bf318327b8a5cc7df91e409163",
".agents/skills/personal-dev/scripts/pda/permissions.py": "7f1775b9efadb37cb4871282763db9468c4b4285f2945eb429084ad86366d2bc",
".agents/skills/personal-dev/scripts/pda/task_next.py": "e83e22ae4cbfd2d3bc602377d4c81e53a2ddf6b47457fe441c4c3211c2f6ccb4",
".agents/skills/personal-dev/assets/question-pending.wav": "9fe477010873a1155d7d7305a830f812f20f9a5ec8d5878c88b1d5d6aaa516f6",
".agents/skills/personal-dev/SKILL.md": "ee8283e37179e00ba830bff121538e390636aaf0526ee8fab4bcc9efe4c5f3f2",
".agents/skills/personal-dev/SKILL.md": "a03f02325a01a44c60ebfa13ff683867b8102919dd3966de5853dfea84120a6c",
".agents/skills/personal-dev/assets/README.md": "466a79a5b5efbdbbef53aa4af2026cb4c8b881e0fddfd15c4a161f826e231f13",
".agents/skills/personal-dev/references/completion.md": "0494a6982754dc04f5a2d3b53b03733e714edb2da68b4071a8a4fdd339155c69",
".agents/skills/personal-dev/references/completion.md": "56e21ee994102a11d74e1cabc0bd62880de64e0cd2a6b0eeac96243d1404e622",
".agents/skills/personal-dev/references/credentials.md": "1c61d4121af0439ce3fb07c856e773cba228fa4b8a920a708b97b3730964e95e",
".agents/skills/personal-dev/references/delivery.md": "bad97b5ef379d820849e41a8ebb219e24c226a0221d120e5cef5b47fefb24758",
".agents/skills/personal-dev/references/delivery.md": "3418ecba6ae089c59c1dbc9359fb177bfead0247fe9fec302c86da116dd8ea60",
".agents/skills/personal-dev/references/dependency-downloads.md": "76aa3055de99390c4e29e44e78cbc5e71bcd537c3185e1f4c89f49ff30a7cba2",
".agents/skills/personal-dev/references/implementation.md": "175c70f7769dabf937b6151b32b57378f15e46ea20e8d00f7780c79b348fdf79",
".agents/skills/personal-dev/references/issue-claims.md": "d32e4f3597b7ee8347c3b38827f646172a868fa496da860c89e011ec618f82a0",
".agents/skills/personal-dev/references/issue-claims.md": "e977d1c281508c94eec530a917abc4c3879d26d3a3a427a15d2fa4f27a395586",
".agents/skills/personal-dev/references/issue-context.md": "6e5027f06821f7bb77fde5335861abe4d036a5909adc09037f0fd8b85b6e9786",
".agents/skills/personal-dev/references/issue-queue.md": "ac4766aa24a8628fa7a18f03391f2d416ad03cc2ff6f165cbaa962aa166a033d",
".agents/skills/personal-dev/references/issue-watch.md": "bbf43659f036ba1326651da87d55e091f0d1ea53c3a2ba11b08e79778db98817",
".agents/skills/personal-dev/references/local-validation.md": "9dcd1ffa506a5a7d7e02a14c8f8e7fa7fd177115fd3526c98a4d66fa5ae6e10b",
".agents/skills/personal-dev/references/local-validation.md": "18ab3da7c37de6076cf92977ff0ccee202ac8b15084b1ea95bfd462d25636f58",
".agents/skills/personal-dev/references/permissions.md": "cbbcb8d202eb7a1fd38b9ac0bac3638b242d3a41cb3bdc7bad63b45e77842e35",
".agents/skills/personal-dev/references/planning.md": "cf99f9a2f2cb536ea0caa09c4968c2f27f4adcc871e2580c7d63b685b2e3a387",
".agents/skills/personal-dev/references/providers.md": "1dec1351dcdaf067bcd670c9e79c86a3e3b4565787c888e3f387b89206e3672c",
".agents/skills/personal-dev/references/pull-requests.md": "c447e736b751bc52ffcde10d62a8f57dbe1fc728adc8cfe8001a7432cfeadb31",
".agents/skills/personal-dev/references/question-notification.md": "2839daaff4d59542cf543811f26b9ccc32396096a97b743f4337c58708707f6d",
".agents/skills/personal-dev/references/planning.md": "43d8f382640b62efbecb6e22dedd3cf6232a8cb67e71b5d3625e22776e91b051",
".agents/skills/personal-dev/references/providers.md": "a5a48692bc6589b5e8e90245a357954fc61eba6cfdc347ef52a35e5888f95fc2",
".agents/skills/personal-dev/references/pull-requests.md": "8472f823dc3ba667e5dc07e6c6fe2fb17becbd00f52cc2c7f31f1605d819ee0a",
".agents/skills/personal-dev/references/question-notification.md": "8643b55d7c0696a7bfa0130a2f31206716b2e92c2c35140e5afc2b26517fd277",
".agents/skills/personal-dev/references/roles.md": "8e97c135673cf767f35b0711822c529fbee140b113e5c6c4112843a324a87872",
".agents/skills/personal-dev/references/testing.md": "7ff0f1522a79eab2998576315ba85e3c4f04506fcfcbda3dcb2df4dcdab56c02",
".agents/skills/personal-dev/references/ui-design.md": "17da21aac6d856250c600de183d84cc2d81859688d4a8f0ac8f6d9b9abee92d0",
".agents/skills/personal-dev/references/updates.md": "4984ad02d0da16aa40e16b4c80066d8099bb658a612578336756957733d5ee22",
".agents/skills/personal-dev/references/testing.md": "681e30d89e2ec797776b5c27a80350b3130cf1b26fe87758301e99433ed8210d",
".agents/skills/personal-dev/references/ui-design.md": "f54a34a71a372ef8ec0e2fe722e2ee47a709d5fce8ea1a1f75f088b02e038141",
".agents/skills/personal-dev/references/updates.md": "fedb28901250df116517d535c9e68506d176c08d2cf2c824e81a221e217478f3",
".agents/skills/personal-dev/references/worktree-layout.md": "6823e0b976c894f7e94643dfca6c5d7fdc244ab686da85ad93a29bced5e88412",
".agents/skills/personal-dev/references/worktree-lifecycle.md": "3415d5748317ea58206f70ec6b33180ed49c6b558cbb6223d37f6c6cf3298577"
".agents/skills/personal-dev/references/worktree-lifecycle.md": "c8853b46d44928f0fd23f67713a0d5a922c70a36f3d9ed1eb2c44518e4fcc06e"
},
"blocks": {
"AGENTS.md": "7ff1f49a7f8c103a31652ea3fe78fcfe6bffe7e1103b7bf9bd6d44237d9a747f",
"AGENTS.md": "769bfa9bf8147b3fffe88ce9874c23b901b33f344a530c7c6cfcaead98366766",
".gitignore": "8d8aed371a09669dcdafcc43f8825b5e284a30e9ae1ad26c8c61bd8a88a45751"
},
"source": {
"repository": "longwu1988/pensonal-dev-agent",
"channel": "main",
"commit": "eb4c320651f9d33eabf8a46173eedac5f47c9bce"
"commit": "c6f8125046c0f4cdedf58dc393980a91e68e4527"
}
}
+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,11 @@
-- Issue #13:在 project_top_menu 之后执行,可重复执行。
-- 独立功能权限,挂在“项目”一级节点下,授权时无需同时授予项目资料管理权限。
-- 不从归属维护或管理员角色继承授权,不写 sys_role_menu;由角色管理显式勾选。
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(parent.tenant_no, ':project:project:all')), '全部项目', parent.id,
'F', '', '', 'project:project:all', 1, '0', '1', parent.tenant_no, 20, '', NOW(), NOW(), 0
FROM sys_menu parent
WHERE parent.perms='menu:project' AND parent.del_flag=0 AND parent.status='1'
AND NOT EXISTS (SELECT 1 FROM sys_menu existing
WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:project:all');
@@ -0,0 +1,197 @@
-- Issue #13: MySQL 5.7 增量迁移。已在隔离 MySQL 5.7 验证;上线前仍须备份并在目标副本验证。
-- DDL 隐式提交;该脚本不作为自动应用启动步骤。
CREATE TABLE IF NOT EXISTS biz_project_team (
id VARCHAR(64) NOT NULL,
team_name VARCHAR(200) NOT NULL,
leader_user_id VARCHAR(64) NOT NULL,
enabled SMALLINT NOT NULL DEFAULT 1,
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_tenant_active (tenant_no, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS biz_project_team_member (
id VARCHAR(64) NOT NULL,
team_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) 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),
KEY ix_tenant_active (tenant_no, del_flag),
KEY ix_team (tenant_no, team_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS biz_project_team_region (
id VARCHAR(64) NOT NULL,
team_id VARCHAR(64) NOT NULL,
region_id VARCHAR(64) 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),
KEY ix_tenant_active (tenant_no, del_flag),
KEY ix_team (tenant_no, team_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS biz_project_team_hospital (
id VARCHAR(64) NOT NULL,
team_id VARCHAR(64) NOT NULL,
hospital_id VARCHAR(64) 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),
KEY ix_tenant_active (tenant_no, del_flag),
KEY ix_team (tenant_no, team_id, del_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS biz_project (
id VARCHAR(64) NOT NULL,
project_no VARCHAR(64) NOT NULL,
project_name VARCHAR(200) NOT NULL,
team_id VARCHAR(64) NOT NULL,
leader_user_id VARCHAR(64) NOT NULL,
biz_types VARCHAR(255) NOT NULL,
start_date DATE NULL,
end_date DATE NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
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_tenant_active (tenant_no, del_flag),
KEY ix_team (tenant_no, team_id, del_flag),
UNIQUE KEY uk_project_tenant_no (tenant_no, project_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS biz_project_assignment_audit (
id VARCHAR(64) NOT NULL,
biz_type VARCHAR(32) NOT NULL,
biz_id VARCHAR(64) NOT NULL,
old_project_id VARCHAR(64) NULL,
new_project_id VARCHAR(64) NULL,
old_project_name VARCHAR(200) NULL,
new_project_name VARCHAR(200) NULL,
reason VARCHAR(512) NOT NULL,
operator_name VARCHAR(100) 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),
KEY ix_tenant_active (tenant_no, del_flag),
KEY ix_business (tenant_no, biz_type, biz_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 原业务表可能使用不同的默认排序规则;关联列必须与实际项目主键一致。
-- 同时修复已执行旧版迁移的列,不更改原表其他业务列或整库排序规则。
SELECT character_set_name, collation_name INTO @project_charset, @project_collation
FROM information_schema.columns
WHERE table_schema=DATABASE() AND table_name='biz_project' AND column_name='id';
SET @project_ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_presign_order' AND column_name='project_id')=0,
CONCAT('ALTER TABLE biz_presign_order ADD COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
IF((SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_presign_order' AND column_name='project_id') <> @project_collation,
CONCAT('ALTER TABLE biz_presign_order MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
'SELECT 1'));
PREPARE project_stmt FROM @project_ddl;
EXECUTE project_stmt;
DEALLOCATE PREPARE project_stmt;
SET @project_ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='biz_presign_order' AND index_name='ix_project_scope')=0,
'ALTER TABLE biz_presign_order ADD INDEX ix_project_scope (tenant_no, project_id, del_flag, create_time)',
'SELECT 1');
PREPARE project_stmt FROM @project_ddl;
EXECUTE project_stmt;
DEALLOCATE PREPARE project_stmt;
SET @project_ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_collection_protocol' AND column_name='project_id')=0,
CONCAT('ALTER TABLE biz_collection_protocol ADD COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
IF((SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_collection_protocol' AND column_name='project_id') <> @project_collation,
CONCAT('ALTER TABLE biz_collection_protocol MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
'SELECT 1'));
PREPARE project_stmt FROM @project_ddl;
EXECUTE project_stmt;
DEALLOCATE PREPARE project_stmt;
SET @project_ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='biz_collection_protocol' AND index_name='ix_project_scope')=0,
'ALTER TABLE biz_collection_protocol ADD INDEX ix_project_scope (tenant_no, project_id, del_flag, create_time)',
'SELECT 1');
PREPARE project_stmt FROM @project_ddl;
EXECUTE project_stmt;
DEALLOCATE PREPARE project_stmt;
SET @project_ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_research_donation' AND column_name='project_id')=0,
CONCAT('ALTER TABLE biz_research_donation ADD COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
IF((SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_research_donation' AND column_name='project_id') <> @project_collation,
CONCAT('ALTER TABLE biz_research_donation MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
'SELECT 1'));
PREPARE project_stmt FROM @project_ddl;
EXECUTE project_stmt;
DEALLOCATE PREPARE project_stmt;
SET @project_ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='biz_research_donation' AND index_name='ix_project_scope')=0,
'ALTER TABLE biz_research_donation ADD INDEX ix_project_scope (tenant_no, project_id, del_flag, create_time)',
'SELECT 1');
PREPARE project_stmt FROM @project_ddl;
EXECUTE project_stmt;
DEALLOCATE PREPARE project_stmt;
SET @project_ddl = IF(
(SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_pilot_application' AND column_name='project_id')=0,
CONCAT('ALTER TABLE biz_pilot_application ADD COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
IF((SELECT collation_name FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='biz_pilot_application' AND column_name='project_id') <> @project_collation,
CONCAT('ALTER TABLE biz_pilot_application MODIFY COLUMN project_id VARCHAR(64) CHARACTER SET ', @project_charset, ' COLLATE ', @project_collation, ' NULL'),
'SELECT 1'));
PREPARE project_stmt FROM @project_ddl;
EXECUTE project_stmt;
DEALLOCATE PREPARE project_stmt;
SET @project_ddl = IF(
(SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='biz_pilot_application' AND index_name='ix_project_scope')=0,
'ALTER TABLE biz_pilot_application ADD INDEX ix_project_scope (tenant_no, project_id, del_flag, create_time)',
'SELECT 1');
PREPARE project_stmt FROM @project_ddl;
EXECUTE project_stmt;
DEALLOCATE PREPARE project_stmt;
@@ -0,0 +1,39 @@
-- Issue #13 菜单种子;仅附加到各租户现有 Web 市场节点,不创建新的虚拟根。
-- 不给现有角色自动授权。管理员通过现有角色菜单维护分配六个权限及祖先菜单。
-- 使用租户+权限散列稳定ID,允许重复执行。未执行真实数据库。
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(parent.tenant_no, ':project:team:list')), '项目组管理', parent.id, 'C', 'project-management/teams', '/project-management/teams/index', 'project:team:list', 1, '1', '1', parent.tenant_no, 10, '', NOW(), NOW(), 0
FROM sys_menu parent
WHERE parent.perms='menu:market' AND parent.del_flag=0 AND parent.status='1'
AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:team:list' AND existing.del_flag=0);
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(parent.tenant_no, ':project:project:list')), '项目管理', parent.id, 'C', 'project-management/projects', '/project-management/projects/index', 'project:project:list', 1, '1', '1', parent.tenant_no, 11, '', NOW(), NOW(), 0
FROM sys_menu parent
WHERE parent.perms='menu:market' AND parent.del_flag=0 AND parent.status='1'
AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:project:list' AND existing.del_flag=0);
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(parent.tenant_no, ':project:statistics:list')), '业务统计', parent.id, 'C', 'project-management/statistics', '/project-management/statistics/index', 'project:statistics:list', 1, '1', '1', parent.tenant_no, 12, '', NOW(), NOW(), 0
FROM sys_menu parent
WHERE parent.perms='menu:market' AND parent.del_flag=0 AND parent.status='1'
AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:statistics:list' AND existing.del_flag=0);
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(parent.tenant_no, ':project:assignment:edit')), '项目归属维护', parent.id, 'C', 'project-management/assignments', '/project-management/assignments/index', 'project:assignment:edit', 1, '1', '1', parent.tenant_no, 13, '', NOW(), NOW(), 0
FROM sys_menu parent
WHERE parent.perms='menu:market' AND parent.del_flag=0 AND parent.status='1'
AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:assignment:edit' AND existing.del_flag=0);
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(parent.tenant_no, ':project:team:edit')), '项目组编辑', parent.id, 'F', '', '', 'project:team:edit', 1, '0', '1', parent.tenant_no, 1, '', NOW(), NOW(), 0
FROM sys_menu parent
WHERE parent.perms='project:team:list' AND parent.del_flag=0 AND parent.status='1'
AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:team:edit' AND existing.del_flag=0);
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms, is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(parent.tenant_no, ':project:project:edit')), '项目编辑', parent.id, 'F', '', '', 'project:project:edit', 1, '0', '1', parent.tenant_no, 1, '', NOW(), NOW(), 0
FROM sys_menu parent
WHERE parent.perms='project:project:list' AND parent.del_flag=0 AND parent.status='1'
AND NOT EXISTS (SELECT 1 FROM sys_menu existing WHERE existing.tenant_no=parent.tenant_no AND existing.perms='project:project:edit' AND existing.del_flag=0);
@@ -0,0 +1,51 @@
-- Issue #13:在项目管理菜单种子之后执行,可重复执行。
-- 项目与市场使用同一 Web 虚拟根;不创建/改动移动根或移动菜单。
-- 保留四个叶子菜单及按钮 ID,仅迁移父级和路由;已有角色只补新祖先权限。
START TRANSACTION;
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(market.tenant_no, ':menu:project')), '项目', market.parent_id, 'M',
'/project', '/index/index', 'menu:project', 1, '1', '1', market.tenant_no,
COALESCE(market.order_num, 1) - 1, '', NOW(), NOW(), 0
FROM sys_menu market
WHERE market.perms='menu:market' AND market.del_flag=0
AND NOT EXISTS (SELECT 1 FROM sys_menu existing
WHERE existing.tenant_no=market.tenant_no AND existing.perms='menu:project');
UPDATE sys_menu leaf
JOIN sys_menu top_menu ON top_menu.tenant_no=leaf.tenant_no
AND top_menu.perms='menu:project' AND top_menu.del_flag=0
SET leaf.parent_id=top_menu.id,
leaf.path=CASE leaf.perms
WHEN 'project:team:list' THEN 'teams'
WHEN 'project:project:list' THEN 'projects'
WHEN 'project:statistics:list' THEN 'statistics'
WHEN 'project:assignment:edit' THEN 'assignments' END,
leaf.menu_name=CASE WHEN leaf.perms='project:assignment:edit'
THEN '历史归属维护' ELSE leaf.menu_name END,
leaf.update_time=NOW()
WHERE leaf.del_flag=0 AND leaf.perms IN (
'project:team:list', 'project:project:list', 'project:statistics:list', 'project:assignment:edit');
-- 仅拥有项目子菜单/按钮的角色补齐“项目”及原 Web 虚拟根,不给仅有市场权限的角色新增业务权限。
INSERT INTO sys_role_menu (id, role_id, menu_id, tenant_no, create_dept_id, create_time, update_time, del_flag)
SELECT DISTINCT MD5(CONCAT(grant_row.tenant_no, ':project-ancestor:', grant_row.role_id, ':', ancestor.id)),
grant_row.role_id, ancestor.id, grant_row.tenant_no, '', NOW(), NOW(), 0
FROM sys_role_menu grant_row
JOIN sys_role role_row ON role_row.id=grant_row.role_id AND role_row.tenant_no=grant_row.tenant_no
AND role_row.del_flag=0
JOIN sys_menu leaf ON leaf.id=grant_row.menu_id AND leaf.tenant_no=grant_row.tenant_no
AND leaf.del_flag=0 AND leaf.perms IN ('project:team:list', 'project:project:list',
'project:statistics:list', 'project:assignment:edit', 'project:team:edit', 'project:project:edit')
JOIN sys_menu top_menu ON top_menu.tenant_no=grant_row.tenant_no
AND top_menu.perms='menu:project' AND top_menu.del_flag=0
JOIN sys_menu ancestor ON ancestor.tenant_no=grant_row.tenant_no AND ancestor.del_flag=0
AND (ancestor.id=top_menu.id OR ancestor.id=top_menu.parent_id)
WHERE grant_row.del_flag=0
AND NOT EXISTS (SELECT 1 FROM sys_role_menu existing
WHERE existing.tenant_no=grant_row.tenant_no AND existing.role_id=grant_row.role_id
AND existing.menu_id=ancestor.id AND existing.del_flag=0)
ON DUPLICATE KEY UPDATE del_flag=0, update_time=NOW();
COMMIT;
@@ -0,0 +1,46 @@
-- Issue #13:在 project_all_permission 之后执行,可重复执行。
-- 项目 → 项目管理 → 四个原页面。保留页面 ID/权限及移动菜单。
-- 全部项目权限仍挂在项目一级节点,不以目录授权隐式授予业务权限。
START TRANSACTION;
INSERT INTO sys_menu (id, menu_name, parent_id, menu_type, path, component, perms,
is_frame, visible, status, tenant_no, order_num, create_dept_id, create_time, update_time, del_flag)
SELECT MD5(CONCAT(top_menu.tenant_no, ':menu:project:management')), '项目管理', top_menu.id,
'M', 'management', '', 'menu:project:management', 1, '1', '1', top_menu.tenant_no,
1, '', NOW(), NOW(), 0
FROM sys_menu top_menu
WHERE top_menu.perms='menu:project' AND top_menu.del_flag=0
AND NOT EXISTS (SELECT 1 FROM sys_menu existing
WHERE existing.tenant_no=top_menu.tenant_no AND existing.perms='menu:project:management');
UPDATE sys_menu leaf
JOIN sys_menu group_menu ON group_menu.tenant_no=leaf.tenant_no
AND group_menu.perms='menu:project:management' AND group_menu.del_flag=0
SET leaf.parent_id=group_menu.id, leaf.update_time=NOW()
WHERE leaf.del_flag=0 AND leaf.perms IN (
'project:team:list', 'project:project:list', 'project:statistics:list', 'project:assignment:edit');
-- 仅已有页面/编辑按钮授权的角色补齐目录、项目一级和 Web 根。
-- 只拥有全部项目选择权限的角色不增加四个管理页面的访问权限。
INSERT INTO sys_role_menu (id, role_id, menu_id, tenant_no, create_dept_id, create_time, update_time, del_flag)
SELECT DISTINCT MD5(CONCAT(grant_row.tenant_no, ':project-management-ancestor:', grant_row.role_id, ':', ancestor.id)),
grant_row.role_id, ancestor.id, grant_row.tenant_no, '', NOW(), NOW(), 0
FROM sys_role_menu grant_row
JOIN sys_role role_row ON role_row.id=grant_row.role_id AND role_row.tenant_no=grant_row.tenant_no
AND role_row.del_flag=0
JOIN sys_menu leaf ON leaf.id=grant_row.menu_id AND leaf.tenant_no=grant_row.tenant_no
AND leaf.del_flag=0 AND leaf.perms IN ('project:team:list', 'project:project:list',
'project:statistics:list', 'project:assignment:edit', 'project:team:edit', 'project:project:edit')
JOIN sys_menu group_menu ON group_menu.tenant_no=grant_row.tenant_no
AND group_menu.perms='menu:project:management' AND group_menu.del_flag=0
JOIN sys_menu top_menu ON top_menu.id=group_menu.parent_id AND top_menu.tenant_no=grant_row.tenant_no
AND top_menu.perms='menu:project' AND top_menu.del_flag=0
JOIN sys_menu ancestor ON ancestor.tenant_no=grant_row.tenant_no AND ancestor.del_flag=0
AND ancestor.id IN (group_menu.id, top_menu.id, top_menu.parent_id)
WHERE grant_row.del_flag=0
AND NOT EXISTS (SELECT 1 FROM sys_role_menu existing
WHERE existing.tenant_no=grant_row.tenant_no AND existing.role_id=grant_row.role_id
AND existing.menu_id=ancestor.id AND existing.del_flag=0)
ON DUPLICATE KEY UPDATE del_flag=0, update_time=NOW();
COMMIT;
@@ -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,348 @@
-- 质检历史结果刷新:对应 2026-11-09 配置变更第 1–10 条
--
-- 做什么:
-- 1) 「阴/阳」→「阴性/阳性」
-- 2) 已有结果行且值为空,且模板为 select 且有默认值 → 写成默认值(一般为「阴性」)
-- 3) 值兼容时同步结果行 param_type 快照(text→select 等);定量等异形值不改
-- 不做什么:
-- - 不为「任务存在但无结果行」INSERT
-- - 不改第 11 条 HLA、不改已有定量等非空异形值(如 0.01)
--
-- 使用:先跑「一、预览」,确认条数后再跑「二、执行」。
-- 建议在事务中执行:START TRANSACTION; ... 核对 ... COMMIT; / ROLLBACK;
-- 目标库:测试库 zkxw-sc 或生产库(执行前自行确认当前库)
-- SELECT DATABASE();
-- ---------------------------------------------------------------------------
-- 公共:第 1–10 条项目参数模板
-- ---------------------------------------------------------------------------
-- 可先单独核对模板是否已是「阴性/阳性」单选:
SELECT
COALESCE(l.item_code, t.item_code) AS item_code,
COALESCE(l.item_name, t.item_name) AS item_name,
p.id AS param_id,
p.param_name,
p.param_type,
p.options,
p.default_value
FROM biz_qc_item_param p
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
WHERE p.del_flag = 0
AND COALESCE(l.item_code, t.item_code) IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
)
ORDER BY item_code, p.order_num;
-- ===========================================================================
-- 一、预览(只读)
-- ===========================================================================
-- A. 已有结果行库存:空 / 阴阳旧文案 / 标准阴阳性 / 其他
SELECT
COALESCE(l.item_code, t.item_code) AS item_code,
COUNT(*) AS result_rows,
SUM(CASE WHEN r.param_value IS NULL OR TRIM(r.param_value) = '' THEN 1 ELSE 0 END) AS empty_n,
SUM(CASE WHEN r.param_value IN ('阴', '阳') THEN 1 ELSE 0 END) AS old_label_n,
SUM(CASE WHEN r.param_value IN ('阴性', '阳性') THEN 1 ELSE 0 END) AS standard_n,
SUM(
CASE
WHEN r.param_value IS NOT NULL
AND TRIM(r.param_value) <> ''
AND r.param_value NOT IN ('阴', '阳', '阴性', '阳性')
THEN 1 ELSE 0
END
) AS other_n
FROM biz_qc_task_result r
JOIN biz_qc_item_param p ON r.param_id = p.id AND p.del_flag = 0
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
WHERE r.del_flag = 0
AND COALESCE(l.item_code, t.item_code) IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
)
GROUP BY item_code
ORDER BY item_code;
-- B. 将执行:阴/阳 → 阴性/阳性
SELECT
COALESCE(l.item_code, t.item_code) AS item_code,
r.id AS result_id,
r.task_id,
r.param_value AS old_value,
CASE r.param_value WHEN '阴' THEN '阴性' WHEN '阳' THEN '阳性' END AS new_value
FROM biz_qc_task_result r
JOIN biz_qc_item_param p ON r.param_id = p.id AND p.del_flag = 0
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
WHERE r.del_flag = 0
AND r.param_value IN ('阴', '阳')
AND COALESCE(l.item_code, t.item_code) IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
);
-- C. 将执行:空结果行 → 模板 default_value(仅 select 且模板有默认值)
SELECT
COALESCE(l.item_code, t.item_code) AS item_code,
r.id AS result_id,
r.task_id,
r.param_value AS old_value,
p.default_value AS new_value,
r.param_type AS old_type,
p.param_type AS template_type
FROM biz_qc_task_result r
JOIN biz_qc_item_param p ON r.param_id = p.id AND p.del_flag = 0
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
WHERE r.del_flag = 0
AND (r.param_value IS NULL OR TRIM(r.param_value) = '')
AND p.param_type = 'select'
AND p.default_value IS NOT NULL
AND TRIM(p.default_value) <> ''
AND COALESCE(l.item_code, t.item_code) IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
);
-- D. 将执行:同步 param_type(值空,或值已是阴性/阳性,或值已落在模板 options 内)
SELECT
COALESCE(l.item_code, t.item_code) AS item_code,
r.id AS result_id,
r.task_id,
r.param_value,
r.param_type AS old_type,
p.param_type AS new_type
FROM biz_qc_task_result r
JOIN biz_qc_item_param p ON r.param_id = p.id AND p.del_flag = 0
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
WHERE r.del_flag = 0
AND IFNULL(r.param_type, '') <> IFNULL(p.param_type, '')
AND (
r.param_value IS NULL
OR TRIM(r.param_value) = ''
OR r.param_value IN ('阴性', '阳性', '阴', '阳')
OR (
p.options IS NOT NULL
AND JSON_VALID(p.options)
AND JSON_CONTAINS(CAST(p.options AS JSON), JSON_QUOTE(TRIM(r.param_value)))
)
)
AND COALESCE(l.item_code, t.item_code) IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
);
-- E. 信息:关联任务但无结果行(本脚本不会 INSERT)
SELECT 'lab' AS src, lti.item_code, COUNT(*) AS tasks_no_result
FROM biz_qc_task task
JOIN biz_qc_work_order_lab_item wi ON wi.id = task.source_line_id AND wi.del_flag = 0
JOIN biz_qc_lab_test_item lti ON lti.id = wi.lab_test_item_id AND lti.del_flag = 0
JOIN biz_qc_item_param p
ON p.item_source = 'lab' AND p.item_id = lti.id AND p.del_flag = 0
WHERE task.del_flag = 0
AND task.task_type = 'sysz'
AND lti.item_code IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
)
AND NOT EXISTS (
SELECT 1 FROM biz_qc_task_result r
WHERE r.task_id = task.id AND r.del_flag = 0
AND (r.param_id = p.id OR (p.param_code <> '' AND r.param_code = p.param_code))
)
GROUP BY lti.item_code
UNION ALL
SELECT 'third_party', tpi.item_code, COUNT(*)
FROM biz_qc_task task
JOIN biz_qc_work_order_third_item wi ON wi.id = task.source_line_id AND wi.del_flag = 0
JOIN biz_qc_third_party_item tpi ON tpi.id = wi.third_party_item_id AND tpi.del_flag = 0
JOIN biz_qc_item_param p
ON p.item_source = 'third_party' AND p.item_id = tpi.id AND p.del_flag = 0
WHERE task.del_flag = 0
AND task.task_type = 'dsfjc'
AND tpi.item_code IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
)
AND NOT EXISTS (
SELECT 1 FROM biz_qc_task_result r
WHERE r.task_id = task.id AND r.del_flag = 0
AND (r.param_id = p.id OR (p.param_code <> '' AND r.param_code = p.param_code))
)
GROUP BY tpi.item_code
ORDER BY item_code;
-- ===========================================================================
-- 二、执行(建议包在事务里)
-- ===========================================================================
-- START TRANSACTION;
-- 1) 阴/阳 → 阴性/阳性
UPDATE biz_qc_task_result r
JOIN biz_qc_item_param p ON r.param_id = p.id AND p.del_flag = 0
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
SET
r.param_value = CASE r.param_value WHEN '阴' THEN '阴性' WHEN '阳' THEN '阳性' ELSE r.param_value END,
r.update_by = 'd3000000000000000000000000000001',
r.update_time = NOW()
WHERE r.del_flag = 0
AND r.param_value IN ('阴', '阳')
AND COALESCE(l.item_code, t.item_code) IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
);
-- 2) 空结果行 → 模板默认值
UPDATE biz_qc_task_result r
JOIN biz_qc_item_param p ON r.param_id = p.id AND p.del_flag = 0
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
SET
r.param_value = p.default_value,
r.update_by = 'd3000000000000000000000000000001',
r.update_time = NOW()
WHERE r.del_flag = 0
AND (r.param_value IS NULL OR TRIM(r.param_value) = '')
AND p.param_type = 'select'
AND p.default_value IS NOT NULL
AND TRIM(p.default_value) <> ''
AND COALESCE(l.item_code, t.item_code) IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
);
-- 3) 兼容时同步 param_type 快照
-- 注意:先跑 1)2) 后,「阴/阳」已变成标准文案;定量等 other 值不会命中本条件
UPDATE biz_qc_task_result r
JOIN biz_qc_item_param p ON r.param_id = p.id AND p.del_flag = 0
LEFT JOIN biz_qc_lab_test_item l
ON p.item_source = 'lab' AND p.item_id = l.id AND l.del_flag = 0
LEFT JOIN biz_qc_third_party_item t
ON p.item_source = 'third_party' AND p.item_id = t.id AND t.del_flag = 0
SET
r.param_type = p.param_type,
r.update_by = 'd3000000000000000000000000000001',
r.update_time = NOW()
WHERE r.del_flag = 0
AND IFNULL(r.param_type, '') <> IFNULL(p.param_type, '')
AND (
r.param_value IS NULL
OR TRIM(r.param_value) = ''
OR r.param_value IN ('阴性', '阳性')
OR (
p.options IS NOT NULL
AND JSON_VALID(p.options)
AND JSON_CONTAINS(CAST(p.options AS JSON), JSON_QUOTE(TRIM(r.param_value)))
)
)
AND COALESCE(l.item_code, t.item_code) IN (
'ZKXW-QC-000-1',
'ZKXW-QC-003-18',
'ZKXW-QC-003-19',
'ZKXW-QC-003-17',
'ZKXW-QC-001-12',
'ZKXW-QC-001-11',
'ZKXW-QC-001-10',
'ZKXW-QC-001-06',
'ZKXW-QC-001-18',
'ZKXW-QC-002-02'
);
-- 执行后可再跑「一、预览」核对;确认无误再 COMMIT,否则 ROLLBACK
-- COMMIT;
-- ROLLBACK;
-- 可选:把 update_by 换成实际操作人 ID
@@ -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`。
+8
View File
@@ -217,3 +217,11 @@ export function marketModuleContract(context) {
const normalizedContext = assertContext(context)
return getMarketModule(normalizedContext.module)
}
export async function fetchProjectOptions(bizType, { signal, filter = false, context } = {}) {
const { request } = await import('../utils/http.js')
const url = filter
? `/mini/market/${assertContext(context).marketRole}/project-options`
: '/project-management/options'
return request({ url, method: 'GET', data: { bizType }, signal })
}
@@ -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 : []) || []
@@ -0,0 +1,102 @@
<template>
<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">
<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>
<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>
</component>
</template>
<script setup>
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { fetchProjectOptions } from '../../api/market.js'
import { projectLabel, projectOptions, projectValidation } from '../project-assignment.js'
import PresignSelectField from './PresignSelectField.vue'
const props = defineProps({
modelValue: { type: String, default: '' },
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: () => ({}) }
})
const emit = defineEmits(['update:modelValue'])
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
])
async function load() {
controller?.abort()
if (props.editing) { loading.value = false; return }
const current = new AbortController()
controller = current
loading.value = true
error.value = ''
options.value = []
try {
const data = await fetchProjectOptions(props.bizType, { signal: current.signal, filter: props.filter, context: props.context })
if (controller !== current) return
options.value = projectOptions(data)
required.value = Boolean(data.required)
} catch (cause) {
if (controller === current && cause?.name !== 'AbortError') error.value = cause?.message || '请稍后重试'
} finally {
if (controller === current) loading.value = false
}
}
watch(() => [props.bizType, props.editing], (next, previous) => {
if (previous && next[0] !== previous[0] && !props.editing) emit('update:modelValue', '')
load()
}, { immediate: true })
onBeforeUnmount(() => { controller?.abort(); controller = null })
defineExpose({ validate: () => projectValidation({ editing: props.editing || props.filter, loading: loading.value, error: error.value, required: required.value, projectId: props.modelValue }) })
</script>
+3 -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
@@ -48,6 +49,7 @@ export function createListFilters(module, source = '') {
export function buildListParams(module, filters = {}) {
const result = {}
if (['presign', 'protocol'].includes(module)) addText(result, 'projectId', filters.projectId)
if (module === 'presign') {
addText(result, 'orderStatus', filters.orderStatus)
} else if (module === 'protocol') {
+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)
@@ -131,6 +131,8 @@ function protocolCard(item) {
status,
statusTone: statusTag.tagType,
fields: [
field('所属项目', item.projectName || item.basic?.projectName || ((item.projectId || item.basic?.projectId) ? '历史项目' : '未归属')),
field('项目组', item.teamName || item.basic?.teamName),
field('样本编码', item.sampleCode || item.labStorageCode),
field('采集日期', collectDate || item.donationDate),
field('当月核量号', item.monthlyVerifyNo),
@@ -167,6 +169,8 @@ function protocolCard(item) {
status,
statusTone: statusTag.tagType,
fields: [
field('所属项目', item.projectName || item.basic?.projectName || ((item.projectId || item.basic?.projectId) ? '历史项目' : '未归属')),
field('项目组', item.teamName || item.basic?.teamName),
field('样本编码', item.sampleCode),
field('登记日期', item.registerDate || item.donationDate),
field('母亲电话', item.motherPhone),
@@ -41,8 +41,8 @@ test('protocol card exposes web parity fields without truncating to three rows',
})
assert.equal(card.title, '李安然')
assert.equal(card.subtitle, '细胞存储 · S-001')
assert.equal(card.fields[0].label, '样本编码')
assert.equal(card.fields[0].value, 'S-001')
assert.equal(card.fields[2].label, '样本编码')
assert.equal(card.fields[2].value, 'S-001')
assert.ok(!card.fields.some((item) => item.label === '协议编号'))
assert.ok(!card.fields.some((item) => item.label === '状态'))
assert.ok(card.fields.every((item) => !item.wide))
@@ -110,7 +110,7 @@ test('research donation protocol card matches excel-parity field set', () => {
allowedActions: ['edit']
})
const labels = card.fields.map((f) => f.label)
assert.equal(labels[0], '样本编码')
assert.equal(labels[2], '样本编码')
for (const label of [
'样本编码', '当月核量号', '实验室流转编号', '废弃原因', '样本类型',
'采集地区', '采集医院', '分娩时间', '采集时间', '市场专员', '地区经理',
@@ -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', () => {
@@ -570,7 +590,7 @@ test('protocol cards expose the same business fields as the web list', async ()
})
assert.deepEqual(card.fields.map((field) => field.label), [
'样本编码', '登记日期', '母亲电话', '母亲身份证号', '父亲姓名',
'所属项目', '项目组', '样本编码', '登记日期', '母亲电话', '母亲身份证号', '父亲姓名',
'新生儿性别', '分娩方式', '新生儿体重(克)', '采集地区', '采集医院',
'市场专员', '样本采集时间', '创建时间'
])
@@ -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', () => {
@@ -100,6 +100,8 @@ export default {
key: 'base',
title: '预签信息',
fields: [
{ label: '所属项目', value: detail?.projectName || (detail?.projectId ? '历史项目' : '未归属') },
{ label: '项目组', value: detail?.teamName || '—' },
{ label: '业务日期', value: displayValue(formatPresignDate(detail?.orderDate)) },
{ label: '母亲姓名', value: detail?.motherName || '—' },
{ label: '预产期', value: displayValue(formatPresignDate(detail?.expectedDeliveryDate)) },
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more