Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2fb3a931 | ||
|
|
87a22880e8 | ||
|
|
529f36eac6 | ||
|
|
9dc6812393 | ||
|
|
e482980c7d | ||
|
|
900e957f25 | ||
|
|
15a20db725 | ||
|
|
3ac30a6c2c | ||
|
|
95d6b6025f | ||
|
|
d0eb97b167 | ||
|
|
476f744ee9 | ||
|
|
cec96ce480 | ||
|
|
77473a1738 | ||
|
|
db74cd64fe | ||
|
|
e1f0030403 | ||
|
|
5109d8b3e5 | ||
|
|
2a0a2ef72a | ||
|
|
3a396d79a2 | ||
|
|
9c4944702b | ||
|
|
e06f89dcf6 | ||
|
|
f343f12645 | ||
|
|
1032631318 | ||
|
|
f9f79479e8 | ||
|
|
e488b86c35 | ||
|
|
6a78c28121 | ||
|
|
c7905b5da0 | ||
|
|
67a209298c | ||
|
|
e2a50b33fe | ||
|
|
3d9a71da46 | ||
|
|
49e36e0bbb | ||
|
|
654f9a8904 | ||
|
|
b40e41bd87 | ||
|
|
1afbe06021 | ||
|
|
3ad1a08e30 |
No files matched your search
@@ -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#问题展示)与 Cursor 宿主节在当前回复写出「待你确认」,完成[发问前检查](references/planning.md#发问前检查)后,再按[本地确认语音](references/question-notification.md)提醒;Cursor 下 `notify-question` 须为本轮最后一次工具且发问后停手 |
|
||||
| 关闭/恢复声音提醒 | [本地确认语音](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,54 @@
|
||||
4. **决定后落实**:在现有工单或决策入口记录答案、范围及依据,区分“用户明确确认”和“按默认策略超时采用推荐”,更新相关文档后继续,不新增重复报告、不重复询问同一已确认事项。已有文档不构成业务规则已获批准的证据。
|
||||
5. **中途出现疑问**:暂停受影响部分,说明未知项及影响并澄清;保留独立工作进度。若新结论改变原有范围或已批准原型,按对应确认流程处理,不能用前一次确认覆盖新的决定。
|
||||
|
||||
发出确认问题后按[本地语音提醒](question-notification.md)播放固定音频;一次提醒不改变该问题的等待或批准条件。
|
||||
发出确认问题后按[本地语音提醒](question-notification.md)播放固定音频;一次提醒不改变该问题的等待或批准条件。问题正文先按下一节展示,再播放。
|
||||
|
||||
## 问题展示
|
||||
|
||||
需要用户确认时,先把全部题目写进**当前这条用户可见回复**,标题用「待你确认」。这份清单就是问题本身。写在工单草稿、任务记录、工具参数或语音里,都还不算已经发问。清单完成后再播放提示音,并从此刻开始计时。
|
||||
|
||||
每题占一段,包含问题、带「(推荐)」的推荐项、理由与主要影响、未回复时的处理。还有其他可选项时写上「备选」。多题一次列完。示例:
|
||||
|
||||
```text
|
||||
待你确认
|
||||
|
||||
1. 问题:这次修改哪些端?
|
||||
推荐:Web 与移动 OA(推荐)
|
||||
理由:工单截图来自这两端,共用接口也要一起改。
|
||||
未回复时:发送后 60 秒按此推荐继续。其余端本次不改。
|
||||
```
|
||||
|
||||
必须等待的题,最后一行写「未回复时:本题保持等待。」宿主问题卡片可以同时使用,卡片内容与这份清单一致。回复正文里没有「待你确认」时,不播放提示音,也不开始 60 秒计时。
|
||||
|
||||
### Cursor / 工具先于正文的宿主
|
||||
|
||||
Cursor Agent 等同轮「先跑工具、再出最终回复」的宿主上:
|
||||
|
||||
1. 需要播音时,`notify-question` 只能作为本轮**最后一次**工具调用;最终可见回复必须以「待你确认」开头写全清单,本轮随后不得再编辑代码、开浏览器或继续依赖答复的实现。
|
||||
2. 承认提示音可能早于正文渲染数秒;用清单内一句「请在本会话回复;Queued 不是确认入口」消除错位。提示音整机可听,不保证当前聚焦会话就是发问会话。
|
||||
3. 工程若提供宿主适配说明(如 `docs/agents/cursor-question-visibility.md`),发问前按该说明执行;与本节冲突时以「用户必须看见清单」为准。
|
||||
|
||||
## 发问前检查
|
||||
|
||||
每次发送确认问题(含调用宿主提问工具)前,逐题完成以下检查;缺项先改题,再发送。这里是 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,12 +1,13 @@
|
||||
# 确认问题的本地语音提醒
|
||||
|
||||
向用户发出需要答复的确认问题后,默认在用户使用的本机播放一次固定提示:“有问题待确认,请及时处理”。语音只提醒有待办,内容不含问题正文、账号或业务信息;实际选项与推荐仍通过文字发送。
|
||||
向用户发出需要答复的确认问题后,默认在用户使用的本机播放一次固定提示:“有问题待确认,请及时处理”。语音只提醒有待办,内容不含问题正文、账号或业务信息。要确认的题目、推荐和未回复时的处理,按[问题展示](planning.md#问题展示)写在同一条用户可见回复的「待你确认」清单里。
|
||||
|
||||
## 触发与顺序
|
||||
|
||||
1. 先核对用户/工程的静音选择。实际发送问题后,记录该批问题的发送时间、推荐及范围,再播放;不能在问题尚未发出时声称已提醒。
|
||||
1. 先核对用户/工程的静音选择。当前回复已经写上「待你确认」清单后,记录该批问题的发送时间、推荐及范围,再播放。回复里还没有这份清单时不播放,也不把这一轮当作已经发问。
|
||||
2. 每批问题只播放一次,多题合并发送共用一次提醒。将“已尝试提醒/结果”记在现有任务记录;同一批等待、轮询、重试和会话恢复不重复播放。真正重新提出的新问题或用户要求再提醒时才播放下一次。
|
||||
3. 执行以下已安装入口。它只读取随包预生成的本地 WAV,并调用播放器;不联网、不调用语音生成服务、不在提问时临时生成语音。命令最多等待播放器 12 秒,不另开后台常驻进程。
|
||||
3. **Cursor 等「工具先于最终正文」的宿主**:不得在仍要继续工具批处理时播放。需要播放时,将 `notify-question` 放在本轮最后一次工具调用,并保证同轮最终正文以「待你确认」清单为主体且本轮停手。提示音在整机播放、不绑定聊天标签;Queued/Multitasking 队列不是确认入口。工程适配见 `docs/agents/cursor-question-visibility.md`(若存在)。
|
||||
4. 执行以下已安装入口。它只读取随包预生成的本地 WAV,并调用播放器;不联网、不调用语音生成服务、不在提问时临时生成语音。命令最多等待播放器 12 秒,不另开后台常驻进程。
|
||||
|
||||
```sh
|
||||
python3 .agents/skills/personal-dev/scripts/pda.py notify-question --project .
|
||||
|
||||
@@ -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 成功执行全目录强制清空。
|
||||
|
||||
@@ -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,9 +240,10 @@ class Tracker:
|
||||
if not value:
|
||||
return records
|
||||
records.extend(value)
|
||||
# Gitea comment pages may ignore page and keep returning the same short
|
||||
# list; a short page is the conventional last-page signal.
|
||||
if len(value) < PAGE_SIZE:
|
||||
# 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')
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
---
|
||||
description: 个人开发体系的工程任务入口
|
||||
description: 个人开发体系的工程任务入口(含 Cursor 确认可见性)
|
||||
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` 查阅。
|
||||
|
||||
需要用户确认时:
|
||||
|
||||
1. 先读 [Cursor 确认可见性](../../docs/agents/cursor-question-visibility.md),再按 Skill 的问题展示规则发问。
|
||||
2. 本轮最终用户可见回复必须以「待你确认」清单为主体(含推荐与未回复处理);写完清单前不播放提示音。
|
||||
3. 若播放提示音:`notify-question` 必须是本轮最后一次工具调用,且本轮随后不再继续编辑/浏览器/实现类工具。
|
||||
4. 清单中写明请在本会话回复;底部 Queued / Multitasking 不是确认入口。
|
||||
5. 回复正文没有「待你确认」时,不播放提示音。
|
||||
@@ -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 -->
|
||||
@@ -1,19 +1,28 @@
|
||||
"""样本储存证质检信息 API"""
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.menu_guard import require_any_menu_code, require_strict_any_menu_code
|
||||
from app.core.menu_guard import (
|
||||
require_any_menu_code,
|
||||
require_strict_any_menu_code,
|
||||
require_strict_menu_code,
|
||||
strict_user_has_any_menu_perm,
|
||||
strict_user_has_menu_perm,
|
||||
user_has_menu_perm,
|
||||
)
|
||||
from app.core.response import error_response, success_response
|
||||
from app.crud import sample_storage_cert_qc as svc
|
||||
from app.crud import sample_storage_cert_qc_acl as acl_svc
|
||||
from app.models.models import SysUser
|
||||
from app.services.sample_storage_cert_qc import ACL_CONFIG_MENU
|
||||
|
||||
router = APIRouter(prefix="/web/admin", tags=["储存证质检信息"])
|
||||
|
||||
# 市场 / 质检 / 生产三端共用读写(方案 A:完整表后写覆盖)
|
||||
# 市场 / 质检 / 生产三端共用读写
|
||||
READ_MENUS = (
|
||||
"biz:qcWorkOrder:list",
|
||||
"biz:myQcTask:list",
|
||||
@@ -41,6 +50,12 @@ def _get_current_user_id(user: dict) -> str:
|
||||
return str(user_id)
|
||||
|
||||
|
||||
def _can_configure_acl(db: Session, user: dict) -> bool:
|
||||
return user_has_menu_perm(db, user, ACL_CONFIG_MENU) or strict_user_has_menu_perm(
|
||||
db, user, ACL_CONFIG_MENU
|
||||
)
|
||||
|
||||
|
||||
class UpsertBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
@@ -48,6 +63,13 @@ class UpsertBody(BaseModel):
|
||||
qc_work_order_id: Optional[str] = Field(None, alias="qcWorkOrderId")
|
||||
|
||||
|
||||
class AclUpsertBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
field_keys: List[str] = Field(default_factory=list, alias="fieldKeys")
|
||||
user_ids: List[str] = Field(default_factory=list, alias="userIds")
|
||||
|
||||
|
||||
@router.get("/sample-storage-cert-qc/{sample_code}")
|
||||
async def get_sample_storage_cert_qc(
|
||||
sample_code: str,
|
||||
@@ -55,9 +77,19 @@ async def get_sample_storage_cert_qc(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
detail = svc.get_detail(db, tenant_no, sample_code)
|
||||
if detail is None:
|
||||
return error_response(404, "样本不存在")
|
||||
has_legacy_write = strict_user_has_any_menu_perm(db, user, WRITE_MENUS)
|
||||
svc.attach_acl_context(
|
||||
db,
|
||||
detail,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
has_legacy_write=has_legacy_write,
|
||||
can_configure_acl=_can_configure_acl(db, user),
|
||||
)
|
||||
return success_response(detail)
|
||||
|
||||
|
||||
@@ -70,6 +102,18 @@ async def put_sample_storage_cert_qc(
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
has_legacy_write = strict_user_has_any_menu_perm(db, user, WRITE_MENUS)
|
||||
# 先用空 sampleTypeKey 解析严格模式名单;类型在 upsert 内再收窄
|
||||
preview = svc.get_detail(db, tenant_no, sample_code)
|
||||
if preview is None:
|
||||
return error_response(404, "样本不存在")
|
||||
editable = acl_svc.resolve_editable_field_keys(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_type_key=preview.get("sampleTypeKey"),
|
||||
has_legacy_write=has_legacy_write,
|
||||
)
|
||||
try:
|
||||
detail = svc.upsert(
|
||||
db,
|
||||
@@ -78,6 +122,54 @@ async def put_sample_storage_cert_qc(
|
||||
sample_code=sample_code,
|
||||
payload=body.payload,
|
||||
qc_work_order_id=body.qc_work_order_id,
|
||||
editable_field_keys=editable,
|
||||
)
|
||||
svc.attach_acl_context(
|
||||
db,
|
||||
detail,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
has_legacy_write=has_legacy_write,
|
||||
can_configure_acl=_can_configure_acl(db, user),
|
||||
)
|
||||
return success_response(detail, message="保存成功")
|
||||
except PermissionError as e:
|
||||
return error_response(403, str(e))
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.get("/sample-storage-cert-qc-acl/{scope}")
|
||||
async def get_sample_storage_cert_qc_acl(
|
||||
scope: str,
|
||||
user: dict = Depends(require_strict_menu_code(ACL_CONFIG_MENU, allow_super_admin=True)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
try:
|
||||
detail = acl_svc.get_acl_detail(db, tenant_no, scope)
|
||||
return success_response(detail)
|
||||
except ValueError as e:
|
||||
return error_response(400, str(e))
|
||||
|
||||
|
||||
@router.put("/sample-storage-cert-qc-acl/{scope}")
|
||||
async def put_sample_storage_cert_qc_acl(
|
||||
scope: str,
|
||||
body: AclUpsertBody,
|
||||
user: dict = Depends(require_strict_menu_code(ACL_CONFIG_MENU, allow_super_admin=True)),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
tenant_no = _get_tenant_no(db, user)
|
||||
user_id = _get_current_user_id(user)
|
||||
try:
|
||||
detail = acl_svc.upsert_acl(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
scope=scope,
|
||||
field_keys=body.field_keys,
|
||||
user_ids=body.user_ids,
|
||||
)
|
||||
return success_response(detail, message="保存成功")
|
||||
except ValueError as e:
|
||||
|
||||
@@ -576,12 +576,14 @@ def attach_presign_ledger_fields(
|
||||
}
|
||||
times_by_order: Dict[str, List[str]] = {}
|
||||
if order_ids:
|
||||
# 与 confirmedDepositAmount 一致:仅已确认到账(yjf)计入刷卡时间
|
||||
transfers = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.presign_order_id.in_(order_ids),
|
||||
BizPresignTransfer.tenant_no == tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
BizPresignTransfer.pay_status == "yjf",
|
||||
)
|
||||
.order_by(
|
||||
BizPresignTransfer.transfer_time.asc(),
|
||||
|
||||
@@ -94,7 +94,7 @@ def apply_planned_start_time_step_filter(
|
||||
planned_start_time_end: Optional[str] = None,
|
||||
mine_user_id: Optional[str] = None,
|
||||
):
|
||||
"""按活跃工单/任务下未完成工艺步骤的计划开始时间过滤。"""
|
||||
"""按工艺步骤计划开始时间过滤;工单/任务/步骤状态由外层检索条件组合控制。"""
|
||||
start = (planned_start_time_start or "").strip()
|
||||
end = (planned_start_time_end or "").strip()
|
||||
if not start and not end:
|
||||
@@ -104,15 +104,9 @@ def apply_planned_start_time_step_filter(
|
||||
BizProductionTaskStep.del_flag == 0,
|
||||
BizProductionTaskStep.tenant_no == tenant_no,
|
||||
BizProductionTaskStep.planned_start_time.isnot(None),
|
||||
BizProductionTaskStep.step_status != "ywc",
|
||||
BizProductionTask.id == BizProductionTaskStep.task_id,
|
||||
BizProductionTask.del_flag == 0,
|
||||
BizProductionTask.tenant_no == tenant_no,
|
||||
BizProductionTask.task_status.in_(("dpz", "jxz")),
|
||||
BizProductionWorkOrder.id == BizProductionTask.work_order_id,
|
||||
BizProductionWorkOrder.del_flag == 0,
|
||||
BizProductionWorkOrder.tenant_no == tenant_no,
|
||||
BizProductionWorkOrder.work_order_status.in_(("dpz", "jxz")),
|
||||
work_order_id_column == BizProductionTask.work_order_id,
|
||||
]
|
||||
if start:
|
||||
|
||||
@@ -15,12 +15,14 @@ from app.models.models import (
|
||||
BizProtocolSample,
|
||||
BizSampleStorageCertQc,
|
||||
)
|
||||
from app.crud import sample_storage_cert_qc_acl as acl_svc
|
||||
from app.services.sample_storage_cert_qc import (
|
||||
dumps_payload,
|
||||
empty_detail,
|
||||
loads_payload,
|
||||
normalize_payload,
|
||||
merge_payload_with_acl,
|
||||
resolve_sample_type_key,
|
||||
sanitize_payload_for_type,
|
||||
)
|
||||
from app.services.sample_storage_cert_qc_prefill import collect_prefill
|
||||
|
||||
@@ -44,7 +46,7 @@ def _row_to_dict(
|
||||
"collectionProtocolId": row.collection_protocol_id or "",
|
||||
"qcWorkOrderId": row.qc_work_order_id or "",
|
||||
"filledFlag": str(row.filled_flag or "0") == "1",
|
||||
"payload": loads_payload(row.payload),
|
||||
"payload": sanitize_payload_for_type(loads_payload(row.payload), row.sample_type_key),
|
||||
"createTime": row.create_time.isoformat(sep=" ", timespec="seconds") if row.create_time else "",
|
||||
"updateTime": row.update_time.isoformat(sep=" ", timespec="seconds") if row.update_time else "",
|
||||
}
|
||||
@@ -165,6 +167,7 @@ def upsert(
|
||||
sample_code: str,
|
||||
payload: Any,
|
||||
qc_work_order_id: Optional[str] = None,
|
||||
editable_field_keys: Optional[list] = None,
|
||||
) -> Dict[str, Any]:
|
||||
ctx = _load_sample_context(db, tenant_no, sample_code)
|
||||
if not ctx:
|
||||
@@ -173,7 +176,6 @@ def upsert(
|
||||
key = resolve_sample_type_key(type_name)
|
||||
if not key:
|
||||
raise ValueError("该样本类型不支持填写储存证质检信息")
|
||||
clean = normalize_payload(payload)
|
||||
now = datetime.now()
|
||||
row = (
|
||||
db.query(BizSampleStorageCertQc)
|
||||
@@ -184,6 +186,14 @@ def upsert(
|
||||
)
|
||||
.first()
|
||||
)
|
||||
existing = loads_payload(row.payload) if row else {}
|
||||
allowed = list(editable_field_keys or [])
|
||||
clean = merge_payload_with_acl(
|
||||
existing=existing,
|
||||
incoming=payload,
|
||||
editable_keys=allowed,
|
||||
sample_type_key=key,
|
||||
)
|
||||
if not row:
|
||||
row = BizSampleStorageCertQc(
|
||||
id=generate_uuid(),
|
||||
@@ -220,3 +230,24 @@ def upsert(
|
||||
mother_name=mother_name,
|
||||
sample_type_name=type_name,
|
||||
)
|
||||
|
||||
|
||||
def attach_acl_context(
|
||||
db: Session,
|
||||
detail: Dict[str, Any],
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
has_legacy_write: bool,
|
||||
can_configure_acl: bool,
|
||||
) -> Dict[str, Any]:
|
||||
detail["editableFieldKeys"] = acl_svc.resolve_editable_field_keys(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
sample_type_key=detail.get("sampleTypeKey"),
|
||||
has_legacy_write=has_legacy_write,
|
||||
)
|
||||
detail["canConfigureAcl"] = bool(can_configure_acl)
|
||||
detail["aclStrict"] = acl_svc.is_acl_strict(db, tenant_no)
|
||||
return detail
|
||||
@@ -0,0 +1,271 @@
|
||||
"""储存证质检字段 ACL CRUD。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.utils import generate_uuid
|
||||
from app.crud.child_row_sync import sync_child_rows
|
||||
from app.models.models import BizStorageCertQcAcl, BizStorageCertQcAclUser, SysUser
|
||||
from app.services.sample_storage_cert_qc import (
|
||||
ACL_SCOPES,
|
||||
ALL_STORAGE_CERT_QC_FIELD_KEYS,
|
||||
dumps_field_keys,
|
||||
legal_field_keys_for_type,
|
||||
loads_field_keys,
|
||||
)
|
||||
|
||||
|
||||
def is_acl_strict(db: Session, tenant_no: str) -> bool:
|
||||
row = (
|
||||
db.query(BizStorageCertQcAcl.id)
|
||||
.filter(
|
||||
BizStorageCertQcAcl.tenant_no == tenant_no,
|
||||
BizStorageCertQcAcl.configured_flag == "1",
|
||||
BizStorageCertQcAcl.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
return row is not None
|
||||
|
||||
|
||||
def resolve_editable_field_keys(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
sample_type_key: Optional[str],
|
||||
has_legacy_write: bool,
|
||||
) -> List[str]:
|
||||
legal = legal_field_keys_for_type(sample_type_key)
|
||||
if not is_acl_strict(db, tenant_no):
|
||||
return sorted(legal) if has_legacy_write else []
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
return []
|
||||
acls = (
|
||||
db.query(BizStorageCertQcAcl)
|
||||
.filter(
|
||||
BizStorageCertQcAcl.tenant_no == tenant_no,
|
||||
BizStorageCertQcAcl.configured_flag == "1",
|
||||
BizStorageCertQcAcl.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not acls:
|
||||
return []
|
||||
acl_ids = [row.id for row in acls]
|
||||
membership = (
|
||||
db.query(BizStorageCertQcAclUser.acl_id)
|
||||
.filter(
|
||||
BizStorageCertQcAclUser.tenant_no == tenant_no,
|
||||
BizStorageCertQcAclUser.acl_id.in_(acl_ids),
|
||||
BizStorageCertQcAclUser.user_id == uid,
|
||||
BizStorageCertQcAclUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
member_acl_ids = {row[0] for row in membership}
|
||||
keys: Set[str] = set()
|
||||
for acl in acls:
|
||||
if acl.id not in member_acl_ids:
|
||||
continue
|
||||
keys.update(loads_field_keys(acl.field_keys))
|
||||
return sorted(keys & legal)
|
||||
|
||||
|
||||
def _validate_scope(scope: str) -> str:
|
||||
value = (scope or "").strip()
|
||||
if value not in ACL_SCOPES:
|
||||
raise ValueError("scope 必须为 qc 或 production")
|
||||
return value
|
||||
|
||||
|
||||
def _validate_field_keys(field_keys: Any) -> List[str]:
|
||||
if not isinstance(field_keys, list):
|
||||
raise ValueError("fieldKeys 必须为数组")
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for item in field_keys:
|
||||
key = str(item or "").strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
if key not in ALL_STORAGE_CERT_QC_FIELD_KEYS:
|
||||
raise ValueError(f"非法字段: {key}")
|
||||
seen.add(key)
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def _validate_user_ids(db: Session, tenant_no: str, user_ids: Any) -> List[str]:
|
||||
if not isinstance(user_ids, list):
|
||||
raise ValueError("userIds 必须为数组")
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for item in user_ids:
|
||||
uid = str(item or "").strip()
|
||||
if not uid or uid in seen:
|
||||
continue
|
||||
seen.add(uid)
|
||||
out.append(uid)
|
||||
if not out:
|
||||
return []
|
||||
rows = (
|
||||
db.query(SysUser.id)
|
||||
.filter(
|
||||
SysUser.tenant_no == tenant_no,
|
||||
SysUser.id.in_(out),
|
||||
SysUser.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
found = {row[0] for row in rows}
|
||||
missing = [uid for uid in out if uid not in found]
|
||||
if missing:
|
||||
raise ValueError(f"用户不存在或不属于当前租户: {','.join(missing[:5])}")
|
||||
return out
|
||||
|
||||
|
||||
def get_acl_detail(db: Session, tenant_no: str, scope: str) -> Dict[str, Any]:
|
||||
scope_key = _validate_scope(scope)
|
||||
row = (
|
||||
db.query(BizStorageCertQcAcl)
|
||||
.filter(
|
||||
BizStorageCertQcAcl.tenant_no == tenant_no,
|
||||
BizStorageCertQcAcl.scope == scope_key,
|
||||
BizStorageCertQcAcl.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
return {
|
||||
"scope": scope_key,
|
||||
"fieldKeys": [],
|
||||
"userIds": [],
|
||||
"users": [],
|
||||
"configuredFlag": False,
|
||||
"aclStrict": is_acl_strict(db, tenant_no),
|
||||
}
|
||||
users = (
|
||||
db.query(BizStorageCertQcAclUser, SysUser)
|
||||
.outerjoin(
|
||||
SysUser,
|
||||
(SysUser.id == BizStorageCertQcAclUser.user_id) & (SysUser.del_flag == 0),
|
||||
)
|
||||
.filter(
|
||||
BizStorageCertQcAclUser.tenant_no == tenant_no,
|
||||
BizStorageCertQcAclUser.acl_id == row.id,
|
||||
BizStorageCertQcAclUser.del_flag == 0,
|
||||
)
|
||||
.order_by(BizStorageCertQcAclUser.order_num.asc(), BizStorageCertQcAclUser.create_time.asc())
|
||||
.all()
|
||||
)
|
||||
user_ids: List[str] = []
|
||||
user_rows: List[Dict[str, str]] = []
|
||||
for link, user in users:
|
||||
uid = link.user_id or ""
|
||||
if not uid:
|
||||
continue
|
||||
user_ids.append(uid)
|
||||
user_rows.append(
|
||||
{
|
||||
"id": uid,
|
||||
"nickName": (user.nick_name if user else "") or "",
|
||||
"userName": (user.user_no if user else "") or "",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"scope": scope_key,
|
||||
"fieldKeys": loads_field_keys(row.field_keys),
|
||||
"userIds": user_ids,
|
||||
"users": user_rows,
|
||||
"configuredFlag": str(row.configured_flag or "0") == "1",
|
||||
"aclStrict": is_acl_strict(db, tenant_no),
|
||||
}
|
||||
|
||||
|
||||
def upsert_acl(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_no: str,
|
||||
user_id: str,
|
||||
scope: str,
|
||||
field_keys: Any,
|
||||
user_ids: Any,
|
||||
) -> Dict[str, Any]:
|
||||
scope_key = _validate_scope(scope)
|
||||
keys = _validate_field_keys(field_keys)
|
||||
users = _validate_user_ids(db, tenant_no, user_ids)
|
||||
now = datetime.now()
|
||||
row = (
|
||||
db.query(BizStorageCertQcAcl)
|
||||
.filter(
|
||||
BizStorageCertQcAcl.tenant_no == tenant_no,
|
||||
BizStorageCertQcAcl.scope == scope_key,
|
||||
BizStorageCertQcAcl.del_flag == 0,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not row:
|
||||
row = BizStorageCertQcAcl(
|
||||
id=generate_uuid(),
|
||||
scope=scope_key,
|
||||
field_keys=dumps_field_keys(keys),
|
||||
configured_flag="1",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
db.add(row)
|
||||
db.flush()
|
||||
else:
|
||||
row.field_keys = dumps_field_keys(keys)
|
||||
row.configured_flag = "1"
|
||||
row.update_by = user_id
|
||||
row.update_time = now
|
||||
|
||||
def _build_row(new_id: str):
|
||||
return BizStorageCertQcAclUser(
|
||||
id=new_id,
|
||||
acl_id=row.id,
|
||||
user_id="",
|
||||
tenant_no=tenant_no,
|
||||
create_by=user_id,
|
||||
create_dept_id="",
|
||||
create_time=now,
|
||||
update_by=user_id,
|
||||
update_time=now,
|
||||
del_flag=0,
|
||||
)
|
||||
|
||||
def _apply_fields(link, item: Dict[str, Any], index: int, ts: datetime):
|
||||
link.user_id = str(item.get("userId") or "").strip()
|
||||
link.tenant_no = tenant_no
|
||||
link.order_num = index
|
||||
link.update_by = user_id
|
||||
link.update_time = ts
|
||||
|
||||
sync_child_rows(
|
||||
db,
|
||||
BizStorageCertQcAclUser,
|
||||
parent_attr="acl_id",
|
||||
parent_id=row.id,
|
||||
items=[{"userId": uid} for uid in users],
|
||||
build_row=_build_row,
|
||||
apply_fields=_apply_fields,
|
||||
tenant_no=tenant_no,
|
||||
user_id=user_id,
|
||||
now=now,
|
||||
natural_key=lambda item: str(item.get("userId") or "").strip(),
|
||||
row_natural_key=lambda link: str(getattr(link, "user_id", "") or "").strip(),
|
||||
duplicate_message="人员重复",
|
||||
foreign_message="人员记录不属于当前 ACL",
|
||||
)
|
||||
db.commit()
|
||||
return get_acl_detail(db, tenant_no, scope_key)
|
||||
@@ -1779,6 +1779,45 @@ class BizSampleStorageCertQc(Base):
|
||||
del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记")
|
||||
|
||||
|
||||
class BizStorageCertQcAcl(Base):
|
||||
"""储存证质检字段 ACL 主表 biz_storage_cert_qc_acl"""
|
||||
|
||||
__tablename__ = "biz_storage_cert_qc_acl"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
scope = Column(String(32), nullable=False, comment="qc|production")
|
||||
field_keys = Column(Text, nullable=False, comment="可编辑字段 key JSON 数组")
|
||||
configured_flag = Column(CHAR(1), nullable=False, default="0", comment="是否已正式配置 0否1是")
|
||||
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 BizStorageCertQcAclUser(Base):
|
||||
"""储存证质检字段 ACL 人员子表 biz_storage_cert_qc_acl_user"""
|
||||
|
||||
__tablename__ = "biz_storage_cert_qc_acl_user"
|
||||
|
||||
id = Column(String(64), primary_key=True, comment="主键ID")
|
||||
acl_id = Column(String(64), nullable=False, comment="ACL 主表ID")
|
||||
user_id = Column(String(64), nullable=False, comment="可编辑用户ID")
|
||||
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 BizSampleCodeCorrectionLog(Base):
|
||||
"""样本编码纠错审计日志 biz_sample_code_correction_log"""
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models.models import (
|
||||
BizProtocolPaymentDetail,
|
||||
BizProtocolPaymentSchedule,
|
||||
BizProtocolPaymentTransfer,
|
||||
BizProtocolRefundRecord,
|
||||
BizProtocolSamplePayment,
|
||||
)
|
||||
|
||||
@@ -127,6 +128,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 +158,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 +175,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,
|
||||
@@ -219,6 +230,197 @@ def _first_period_first_transfer_times(
|
||||
return {pid: _fmt_dt(t) for pid, t in earliest.items() if _fmt_dt(t)}
|
||||
|
||||
|
||||
def _received_amount_from_transfers(transfers: List[BizProtocolPaymentTransfer]) -> float:
|
||||
total = Decimal("0")
|
||||
for t in transfers:
|
||||
st = (t.pay_status or "").strip()
|
||||
if st in ("yjf", "jfqrz"):
|
||||
total += t.transfer_amount or Decimal("0")
|
||||
return float(total)
|
||||
|
||||
|
||||
def _v2_schedule_detail_rows(
|
||||
schedules: List[BizProtocolPaymentSchedule],
|
||||
transfers: List[BizProtocolPaymentTransfer],
|
||||
pay_mode: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
by_schedule: Dict[str, List[BizProtocolPaymentTransfer]] = {}
|
||||
for t in transfers:
|
||||
sid = (t.schedule_id or "").strip()
|
||||
if sid:
|
||||
by_schedule.setdefault(sid, []).append(t)
|
||||
active = [
|
||||
s
|
||||
for s in schedules
|
||||
if (s.schedule_status or "").strip() != "void"
|
||||
]
|
||||
active.sort(
|
||||
key=lambda s: (
|
||||
s.schedule_no if s.schedule_no is not None else 10_000,
|
||||
s.order_num if s.order_num is not None else 0,
|
||||
s.create_time or datetime.min,
|
||||
)
|
||||
)
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for idx, sch in enumerate(active):
|
||||
sch_transfers = by_schedule.get(sch.id, [])
|
||||
paid = [
|
||||
t
|
||||
for t in sch_transfers
|
||||
if (t.pay_status or "").strip() in ("yjf", "jfqrz")
|
||||
]
|
||||
paid_amount = _received_amount_from_transfers(paid) if paid else None
|
||||
statuses = [(t.pay_status or "").strip() for t in sch_transfers if (t.pay_status or "").strip()]
|
||||
if "jfqrz" in statuses:
|
||||
st = "jfqrz"
|
||||
elif "yjf" in statuses and paid:
|
||||
st = "yjf"
|
||||
elif statuses:
|
||||
st = statuses[0]
|
||||
else:
|
||||
st = "dfj"
|
||||
times = [t.transfer_time for t in paid if t.transfer_time is not None]
|
||||
transfer_time = _fmt_dt(min(times)) if times else ""
|
||||
inst_no = sch.schedule_no
|
||||
if pay_mode in ("fq", "djfq") and inst_no is not None:
|
||||
label = f"第{inst_no}期"
|
||||
else:
|
||||
label = f"缴费{idx + 1}"
|
||||
rows.append(
|
||||
{
|
||||
"installmentNo": inst_no,
|
||||
"installmentLabel": label,
|
||||
"installmentDueDate": _fmt_ymd(sch.due_date),
|
||||
"dueAmount": _dec(sch.due_amount),
|
||||
"transferAmount": paid_amount,
|
||||
"refundAmount": None,
|
||||
"payStatus": st,
|
||||
"payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"),
|
||||
"transferTime": transfer_time,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _load_v2_protocol_payment_amounts(
|
||||
db: Session,
|
||||
protocol_ids: List[str],
|
||||
tenant_no: str,
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""按协议汇总 V2 样本缴费合同额、已收、明细与退费(仅 list/export 摘要用)。"""
|
||||
ids = [x.strip() for x in protocol_ids if (x or "").strip()]
|
||||
if not ids:
|
||||
return {}
|
||||
samples = (
|
||||
db.query(BizProtocolSamplePayment)
|
||||
.filter(
|
||||
BizProtocolSamplePayment.collection_protocol_id.in_(ids),
|
||||
BizProtocolSamplePayment.tenant_no == tenant_no,
|
||||
BizProtocolSamplePayment.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if not samples:
|
||||
return {}
|
||||
schedules = (
|
||||
db.query(BizProtocolPaymentSchedule)
|
||||
.filter(
|
||||
BizProtocolPaymentSchedule.collection_protocol_id.in_(ids),
|
||||
BizProtocolPaymentSchedule.tenant_no == tenant_no,
|
||||
BizProtocolPaymentSchedule.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
transfers = (
|
||||
db.query(BizProtocolPaymentTransfer)
|
||||
.filter(
|
||||
BizProtocolPaymentTransfer.collection_protocol_id.in_(ids),
|
||||
BizProtocolPaymentTransfer.tenant_no == tenant_no,
|
||||
BizProtocolPaymentTransfer.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
refunds = (
|
||||
db.query(BizProtocolRefundRecord)
|
||||
.filter(
|
||||
BizProtocolRefundRecord.collection_protocol_id.in_(ids),
|
||||
BizProtocolRefundRecord.tenant_no == tenant_no,
|
||||
BizProtocolRefundRecord.del_flag == 0,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
samples_by: Dict[str, List[BizProtocolSamplePayment]] = {}
|
||||
for sp in samples:
|
||||
samples_by.setdefault(sp.collection_protocol_id, []).append(sp)
|
||||
schedules_by: Dict[str, List[BizProtocolPaymentSchedule]] = {}
|
||||
for s in schedules:
|
||||
schedules_by.setdefault(s.collection_protocol_id, []).append(s)
|
||||
transfers_by: Dict[str, List[BizProtocolPaymentTransfer]] = {}
|
||||
for t in transfers:
|
||||
transfers_by.setdefault(t.collection_protocol_id, []).append(t)
|
||||
refund_by: Dict[str, float] = {}
|
||||
refund_seen: Dict[str, bool] = {}
|
||||
for r in refunds:
|
||||
amt = _dec(r.refund_amount)
|
||||
if amt is None:
|
||||
continue
|
||||
refund_seen[r.collection_protocol_id] = True
|
||||
refund_by[r.collection_protocol_id] = refund_by.get(r.collection_protocol_id, 0.0) + float(amt)
|
||||
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for pid, sp_list in samples_by.items():
|
||||
modes = {(sp.pay_mode or "").strip() for sp in sp_list if (sp.pay_mode or "").strip()}
|
||||
ordered_modes = sorted(
|
||||
modes,
|
||||
key=lambda mode: ({"dj": 0, "fq": 1, "djfq": 2, "fgd": 3}.get(mode, 99), mode),
|
||||
)
|
||||
if modes == {"dj", "fq"}:
|
||||
pay_mode = "djfq"
|
||||
else:
|
||||
pay_mode = "+".join(ordered_modes) if ordered_modes else ""
|
||||
contract_total = Decimal("0")
|
||||
has_contract = False
|
||||
for sp in sp_list:
|
||||
if sp.contract_amount is not None:
|
||||
has_contract = True
|
||||
contract_total += sp.contract_amount or Decimal("0")
|
||||
contract = float(contract_total) if has_contract else None
|
||||
proto_transfers = transfers_by.get(pid, [])
|
||||
received = _received_amount_from_transfers(proto_transfers)
|
||||
pending = max(contract - received, 0) if contract is not None else None
|
||||
detail_rows = _v2_schedule_detail_rows(
|
||||
schedules_by.get(pid, []),
|
||||
proto_transfers,
|
||||
pay_mode,
|
||||
)
|
||||
statuses = [(t.pay_status or "").strip() for t in proto_transfers]
|
||||
overall = _protocol_overall_status(statuses, contract, pending)
|
||||
hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"]
|
||||
installment_count = None
|
||||
if pay_mode in ("fq", "djfq"):
|
||||
installment_count = len(
|
||||
{
|
||||
d.get("installmentNo")
|
||||
for d in detail_rows
|
||||
if d.get("installmentNo") is not None
|
||||
}
|
||||
) or len(detail_rows)
|
||||
payload: Dict[str, Any] = {
|
||||
"payMode": pay_mode,
|
||||
"contractAmount": contract,
|
||||
"receivedAmount": received,
|
||||
"pendingAmount": pending,
|
||||
"statusHint": hint,
|
||||
"installmentCount": installment_count,
|
||||
"paymentDetails": detail_rows,
|
||||
**overall,
|
||||
}
|
||||
if refund_seen.get(pid):
|
||||
payload["refundAmount"] = refund_by.get(pid, 0.0)
|
||||
out[pid] = payload
|
||||
return out
|
||||
|
||||
|
||||
def build_protocol_payment_summaries(
|
||||
db: Session,
|
||||
protocol_ids: List[str],
|
||||
@@ -253,6 +455,7 @@ def build_protocol_payment_summaries(
|
||||
):
|
||||
details_by_payment.setdefault(d.payment_id, []).append(d)
|
||||
sample_modes: Dict[str, set] = {}
|
||||
v2_amounts: Dict[str, Dict[str, Any]] = {}
|
||||
if include_sample_payment_modes:
|
||||
for pid, mode in db.query(
|
||||
BizProtocolSamplePayment.collection_protocol_id,
|
||||
@@ -264,6 +467,7 @@ def build_protocol_payment_summaries(
|
||||
).all():
|
||||
if mode:
|
||||
sample_modes.setdefault(pid, set()).add(mode)
|
||||
v2_amounts = _load_v2_protocol_payment_amounts(db, ids, tenant_no)
|
||||
first_pay_map = _first_period_first_transfer_times(db, ids, tenant_no)
|
||||
out: Dict[str, Dict[str, Any]] = {}
|
||||
for pid in ids:
|
||||
@@ -275,7 +479,8 @@ def build_protocol_payment_summaries(
|
||||
)
|
||||
first_pay = first_pay_map.get(pid) or ""
|
||||
modes = sample_modes.get(pid, set())
|
||||
if not summary and not first_pay and not modes:
|
||||
v2 = v2_amounts.get(pid)
|
||||
if not summary and not first_pay and not modes and not v2:
|
||||
continue
|
||||
if not summary:
|
||||
summary = {
|
||||
@@ -284,6 +489,7 @@ def build_protocol_payment_summaries(
|
||||
"contractAmount": None,
|
||||
"receivedAmount": None,
|
||||
"pendingAmount": None,
|
||||
"refundAmount": None,
|
||||
"statusHint": "",
|
||||
"installmentCount": None,
|
||||
"paymentDetails": [],
|
||||
@@ -299,6 +505,27 @@ def build_protocol_payment_summaries(
|
||||
labels = {**_PAY_MODE_LABELS, "fgd": "分改趸"}
|
||||
summary["payMode"] = "djfq" if modes == {"dj", "fq"} else "+".join(ordered_modes)
|
||||
summary["payModeLabel"] = "+".join(labels.get(mode, mode) for mode in ordered_modes)
|
||||
# V1 摘要缺已收金额时,用 V2 样本缴费补齐(自存表导出 / 协议列表依赖)。
|
||||
if v2 and summary.get("receivedAmount") is None:
|
||||
if summary.get("contractAmount") is None:
|
||||
summary["contractAmount"] = v2.get("contractAmount")
|
||||
summary["receivedAmount"] = v2.get("receivedAmount")
|
||||
if summary.get("pendingAmount") is None:
|
||||
summary["pendingAmount"] = v2.get("pendingAmount")
|
||||
if not summary.get("paymentDetails"):
|
||||
summary["paymentDetails"] = v2.get("paymentDetails") or []
|
||||
if summary.get("installmentCount") is None:
|
||||
summary["installmentCount"] = v2.get("installmentCount")
|
||||
if summary.get("refundAmount") is None and "refundAmount" in v2:
|
||||
summary["refundAmount"] = v2.get("refundAmount")
|
||||
for key in (
|
||||
"statusHint",
|
||||
"overallPayStatus",
|
||||
"overallPayStatusLabel",
|
||||
"overallPayStatusTagType",
|
||||
):
|
||||
if not summary.get(key) and v2.get(key):
|
||||
summary[key] = v2.get(key)
|
||||
if first_pay:
|
||||
summary["firstPayTransferTime"] = first_pay
|
||||
out[pid] = summary
|
||||
|
||||
@@ -2,18 +2,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set
|
||||
|
||||
SAMPLE_TYPE_NAME_PERINATAL_HSC = "围产期造血干细胞"
|
||||
SAMPLE_TYPE_NAME_UC_MSC = "脐带间充质干细胞"
|
||||
SAMPLE_TYPE_KEY_PERINATAL_HSC = "perinatal_hsc"
|
||||
SAMPLE_TYPE_KEY_UC_MSC = "uc_msc"
|
||||
|
||||
ACL_SCOPE_QC = "qc"
|
||||
ACL_SCOPE_PRODUCTION = "production"
|
||||
ACL_SCOPES = frozenset({ACL_SCOPE_QC, ACL_SCOPE_PRODUCTION})
|
||||
ACL_CONFIG_MENU = "cell-storage:sample-info:storage-cert-qc-acl"
|
||||
|
||||
_NAME_TO_KEY = {
|
||||
SAMPLE_TYPE_NAME_PERINATAL_HSC: SAMPLE_TYPE_KEY_PERINATAL_HSC,
|
||||
SAMPLE_TYPE_NAME_UC_MSC: SAMPLE_TYPE_KEY_UC_MSC,
|
||||
}
|
||||
|
||||
# 与 web-admin STORAGE_CERT_QC_GROUPS 对齐的合法 payload 键(两类型并集)。
|
||||
ALL_STORAGE_CERT_QC_FIELD_KEYS = frozenset(
|
||||
{
|
||||
"collection_time",
|
||||
"transport_temp_c",
|
||||
"receive_time",
|
||||
"cryo_volume_ml",
|
||||
"tnc_1e8",
|
||||
"cd34_count_1e6",
|
||||
"cd34_cd45dim_1e5",
|
||||
"viability_pct",
|
||||
"bacteria_test",
|
||||
"hbsag",
|
||||
"anti_hcv",
|
||||
"anti_tp",
|
||||
"anti_hiv",
|
||||
"cmv_igm",
|
||||
"mother_hbsag",
|
||||
"mother_hbv_dna",
|
||||
"mother_anti_hcv",
|
||||
"mother_hcv_rna",
|
||||
"mother_anti_tp",
|
||||
"mother_cmv_igm",
|
||||
"mother_anti_hiv",
|
||||
"mother_hiv1_rna",
|
||||
"hla_typing",
|
||||
"storage_date",
|
||||
"storage_years",
|
||||
"result_comment",
|
||||
"report_issue_date",
|
||||
"storage_tube_count",
|
||||
"available_cell_type",
|
||||
"available_cell_count_1e7",
|
||||
"available_viability_pct",
|
||||
"endotoxin_test",
|
||||
"fungus_test",
|
||||
"mycoplasma_test",
|
||||
"available_cell_morphology",
|
||||
"available_immunophenotype",
|
||||
"result_judgment",
|
||||
"storage_date_p2",
|
||||
"freeze_method",
|
||||
"storage_temp",
|
||||
}
|
||||
)
|
||||
|
||||
# 脐带间充质储存证不收录母亲血液三项核酸检测。
|
||||
UC_MSC_EXCLUDED_PAYLOAD_KEYS = frozenset(
|
||||
{
|
||||
"mother_hbv_dna",
|
||||
"mother_hcv_rna",
|
||||
"mother_hiv1_rna",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def resolve_sample_type_key(sample_type_name: Optional[str]) -> Optional[str]:
|
||||
name = (sample_type_name or "").strip()
|
||||
@@ -24,6 +84,13 @@ def supports_storage_cert_qc(sample_type_name: Optional[str]) -> bool:
|
||||
return resolve_sample_type_key(sample_type_name) is not None
|
||||
|
||||
|
||||
def legal_field_keys_for_type(sample_type_key: Optional[str]) -> Set[str]:
|
||||
keys = set(ALL_STORAGE_CERT_QC_FIELD_KEYS)
|
||||
if sample_type_key == SAMPLE_TYPE_KEY_UC_MSC:
|
||||
keys -= UC_MSC_EXCLUDED_PAYLOAD_KEYS
|
||||
return keys
|
||||
|
||||
|
||||
def normalize_payload(payload: Any) -> Dict[str, Any]:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload 必须为对象")
|
||||
@@ -38,6 +105,16 @@ def normalize_payload(payload: Any) -> Dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def sanitize_payload_for_type(
|
||||
payload: Dict[str, Any],
|
||||
sample_type_key: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""按样本类型去掉不应展示/落库的键。"""
|
||||
if sample_type_key != SAMPLE_TYPE_KEY_UC_MSC:
|
||||
return payload
|
||||
return {key: value for key, value in payload.items() if key not in UC_MSC_EXCLUDED_PAYLOAD_KEYS}
|
||||
|
||||
|
||||
def dumps_payload(payload: Dict[str, Any]) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
@@ -54,6 +131,62 @@ def loads_payload(raw: Optional[str]) -> Dict[str, Any]:
|
||||
return data
|
||||
|
||||
|
||||
def dumps_field_keys(keys: Iterable[str]) -> str:
|
||||
cleaned = sorted({str(k).strip() for k in keys if str(k).strip()})
|
||||
return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def loads_field_keys(raw: Optional[str]) -> List[str]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
out: List[str] = []
|
||||
seen = set()
|
||||
for item in data:
|
||||
key = str(item or "").strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(key)
|
||||
return out
|
||||
|
||||
|
||||
def _payload_value_text(value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def merge_payload_with_acl(
|
||||
*,
|
||||
existing: Dict[str, Any],
|
||||
incoming: Dict[str, Any],
|
||||
editable_keys: Iterable[str],
|
||||
sample_type_key: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""仅合并有权字段;无权字段若相对原值变化则拒绝。"""
|
||||
allowed = {str(k).strip() for k in editable_keys if str(k).strip()}
|
||||
if not allowed:
|
||||
raise PermissionError("无字段编辑权限")
|
||||
clean_incoming = sanitize_payload_for_type(normalize_payload(incoming), sample_type_key)
|
||||
base = sanitize_payload_for_type(dict(existing or {}), sample_type_key)
|
||||
for key, value in clean_incoming.items():
|
||||
if key in allowed:
|
||||
continue
|
||||
if _payload_value_text(base.get(key)) != _payload_value_text(value):
|
||||
raise PermissionError(f"无权修改字段: {key}")
|
||||
merged = dict(base)
|
||||
for key in allowed:
|
||||
if key in clean_incoming:
|
||||
merged[key] = clean_incoming[key]
|
||||
return sanitize_payload_for_type(merged, sample_type_key)
|
||||
|
||||
|
||||
def empty_detail(
|
||||
*,
|
||||
sample_code: str,
|
||||
|
||||
@@ -168,6 +168,11 @@ _HSC_SEROLOGY_RULES = (
|
||||
)
|
||||
|
||||
_MOTHER_SEROLOGY_RULES = tuple(rule for rule in _HSC_SEROLOGY_RULES if str(rule[0]).startswith("mother_"))
|
||||
# 脐带间充质不预填母亲血液三项核酸。
|
||||
_UC_MSC_EXCLUDED_MOTHER_KEYS = frozenset({"mother_hbv_dna", "mother_hcv_rna", "mother_hiv1_rna"})
|
||||
_MOTHER_SEROLOGY_RULES_UC_MSC = tuple(
|
||||
rule for rule in _MOTHER_SEROLOGY_RULES if str(rule[0]) not in _UC_MSC_EXCLUDED_MOTHER_KEYS
|
||||
)
|
||||
|
||||
|
||||
def _serology_from_tasks(
|
||||
@@ -278,9 +283,19 @@ def _process_qual_from_tasks(tasks: List[Dict[str, Any]]) -> Dict[str, str]:
|
||||
return out
|
||||
|
||||
|
||||
def _fill_shared_mother(suggested: Dict[str, str], tasks: Optional[List[Dict[str, Any]]]) -> None:
|
||||
def _fill_shared_mother(
|
||||
suggested: Dict[str, str],
|
||||
tasks: Optional[List[Dict[str, Any]]],
|
||||
*,
|
||||
sample_type_key: str,
|
||||
) -> None:
|
||||
"""只补母亲血液空项。共享任务里的脐血不参与。"""
|
||||
shared = _serology_from_tasks(tasks or [], _MOTHER_SEROLOGY_RULES)
|
||||
rules = (
|
||||
_MOTHER_SEROLOGY_RULES_UC_MSC
|
||||
if sample_type_key == "uc_msc"
|
||||
else _MOTHER_SEROLOGY_RULES
|
||||
)
|
||||
shared = _serology_from_tasks(tasks or [], rules)
|
||||
for key, value in shared.items():
|
||||
suggested.setdefault(key, value)
|
||||
|
||||
@@ -330,7 +345,7 @@ def build_suggested(
|
||||
if count:
|
||||
suggested["available_cell_count_1e7"] = count
|
||||
suggested.update(_process_qual_from_tasks(process_tasks or []))
|
||||
suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES))
|
||||
suggested.update(_serology_from_tasks(gate_tasks or [], _MOTHER_SEROLOGY_RULES_UC_MSC))
|
||||
suggested.setdefault("bacteria_test", "阴性")
|
||||
suggested.setdefault("result_judgment", "合格")
|
||||
if freeze:
|
||||
@@ -338,7 +353,7 @@ def build_suggested(
|
||||
suggested.update(_FIXED_COMMON)
|
||||
suggested.update(_FIXED_UC_MSC)
|
||||
if sample_type_key in ("perinatal_hsc", "uc_msc"):
|
||||
_fill_shared_mother(suggested, shared_mother_tasks)
|
||||
_fill_shared_mother(suggested, shared_mother_tasks, sample_type_key=sample_type_key)
|
||||
return {key: value for key, value in suggested.items() if str(value).strip()}
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
@@ -2564,10 +2564,13 @@ def test_web_presign_list_includes_ledger_fields():
|
||||
order.mother_name = '陈影'
|
||||
transfer = _transfer('ledger-transfer', order_id='ledger-1', pay_status='yjf')
|
||||
transfer.transfer_time = datetime(2026, 4, 2, 16, 49, 42)
|
||||
pending = _transfer('ledger-pending', order_id='ledger-1', pay_status='dfj')
|
||||
pending.transfer_time = datetime(2026, 4, 1, 10, 0, 0)
|
||||
db.add_all(
|
||||
[
|
||||
order,
|
||||
transfer,
|
||||
pending,
|
||||
BizPresignOrderSampleType(
|
||||
id='ledger-type',
|
||||
presign_order_id='ledger-1',
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""计划开始时间筛选:不内置工单/任务/步骤状态限制,交给外层组合条件。"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.crud.production_work_order import apply_planned_start_time_step_filter
|
||||
from app.models.models import BizProductionWorkOrder
|
||||
|
||||
|
||||
def _compile_planned_start_sql() -> str:
|
||||
eng = create_engine("sqlite://")
|
||||
SessionLocal = sessionmaker(bind=eng)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(BizProductionWorkOrder).filter(BizProductionWorkOrder.del_flag == 0)
|
||||
filtered = apply_planned_start_time_step_filter(
|
||||
q,
|
||||
tenant_no="zkxw",
|
||||
work_order_id_column=BizProductionWorkOrder.id,
|
||||
planned_start_time_start="2026-10-01",
|
||||
planned_start_time_end="2026-12-31",
|
||||
)
|
||||
return str(
|
||||
filtered.statement.compile(
|
||||
dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}
|
||||
)
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def test_planned_start_filter_keeps_date_bounds_without_status_hardcodes():
|
||||
sql = _compile_planned_start_sql().lower()
|
||||
assert "planned_start_time" in sql
|
||||
assert "2026-10-01 00:00:00" in sql
|
||||
assert "2026-12-31 23:59:59" in sql
|
||||
# 不再在 EXISTS 内强制待配置/进行中或未完成步骤
|
||||
assert "work_order_status in (" not in sql
|
||||
assert "task_status in (" not in sql
|
||||
assert "step_status !=" not in sql
|
||||
assert "step_status <>" not in sql
|
||||
@@ -1,10 +1,18 @@
|
||||
"""协议列表可显式读取 V2 样本方案,其他费用摘要消费者保持原行为。"""
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from app.models.base import Base
|
||||
from app.models.models import BizProtocolSamplePayment
|
||||
from app.models.models import (
|
||||
BizProtocolPaymentSchedule,
|
||||
BizProtocolPaymentTransfer,
|
||||
BizProtocolRefundRecord,
|
||||
BizProtocolSamplePayment,
|
||||
)
|
||||
from app.services.qc_work_order_fee_summary import build_protocol_payment_summaries
|
||||
|
||||
|
||||
@@ -31,3 +39,180 @@ def test_protocol_list_uses_active_tenant_v2_payment_modes(modes, expected, labe
|
||||
assert result['p1']['payModeLabel'] == label
|
||||
assert build_protocol_payment_summaries(db, ['p1'], 'tenant-a') == {}
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_protocol_list_fills_received_from_v2_sample_transfers():
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(engine)
|
||||
with sessionmaker(bind=engine)() as db:
|
||||
db.add_all(
|
||||
[
|
||||
BizProtocolSamplePayment(
|
||||
id='sp-dj',
|
||||
collection_protocol_id='p-v2',
|
||||
sample_code='S1',
|
||||
pay_mode='dj',
|
||||
contract_amount=Decimal('12800'),
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolSamplePayment(
|
||||
id='sp-fq',
|
||||
collection_protocol_id='p-v2',
|
||||
sample_code='S2',
|
||||
pay_mode='fq',
|
||||
contract_amount=Decimal('22880'),
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolPaymentSchedule(
|
||||
id='sch-dj-1',
|
||||
sample_payment_id='sp-dj',
|
||||
collection_protocol_id='p-v2',
|
||||
sample_code='S1',
|
||||
schedule_no=1,
|
||||
due_amount=Decimal('12800'),
|
||||
due_date='20260915',
|
||||
schedule_status='cleared',
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolPaymentSchedule(
|
||||
id='sch-fq-1',
|
||||
sample_payment_id='sp-fq',
|
||||
collection_protocol_id='p-v2',
|
||||
sample_code='S2',
|
||||
schedule_no=1,
|
||||
due_amount=Decimal('6480'),
|
||||
due_date='20260915',
|
||||
schedule_status='cleared',
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolPaymentSchedule(
|
||||
id='sch-fq-2',
|
||||
sample_payment_id='sp-fq',
|
||||
collection_protocol_id='p-v2',
|
||||
sample_code='S2',
|
||||
schedule_no=2,
|
||||
due_amount=Decimal('16400'),
|
||||
due_date='20261015',
|
||||
schedule_status='open',
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolPaymentTransfer(
|
||||
id='tr-dj',
|
||||
schedule_id='sch-dj-1',
|
||||
sample_payment_id='sp-dj',
|
||||
collection_protocol_id='p-v2',
|
||||
sample_code='S1',
|
||||
transfer_amount=Decimal('12800'),
|
||||
transfer_time=datetime(2026, 9, 15, 12, 0, 0),
|
||||
pay_status='yjf',
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolPaymentTransfer(
|
||||
id='tr-fq',
|
||||
schedule_id='sch-fq-1',
|
||||
sample_payment_id='sp-fq',
|
||||
collection_protocol_id='p-v2',
|
||||
sample_code='S2',
|
||||
transfer_amount=Decimal('6480'),
|
||||
transfer_time=datetime(2026, 9, 15, 11, 13, 1),
|
||||
pay_status='yjf',
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolPaymentTransfer(
|
||||
id='tr-pending',
|
||||
schedule_id='sch-fq-2',
|
||||
sample_payment_id='sp-fq',
|
||||
collection_protocol_id='p-v2',
|
||||
sample_code='S2',
|
||||
transfer_amount=None,
|
||||
transfer_time=None,
|
||||
pay_status='dfj',
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
result = build_protocol_payment_summaries(
|
||||
db, ['p-v2'], 'tenant-a', include_sample_payment_modes=True
|
||||
)
|
||||
summary = result['p-v2']
|
||||
assert summary['payMode'] == 'djfq'
|
||||
assert summary['contractAmount'] == 35680.0
|
||||
assert summary['receivedAmount'] == 19280.0
|
||||
assert summary['pendingAmount'] == 16400.0
|
||||
details = summary['paymentDetails']
|
||||
assert len(details) == 3
|
||||
first = [d for d in details if d['installmentNo'] == 1]
|
||||
assert sum(float(d['dueAmount'] or 0) for d in first) == 19280.0
|
||||
assert sum(float(d['transferAmount'] or 0) for d in first) == 19280.0
|
||||
# 未开启 include 时不补齐 V2 已收(质检费用摘要保持原语义)
|
||||
plain = build_protocol_payment_summaries(db, ['p-v2'], 'tenant-a')
|
||||
assert plain['p-v2'].get('receivedAmount') is None
|
||||
assert plain['p-v2'].get('paymentDetails') == []
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_protocol_list_fills_refund_amount_from_v2_refund_records():
|
||||
engine = create_engine('sqlite:///:memory:')
|
||||
Base.metadata.create_all(engine)
|
||||
with sessionmaker(bind=engine)() as db:
|
||||
db.add_all(
|
||||
[
|
||||
BizProtocolSamplePayment(
|
||||
id='sp-void',
|
||||
collection_protocol_id='p-void',
|
||||
sample_code='S9',
|
||||
pay_mode='dj',
|
||||
contract_amount=Decimal('5000'),
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolPaymentSchedule(
|
||||
id='sch-void',
|
||||
sample_payment_id='sp-void',
|
||||
collection_protocol_id='p-void',
|
||||
sample_code='S9',
|
||||
schedule_no=1,
|
||||
due_amount=Decimal('5000'),
|
||||
due_date='20260918',
|
||||
schedule_status='cleared',
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolPaymentTransfer(
|
||||
id='tr-void',
|
||||
schedule_id='sch-void',
|
||||
sample_payment_id='sp-void',
|
||||
collection_protocol_id='p-void',
|
||||
sample_code='S9',
|
||||
transfer_amount=Decimal('5000'),
|
||||
transfer_time=datetime(2026, 9, 18, 14, 55, 4),
|
||||
pay_status='yjf',
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
BizProtocolRefundRecord(
|
||||
id='rf-void',
|
||||
collection_protocol_id='p-void',
|
||||
refund_amount=Decimal('5000'),
|
||||
refund_time=datetime(2026, 9, 19, 10, 0, 0),
|
||||
tenant_no='tenant-a',
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
summary = build_protocol_payment_summaries(
|
||||
db, ['p-void'], 'tenant-a', include_sample_payment_modes=True
|
||||
)['p-void']
|
||||
assert summary['receivedAmount'] == 5000.0
|
||||
assert summary['refundAmount'] == 5000.0
|
||||
engine.dispose()
|
||||
@@ -8,8 +8,10 @@ from app.services.sample_storage_cert_qc import (
|
||||
SAMPLE_TYPE_KEY_PERINATAL_HSC,
|
||||
SAMPLE_TYPE_KEY_UC_MSC,
|
||||
empty_detail,
|
||||
merge_payload_with_acl,
|
||||
normalize_payload,
|
||||
resolve_sample_type_key,
|
||||
sanitize_payload_for_type,
|
||||
supports_storage_cert_qc,
|
||||
)
|
||||
from app.services.sample_storage_cert_qc_prefill import (
|
||||
@@ -51,6 +53,34 @@ class TestNormalizePayload(unittest.TestCase):
|
||||
self.assertNotIn(1, out)
|
||||
|
||||
|
||||
class TestSanitizePayloadForType(unittest.TestCase):
|
||||
def test_uc_msc_drops_mother_nucleic_acids(self):
|
||||
out = sanitize_payload_for_type(
|
||||
{
|
||||
"mother_hbsag": "阴性",
|
||||
"mother_hbv_dna": "阴性",
|
||||
"mother_hcv_rna": "阳性",
|
||||
"mother_hiv1_rna": "阴性",
|
||||
"mother_cmv_igm": "阴性",
|
||||
},
|
||||
SAMPLE_TYPE_KEY_UC_MSC,
|
||||
)
|
||||
self.assertEqual(out["mother_hbsag"], "阴性")
|
||||
self.assertEqual(out["mother_cmv_igm"], "阴性")
|
||||
self.assertNotIn("mother_hbv_dna", out)
|
||||
self.assertNotIn("mother_hcv_rna", out)
|
||||
self.assertNotIn("mother_hiv1_rna", out)
|
||||
|
||||
def test_perinatal_keeps_mother_nucleic_acids(self):
|
||||
payload = {
|
||||
"mother_hbv_dna": "阴性",
|
||||
"mother_hcv_rna": "阴性",
|
||||
"mother_hiv1_rna": "阴性",
|
||||
}
|
||||
out = sanitize_payload_for_type(payload, SAMPLE_TYPE_KEY_PERINATAL_HSC)
|
||||
self.assertEqual(out, payload)
|
||||
|
||||
|
||||
class TestEmptyDetail(unittest.TestCase):
|
||||
def test_shape(self):
|
||||
d = empty_detail(
|
||||
@@ -302,13 +332,13 @@ class TestStorageCertPrefill(unittest.TestCase):
|
||||
]
|
||||
suggested = build_suggested(sample_type_key="uc_msc", shared_mother_tasks=shared)
|
||||
self.assertEqual(suggested["mother_hbsag"], "阴性")
|
||||
self.assertEqual(suggested["mother_hbv_dna"], "阴性")
|
||||
self.assertNotIn("mother_hbv_dna", suggested)
|
||||
self.assertEqual(suggested["mother_anti_hcv"], "阴性")
|
||||
self.assertEqual(suggested["mother_hcv_rna"], "阴性")
|
||||
self.assertNotIn("mother_hcv_rna", suggested)
|
||||
self.assertEqual(suggested["mother_anti_tp"], "阴性")
|
||||
self.assertEqual(suggested["mother_cmv_igm"], "阴性")
|
||||
self.assertEqual(suggested["mother_anti_hiv"], "阴性")
|
||||
self.assertEqual(suggested["mother_hiv1_rna"], "阴性")
|
||||
self.assertNotIn("mother_hiv1_rna", suggested)
|
||||
self.assertNotIn("hbsag", suggested)
|
||||
|
||||
def test_own_mother_blood_wins_and_shared_only_fills_gaps(self):
|
||||
@@ -339,5 +369,35 @@ class TestStorageCertPrefill(unittest.TestCase):
|
||||
self.assertNotIn("hbsag", merged)
|
||||
|
||||
|
||||
class TestMergePayloadWithAcl(unittest.TestCase):
|
||||
def test_merges_only_editable_keys(self):
|
||||
merged = merge_payload_with_acl(
|
||||
existing={"receive_time": "a", "bacteria_test": "阴性"},
|
||||
incoming={"receive_time": "b", "bacteria_test": "阴性"},
|
||||
editable_keys=["receive_time"],
|
||||
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
|
||||
)
|
||||
self.assertEqual(merged["receive_time"], "b")
|
||||
self.assertEqual(merged["bacteria_test"], "阴性")
|
||||
|
||||
def test_rejects_unauthorized_change(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
merge_payload_with_acl(
|
||||
existing={"bacteria_test": "阴性"},
|
||||
incoming={"bacteria_test": "阳性"},
|
||||
editable_keys=["receive_time"],
|
||||
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
|
||||
)
|
||||
|
||||
def test_rejects_empty_editable_keys(self):
|
||||
with self.assertRaises(PermissionError):
|
||||
merge_payload_with_acl(
|
||||
existing={},
|
||||
incoming={"receive_time": "x"},
|
||||
editable_keys=[],
|
||||
sample_type_key=SAMPLE_TYPE_KEY_PERINATAL_HSC,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,35 @@
|
||||
"""储存证质检字段 ACL:合法字段与 scope。"""
|
||||
import unittest
|
||||
|
||||
from app.services.sample_storage_cert_qc import (
|
||||
ACL_SCOPE_PRODUCTION,
|
||||
ACL_SCOPE_QC,
|
||||
ALL_STORAGE_CERT_QC_FIELD_KEYS,
|
||||
SAMPLE_TYPE_KEY_UC_MSC,
|
||||
dumps_field_keys,
|
||||
legal_field_keys_for_type,
|
||||
loads_field_keys,
|
||||
)
|
||||
|
||||
|
||||
class TestAclFieldKeys(unittest.TestCase):
|
||||
def test_uc_msc_excludes_mother_nucleic_acids(self):
|
||||
keys = legal_field_keys_for_type(SAMPLE_TYPE_KEY_UC_MSC)
|
||||
self.assertNotIn("mother_hbv_dna", keys)
|
||||
self.assertIn("mother_hbsag", keys)
|
||||
|
||||
def test_roundtrip_field_keys(self):
|
||||
raw = dumps_field_keys(["receive_time", "bacteria_test", "receive_time", ""])
|
||||
self.assertEqual(loads_field_keys(raw), ["bacteria_test", "receive_time"])
|
||||
|
||||
def test_all_keys_cover_common_fields(self):
|
||||
self.assertIn("receive_time", ALL_STORAGE_CERT_QC_FIELD_KEYS)
|
||||
self.assertIn("mother_anti_hiv", ALL_STORAGE_CERT_QC_FIELD_KEYS)
|
||||
|
||||
def test_scopes(self):
|
||||
self.assertEqual(ACL_SCOPE_QC, "qc")
|
||||
self.assertEqual(ACL_SCOPE_PRODUCTION, "production")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+31
-17
@@ -54,12 +54,19 @@ Agent 会核对工程仓库、集成主分支、应用目录、测试入口、
|
||||
|
||||
## 确认问题会有语音提醒
|
||||
|
||||
Agent 发出需要你答复的问题后,默认在本机播放一次“有问题待确认,请及时处理”。语音已预先生成并随包安装,后续只播放本地文件;一批多个问题提醒一次,等待期间不循环播放。
|
||||
Agent 需要你做决定时,会在当前回复里直接写出「待你确认」,逐题列出问题、推荐、理由,以及 60 秒未回复会怎样。请看这段文字作答。随后默认在本机播放一次“有问题待确认,请及时处理”,提醒你去看这段清单。语音已预先生成并随包安装,后续只播放本地文件,不会念出题目;一批多个问题提醒一次,等待期间不循环播放。没有「待你确认」时不应播放。
|
||||
|
||||
“必须等我回复”的问题也会提醒,但仍保持等待。可以说“本次任务静音”或“本工程关闭语音提醒”,恢复时说“恢复语音提醒”;声音开关和一分钟默认选择互相独立,升级保留工程自己的选择。播放失败不影响文字提问,也不会延长或跳过 60 秒计时。
|
||||
|
||||
完整安装提供 `notify-question` 命令,由 Agent 在提问后调用,不是宿主自动弹窗钩子。当前支持用户 macOS 本机;其他系统或远程执行缺少本地播放条件时回退文字提醒,不代表你已听到。安装、更新、普通进度和结束答复不播放。细则见安装目录 `references/question-notification.md`。
|
||||
|
||||
### 在 Cursor 里听到提示音却看不到题时
|
||||
|
||||
- 提示音在整机播放,**不会自动跳到对应聊天**;请到正在跑 Agent 的会话里搜索「待你确认」。
|
||||
- 输入框上方的 **Queued / Start Multitasking** 是你排队的后续消息,**不是**确认清单。
|
||||
- Cursor 会先跑工具再刷出最终正文,声音可能比清单早响一两秒;清单应出现在该轮助手正文开头。多会话并行时,切换其他进行中会话再搜一次。
|
||||
- 工程内 Agent 适配约定见 [Cursor 确认可见性](cursor-question-visibility.md)。
|
||||
|
||||
## 日常开发怎么提需求
|
||||
|
||||
| 场景 | 可以直接发送的示例 |
|
||||
@@ -74,7 +81,11 @@ Agent 发出需要你答复的问题后,默认在本机播放一次“有问
|
||||
|
||||
新页面、新弹窗和视觉改版默认先做 Figma 原型。确实无需原型时明确说“本次不用生成 Figma 原型”;仍按已有界面规范实现。需要等待你亲自确认时说“这个决定必须等我回复”。
|
||||
|
||||
任务通常依次经过:原子领取并读取工单 → 确认范围/原型 → 任务分支与 worktree 实现 → 推送任务分支并创建/复用 PR → 测试与审查 → 合并候选检查及本机集成环境冒烟 → 按授权合并 PR → 恢复核对本机环境并关单。worktree 是独立工作目录,里面有对应任务分支;它不是集成主分支,不能只刷新原来的页面就认定正在测试修复版。
|
||||
已有工程默认沿用当前界面风格,无需每张工单重复提醒。Agent 应在设计前核对同端同类页面、实际可查看的运行效果、组件和主题样式,简要说明参考与复用选择;生成原型和最终实现都要与参考对照。只做 Figma、采用其他设计工具或免除原型,也保留这项要求。无法查看运行界面时说明证据缺口,不把通用模板当成现行风格。步骤见安装目录 `references/ui-design.md`。
|
||||
|
||||
需要指定参考时可以说:“新增这个页面,沿用当前订单列表和编辑弹窗的风格;先查看现有界面及组件,再做设计,说明复用项与差异,不改全局主题。” 明确要改版时说明端、模块和范围,已有决定直接沿用。
|
||||
|
||||
任务通常依次经过:原子领取并读取工单 → 确认范围/原型 → 任务分支与 worktree 实现 → 推送任务分支并创建/复用 PR → 受影响代码的单元测试与审查 → 同一时刻只合入一个 worktree → 按授权合并 PR → 回写并关单。构建、启动和关键路径由你在合入后的集成分支上自行验收。worktree 是独立工作目录,里面有对应任务分支;它不是集成主分支。
|
||||
|
||||
## 仅允许本会话处理一张工单
|
||||
|
||||
@@ -90,42 +101,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`。这些命令不自动测试、关单或后台续跑。
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Cursor 宿主:确认问题可见性
|
||||
|
||||
个人开发体系要求:先在用户可见回复写出「待你确认」,再播放本机提示音。Cursor Agent 的执行顺序与此不完全同构,本文件是工程内的 Cursor 适配约定;与安装目录 `references/planning.md`、`references/question-notification.md` 并用,冲突时以「用户必须先看见清单」为准。
|
||||
|
||||
## Cursor 执行顺序
|
||||
|
||||
1. 同轮内的**工具调用先执行**,最终助手正文后出现。
|
||||
2. `notify-question`(`afplay`)若作为工具调用,提示音可能比「待你确认」正文早数秒响起。
|
||||
3. 提示音在**整机**播放,不绑定当前聚焦的聊天标签;多会话并行时,声音来源会话可能不是眼前这个。
|
||||
4. 输入框上方的 **Queued / Start Multitasking** 是用户排队的后续消息,**不是** Agent 确认清单。
|
||||
|
||||
## Agent 必须遵守
|
||||
|
||||
发问当轮:
|
||||
|
||||
1. **先写清单**:该轮最终用户可见回复必须以「待你确认」开头(或紧接一句定位后立刻出现该标题),按问题展示规则写全题、推荐、理由与未回复处理。
|
||||
2. **同轮停手**:写出清单并(如需)播放提示音后,本轮不得再调用编辑、浏览器、继续实现等工具;依赖答复的工作等下一轮。允许的独立只读工作若与本题无关,应在发问**之前**做完,或放到用户答复之后。
|
||||
3. **提示音时机**:需要播放时,`notify-question` 必须是本轮**最后一次**工具调用,且本轮最终正文就是确认清单。禁止在仍计划继续工具批处理时提前播放。
|
||||
4. **正文定位**:清单首段或标题下用一句说明「请在本会话回复;提示音不念题目,底部 Queued 不是确认入口。」
|
||||
5. **无清单不播放**:最终正文没有「待你确认」时,不得调用 `notify-question`。
|
||||
6. **原生卡片**:若当前 Cursor 会话提供宿主提问/确认卡片,可与清单并用,卡片内容须与「待你确认」一致;不得只用卡片、省略正文清单。
|
||||
|
||||
## 用户侧核对
|
||||
|
||||
听到「有问题待确认」时:
|
||||
|
||||
1. 看**正在或刚结束工具步骤的那个聊天**(不必先看 Queued)。
|
||||
2. 在该会话搜索或上翻 **「待你确认」**;清单在工具时间线收折块之外的助手正文里。
|
||||
3. 若多聊天并行,切换到其他进行中会话再搜同一标题。
|
||||
|
||||
## 静音与一分钟策略
|
||||
|
||||
声音开关、一分钟默认选择仍按安装目录规则;本文件不改变其语义,只约束 Cursor 下的展示与播音顺序。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
-- Issue #24:协议单操作过程日志(before / action / after)
|
||||
-- MySQL 5.7;可重复执行(CREATE IF NOT EXISTS)。上线前须备份并在目标副本验证。
|
||||
-- DDL 隐式提交;该脚本不作为自动应用启动步骤。
|
||||
--
|
||||
-- 注意:若目标库已存在同名「采集协议单操作日志」旧表(含 collection_protocol_id /
|
||||
-- operation_code 等列),本脚本会因 IF NOT EXISTS 直接跳过,不会纠偏。
|
||||
-- 生产纠偏请改用:2026-11-09-protocol-operation-log-rebuild.sql
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_protocol_operation_log (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
-- =============================================================================
|
||||
-- 协议操作日志:兼容生产旧表同名冲突,重建为 Issue #24 模型
|
||||
-- MySQL 5.7;可重复执行。上线前须备份,并在目标副本验证。
|
||||
--
|
||||
-- 背景:
|
||||
-- 生产曾有「采集协议单操作日志」同名表(collection_protocol_id / operation_*),
|
||||
-- 与当前代码(biz_type / before_json / after_json)不兼容。
|
||||
-- 旧脚本 2026-11-05-protocol-operation-log.sql 使用 CREATE IF NOT EXISTS,
|
||||
-- 遇到旧表会直接跳过,无法纠偏。本脚本用于生产纠偏与新环境兜底。
|
||||
--
|
||||
-- 行为:
|
||||
-- 1) 已是新模型(有 biz_type)→ 跳过结构变更
|
||||
-- 2) 旧模型且 0 行 → DROP 后按新模型 CREATE
|
||||
-- 3) 旧模型且有数据 → RENAME 为 biz_protocol_operation_log_legacy_20261109 再 CREATE
|
||||
-- 4) 表不存在 → 直接 CREATE
|
||||
-- =============================================================================
|
||||
|
||||
SET @db := DATABASE();
|
||||
|
||||
SET @table_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = @db AND table_name = 'biz_protocol_operation_log'
|
||||
);
|
||||
|
||||
SET @has_legacy_col := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @db
|
||||
AND table_name = 'biz_protocol_operation_log'
|
||||
AND column_name = 'collection_protocol_id'
|
||||
);
|
||||
|
||||
SET @has_new_col := (
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = @db
|
||||
AND table_name = 'biz_protocol_operation_log'
|
||||
AND column_name = 'biz_type'
|
||||
);
|
||||
|
||||
SET @is_legacy := IF(@table_exists > 0 AND @has_legacy_col > 0 AND @has_new_col = 0, 1, 0);
|
||||
|
||||
SET @legacy_rows := 0;
|
||||
SET @cnt_sql := IF(
|
||||
@is_legacy = 1,
|
||||
'SET @legacy_rows = (SELECT COUNT(*) FROM `biz_protocol_operation_log`)',
|
||||
'SET @legacy_rows = 0'
|
||||
);
|
||||
PREPARE pol_stmt FROM @cnt_sql;
|
||||
EXECUTE pol_stmt;
|
||||
DEALLOCATE PREPARE pol_stmt;
|
||||
|
||||
-- 空旧表:直接删除
|
||||
SET @drop_sql := IF(
|
||||
@is_legacy = 1 AND @legacy_rows = 0,
|
||||
'DROP TABLE `biz_protocol_operation_log`',
|
||||
'SELECT ''skip drop empty legacy biz_protocol_operation_log'' AS migration_status'
|
||||
);
|
||||
PREPARE pol_stmt FROM @drop_sql;
|
||||
EXECUTE pol_stmt;
|
||||
DEALLOCATE PREPARE pol_stmt;
|
||||
|
||||
-- 有数据的旧表:改名保留,避免丢历史(当前生产为 0 行,通常不会走到这里)
|
||||
SET @legacy_backup_exists := (
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = @db AND table_name = 'biz_protocol_operation_log_legacy_20261109'
|
||||
);
|
||||
SET @rename_sql := IF(
|
||||
@is_legacy = 1 AND @legacy_rows > 0 AND @legacy_backup_exists = 0,
|
||||
'RENAME TABLE `biz_protocol_operation_log` TO `biz_protocol_operation_log_legacy_20261109`',
|
||||
IF(
|
||||
@is_legacy = 1 AND @legacy_rows > 0 AND @legacy_backup_exists > 0,
|
||||
'SELECT ''ERROR: legacy backup biz_protocol_operation_log_legacy_20261109 already exists; resolve manually'' AS migration_status',
|
||||
'SELECT ''skip rename legacy biz_protocol_operation_log'' AS migration_status'
|
||||
)
|
||||
);
|
||||
PREPARE pol_stmt FROM @rename_sql;
|
||||
EXECUTE pol_stmt;
|
||||
DEALLOCATE PREPARE pol_stmt;
|
||||
|
||||
-- 新模型建表(纠偏后或全新环境)
|
||||
CREATE TABLE IF NOT EXISTS `biz_protocol_operation_log` (
|
||||
`id` VARCHAR(64) NOT NULL COMMENT '主键ID',
|
||||
`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 COMMENT '租户编号',
|
||||
`order_num` INT NULL COMMENT '排序号',
|
||||
`remark` VARCHAR(512) NULL COMMENT '备注',
|
||||
`create_by` VARCHAR(64) NULL COMMENT '创建人id',
|
||||
`create_dept_id` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_by` VARCHAR(64) NULL COMMENT '更新人id',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`del_flag` SMALLINT NOT NULL DEFAULT 0 COMMENT '删除标记',
|
||||
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 COLLATE=utf8mb4_unicode_ci COMMENT='协议单操作过程日志';
|
||||
|
||||
-- 验收:新模型关键列应存在
|
||||
SELECT
|
||||
COLUMN_NAME,
|
||||
COLUMN_TYPE,
|
||||
IS_NULLABLE,
|
||||
COLUMN_COMMENT
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'biz_protocol_operation_log'
|
||||
AND COLUMN_NAME IN (
|
||||
'biz_type', 'biz_id', 'action', 'before_json', 'after_json',
|
||||
'client_channel', 'operate_time', 'collection_protocol_id', 'operation_code'
|
||||
)
|
||||
ORDER BY ORDINAL_POSITION;
|
||||
@@ -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,65 @@
|
||||
-- Issue #93:储存证质检信息字段级 ACL(质检/生产)。MySQL 5.7,可重复执行。
|
||||
-- 不自动授予任何角色;管理员在角色管理中勾选「储存证质检字段权限配置」。
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
scope VARCHAR(32) NOT NULL COMMENT 'qc|production',
|
||||
field_keys TEXT NOT NULL COMMENT '可编辑字段 key JSON 数组',
|
||||
configured_flag CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否已正式配置 0否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_scope (tenant_no, scope, del_flag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS biz_storage_cert_qc_acl_user (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
acl_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_acl (tenant_no, acl_id, del_flag),
|
||||
KEY ix_tenant_user (tenant_no, user_id, del_flag)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
INSERT INTO sys_menu (
|
||||
id, menu_name, parent_id, menu_type, path, component, perms,
|
||||
is_frame, visible, status, tenant_no, order_num, remark,
|
||||
create_by, create_dept_id, create_time, update_by, update_time, del_flag
|
||||
)
|
||||
SELECT
|
||||
INSERT(INSERT(MD5(CONCAT('storage-cert-qc-acl:', parent.tenant_no, ':', parent.id)),
|
||||
13, 1, '3'), 17, 1, '8'),
|
||||
'储存证质检字段权限配置', parent.id, 'F', '', NULL,
|
||||
'cell-storage:sample-info:storage-cert-qc-acl', 1, '1', '1', parent.tenant_no, 95,
|
||||
'控制储存证质检弹窗中质检/生产字段与人员 ACL 配置入口',
|
||||
'migration', '', NOW(), 'migration', NOW(), 0
|
||||
FROM sys_menu AS parent
|
||||
WHERE parent.del_flag = 0 AND parent.status = '1'
|
||||
AND parent.perms = 'cell-storage:sample-info:list'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sys_menu AS existing
|
||||
WHERE existing.parent_id = parent.id
|
||||
AND existing.tenant_no = parent.tenant_no
|
||||
AND existing.perms = 'cell-storage:sample-info:storage-cert-qc-acl'
|
||||
AND existing.del_flag = 0
|
||||
);
|
||||
|
||||
SELECT tenant_no, parent_id, id, menu_name, perms, status
|
||||
FROM sys_menu
|
||||
WHERE perms = 'cell-storage:sample-info:storage-cert-qc-acl' AND del_flag = 0;
|
||||
@@ -49,11 +49,11 @@
|
||||
/* 快应用特有相关 */
|
||||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
/* 当前:生产 AppID;切测试时把 appid 改为下方测试值 */
|
||||
/* 当前:测试 AppID;切生产时把 appid 改为下方生产值 */
|
||||
/* 生产 AppID:wx2b139581f4c855e4 */
|
||||
/* 测试 AppID:wxa261ad85f2c99b26 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx2b139581f4c855e4",
|
||||
"appid" : "wxa261ad85f2c99b26",
|
||||
"setting" : {
|
||||
"urlCheck" : false,
|
||||
"es6" : true
|
||||
|
||||
@@ -95,6 +95,14 @@
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/oa/file-preview/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "附件预览",
|
||||
"navigationBarBackgroundColor": "#ecfdf5",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/market/webview/index",
|
||||
"style": {
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<view class="file-preview">
|
||||
<view class="file-preview__tip">{{ tip }}</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { API_BASE_URL, MINIO_BASE_URL } from '../../../utils/config.js'
|
||||
import { getToken } from '../../../utils/auth.js'
|
||||
import {
|
||||
buildDirectMinioUrl,
|
||||
pdfPreviewLog,
|
||||
toAbsoluteDownloadUrl
|
||||
} from '../../../utils/oa-pdf-preview-query.js'
|
||||
import { parseFilePreviewQuery } from '../../../utils/oa-file-preview.js'
|
||||
|
||||
function absolutize(url) {
|
||||
const abs = toAbsoluteDownloadUrl(url, {
|
||||
apiBaseUrl: API_BASE_URL,
|
||||
minioBaseUrl: MINIO_BASE_URL
|
||||
})
|
||||
if (String(url || '').trim() && abs && abs !== url) {
|
||||
pdfPreviewLog('file-preview absolutize', {
|
||||
from: String(url).slice(0, 120),
|
||||
to: abs.slice(0, 160)
|
||||
})
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
function directMinio(fileUrl) {
|
||||
return buildDirectMinioUrl(fileUrl, {
|
||||
apiBaseUrl: API_BASE_URL,
|
||||
minioBaseUrl: MINIO_BASE_URL
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
tip: '正在打开附件…',
|
||||
_opened: false
|
||||
}
|
||||
},
|
||||
onLoad(query) {
|
||||
pdfPreviewLog('file-preview onLoad raw query', query)
|
||||
this.openFromQuery(query || {})
|
||||
},
|
||||
methods: {
|
||||
async openFromQuery(query) {
|
||||
const parsed = parseFilePreviewQuery(query)
|
||||
const { fileUrl, legacyUrl, name, fileType, rawKeys } = parsed
|
||||
pdfPreviewLog('file-preview parsed query', {
|
||||
rawKeys,
|
||||
name,
|
||||
fileType,
|
||||
fileUrlLen: fileUrl.length,
|
||||
fileUrlPreview: fileUrl.slice(0, 160),
|
||||
legacyUrlLen: legacyUrl.length,
|
||||
legacyUrlPreview: legacyUrl.slice(0, 120),
|
||||
hasToken: !!getToken(),
|
||||
minioBase: String(MINIO_BASE_URL || '').slice(0, 80)
|
||||
})
|
||||
|
||||
// 与 PDF 预览一致:优先 MinIO 公网直链;预签名经 /minio 改写后真机常 403。
|
||||
let url = ''
|
||||
let fallbackUrl = ''
|
||||
let source = ''
|
||||
|
||||
if (legacyUrl) {
|
||||
const absLegacy = absolutize(legacyUrl)
|
||||
const direct = directMinio(fileUrl || legacyUrl)
|
||||
if (direct && /^https?:\/\//i.test(direct)) {
|
||||
url = direct
|
||||
fallbackUrl = absLegacy && absLegacy !== direct ? absLegacy : ''
|
||||
source = 'minio-direct(+legacy-fallback)'
|
||||
} else {
|
||||
url = absLegacy
|
||||
source = 'query.url'
|
||||
}
|
||||
} else if (fileUrl) {
|
||||
url = directMinio(fileUrl)
|
||||
source = 'minio-direct'
|
||||
pdfPreviewLog('file-preview minio-direct', {
|
||||
urlPreview: String(url || '').slice(0, 160)
|
||||
})
|
||||
}
|
||||
|
||||
url = absolutize(url) || url
|
||||
fallbackUrl = absolutize(fallbackUrl) || fallbackUrl
|
||||
|
||||
if (!url || !/^https?:\/\//i.test(url)) {
|
||||
const reason = !fileUrl && !legacyUrl
|
||||
? '缺少 fileUrl/url 参数(请确认小程序与 oa-h5 已同步部署)'
|
||||
: '无法解析为 http(s) 下载地址'
|
||||
pdfPreviewLog('file-preview invalid url', { reason, fileUrl, legacyUrl, url })
|
||||
this.tip = '文件地址无效'
|
||||
uni.showToast({ title: '文件地址无效', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
pdfPreviewLog('file-preview open', {
|
||||
source,
|
||||
name,
|
||||
fileType,
|
||||
urlPreview: url.slice(0, 160),
|
||||
hasFallback: !!fallbackUrl
|
||||
})
|
||||
this.openFile(url, name, fileType, fallbackUrl)
|
||||
},
|
||||
openFile(url, name, fileType, fallbackUrl) {
|
||||
if (this._opened) return
|
||||
this._opened = true
|
||||
this._tryDownload(url, name, fileType, fallbackUrl, false)
|
||||
},
|
||||
_tryDownload(url, name, fileType, fallbackUrl, isRetry) {
|
||||
pdfPreviewLog('file-preview downloadFile start', {
|
||||
name,
|
||||
fileType,
|
||||
isRetry: !!isRetry,
|
||||
urlPreview: String(url || '').slice(0, 160)
|
||||
})
|
||||
uni.downloadFile({
|
||||
url,
|
||||
success: (res) => {
|
||||
const code = res && res.statusCode
|
||||
pdfPreviewLog('file-preview downloadFile success', {
|
||||
statusCode: code,
|
||||
hasTemp: !!(res && res.tempFilePath),
|
||||
isRetry: !!isRetry
|
||||
})
|
||||
if (code === 403 && fallbackUrl && !isRetry) {
|
||||
pdfPreviewLog('file-preview 403 → retry fallback', {
|
||||
fallbackPreview: String(fallbackUrl).slice(0, 160)
|
||||
})
|
||||
this._tryDownload(fallbackUrl, name, fileType, '', true)
|
||||
return
|
||||
}
|
||||
if (!res || code !== 200 || !res.tempFilePath) {
|
||||
this.tip = '下载失败' + (code ? '(' + code + ')' : '')
|
||||
uni.showToast({
|
||||
title: '下载失败' + (code ? ' ' + code : ''),
|
||||
icon: 'none'
|
||||
})
|
||||
this._opened = false
|
||||
return
|
||||
}
|
||||
const openOpts = {
|
||||
filePath: res.tempFilePath,
|
||||
showMenu: true,
|
||||
success: () => {
|
||||
pdfPreviewLog('file-preview openDocument success')
|
||||
this.tip = '已打开预览'
|
||||
setTimeout(() => {
|
||||
uni.navigateBack({ fail: () => {} })
|
||||
}, 300)
|
||||
},
|
||||
fail: (err) => {
|
||||
pdfPreviewLog('file-preview openDocument fail', err)
|
||||
this.tip = '无法打开附件,请通过右上角菜单转发或用其他应用打开'
|
||||
uni.showToast({ title: '无法打开附件', icon: 'none' })
|
||||
this._opened = false
|
||||
}
|
||||
}
|
||||
if (fileType) openOpts.fileType = fileType
|
||||
uni.openDocument(openOpts)
|
||||
},
|
||||
fail: (err) => {
|
||||
pdfPreviewLog('file-preview downloadFile fail', err)
|
||||
if (fallbackUrl && !isRetry) {
|
||||
pdfPreviewLog('file-preview fail → retry fallback')
|
||||
this._tryDownload(fallbackUrl, name, fileType, '', true)
|
||||
return
|
||||
}
|
||||
this.tip = '下载失败'
|
||||
uni.showToast({ title: '下载失败', icon: 'none' })
|
||||
this._opened = false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-preview {
|
||||
min-height: 100vh;
|
||||
padding: 48rpx 32rpx;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
.file-preview__tip {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* OA / 市场附件:小程序宿主预览页的 query 与 openDocument 类型
|
||||
*/
|
||||
|
||||
import {
|
||||
pickQueryValue,
|
||||
safeDecodeURIComponent
|
||||
} from './oa-pdf-preview-query.js'
|
||||
|
||||
const OPEN_DOCUMENT_EXTS = {
|
||||
pdf: 'pdf',
|
||||
doc: 'doc',
|
||||
docx: 'docx',
|
||||
xls: 'xls',
|
||||
xlsx: 'xlsx',
|
||||
ppt: 'ppt',
|
||||
pptx: 'pptx'
|
||||
}
|
||||
|
||||
export function fileExtension(fileName) {
|
||||
const match = String(fileName || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.match(/\.([a-z0-9]+)$/)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
export function openDocumentFileType(fileName) {
|
||||
const ext = fileExtension(fileName)
|
||||
return OPEN_DOCUMENT_EXTS[ext] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ fileUrl: string, legacyUrl: string, name: string, fileType: string, rawKeys: string[] }}
|
||||
*/
|
||||
export function parseFilePreviewQuery(query) {
|
||||
const rawKeys = Object.keys(query && typeof query === 'object' ? query : {})
|
||||
const fileUrl = safeDecodeURIComponent(
|
||||
pickQueryValue(query, ['fileUrl', 'fileurl', 'file_url', 'f'])
|
||||
)
|
||||
const legacyUrl = safeDecodeURIComponent(
|
||||
pickQueryValue(query, ['url', 'downloadUrl', 'download_url'])
|
||||
)
|
||||
const name =
|
||||
safeDecodeURIComponent(pickQueryValue(query, ['name', 'fileName', 'file_name', 'n'])) ||
|
||||
'file.bin'
|
||||
const fileType =
|
||||
safeDecodeURIComponent(pickQueryValue(query, ['fileType', 'file_type', 't'])) ||
|
||||
openDocumentFileType(name)
|
||||
return { fileUrl, legacyUrl, name, fileType, rawKeys }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
openDocumentFileType,
|
||||
parseFilePreviewQuery
|
||||
} from './oa-file-preview.js'
|
||||
|
||||
assert.equal(openDocumentFileType('a.xlsx'), 'xlsx')
|
||||
assert.equal(openDocumentFileType('a.zip'), '')
|
||||
|
||||
const parsed = parseFilePreviewQuery({
|
||||
fileUrl: encodeURIComponent('oa-approval/202610/abc.xlsx'),
|
||||
n: 'file.xlsx'
|
||||
})
|
||||
assert.equal(parsed.fileUrl, 'oa-approval/202610/abc.xlsx')
|
||||
assert.equal(parsed.name, 'file.xlsx')
|
||||
assert.equal(parsed.fileType, 'xlsx')
|
||||
|
||||
const fallback = parseFilePreviewQuery({
|
||||
f: 'oa-approval/x.pdf'
|
||||
})
|
||||
assert.equal(fallback.fileUrl, 'oa-approval/x.pdf')
|
||||
assert.equal(fallback.name, 'file.bin')
|
||||
assert.equal(fallback.fileType, '')
|
||||
|
||||
console.log('oa-file-preview: ok')
|
||||
@@ -0,0 +1,110 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
import * as Vue from 'vue'
|
||||
import { compile } from '@vue/compiler-dom'
|
||||
import { projectValidation } from './project-assignment.js'
|
||||
|
||||
const source = readFileSync(new URL('../views/market/MarketModuleForm.vue', import.meta.url), 'utf8')
|
||||
// Render the real loop/slot/ref structure; native host nodes need no browser or service.
|
||||
const template = source.slice(source.indexOf('<template v-for="section in leadingSections"'), source.indexOf('\n\n <section'))
|
||||
const render = new Function('Vue', compile(template, { prefixIdentifiers: true }).code)(Vue)
|
||||
const submitSource = source.slice(source.indexOf('async function submit()'), source.indexOf('\nasync function confirmLeave()'))
|
||||
const renderer = Vue.createRenderer({
|
||||
createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
|
||||
insert(node, parent, anchor = null) {
|
||||
parent.children ||= []
|
||||
node.parent = parent
|
||||
const index = parent.children.indexOf(anchor)
|
||||
parent.children.splice(index < 0 ? parent.children.length : index, 0, node)
|
||||
},
|
||||
remove(node) {
|
||||
const siblings = node.parent?.children || []
|
||||
const index = siblings.indexOf(node)
|
||||
if (index >= 0) siblings.splice(index, 1)
|
||||
},
|
||||
setText() {}, setElementText() {}, patchProp() {},
|
||||
parentNode: (node) => node.parent,
|
||||
nextSibling: (node) => {
|
||||
const siblings = node.parent?.children || []
|
||||
return siblings[siblings.indexOf(node) + 1] || null
|
||||
}
|
||||
})
|
||||
|
||||
async function mountForm({ role, mode = 'create', projectId = 'project-1', loading = false, error = '' }) {
|
||||
const projectAssignment = Vue.ref(null)
|
||||
const formData = Vue.ref({ source: 'research_donation', projectId })
|
||||
const messages = [], requests = []
|
||||
let validations = 0
|
||||
const ProjectAssignmentField = {
|
||||
setup(_, { expose }) {
|
||||
expose({ validate() {
|
||||
validations++
|
||||
return projectValidation({ editing: mode === 'edit', required: true, projectId, loading, error })
|
||||
} })
|
||||
return () => Vue.h('div')
|
||||
}
|
||||
}
|
||||
const Section = { setup: (_, { slots }) => () => slots.insert?.() }
|
||||
const context = Vue.ref({ module: 'protocol', marketRole: role })
|
||||
const app = renderer.createApp({
|
||||
components: { MarketFormSection: Section, ProjectAssignmentField },
|
||||
setup: () => ({
|
||||
projectAssignment, context, formData, mode,
|
||||
leadingSections: [{ key: 'customer' }, { key: 'protocol' }],
|
||||
formOptions: {}, fieldsLocked: false, dirty: false,
|
||||
sectionValue: () => ({}), searchProtocolCustomers() {}, setSectionValue() {},
|
||||
uploadField() {}, handleFieldChange() {}, handleCustomerSelect() {}
|
||||
}), render
|
||||
})
|
||||
app.mount({})
|
||||
await Vue.nextTick()
|
||||
const scope = {
|
||||
projectAssignment, context, formData, mode: Vue.ref(mode),
|
||||
submitting: Vue.ref(false), uploadPending: Vue.ref(0), isProtocolV2: Vue.ref(false),
|
||||
isProtocolV1: Vue.ref(false), id: Vue.ref(mode === 'edit' ? 'record-1' : ''),
|
||||
action: Vue.ref(mode), visibleSections: Vue.ref([]), dirty: Vue.ref(true),
|
||||
showToast: (text) => messages.push(text), showSuccessToast: (text) => messages.push(text),
|
||||
validateRequiredFields: () => '', buildMarketActionPayload: (_module, _action, data) => data,
|
||||
sanitizeMarketPayload: (data) => data, buildPayload: () => formData.value,
|
||||
AbortController, submitController: null, allowLeave: false,
|
||||
saveMarketRecord: async (...args) => { requests.push(args); return { id: 'saved' } },
|
||||
mountGuard: { isCurrent: () => true }, mountToken: 1,
|
||||
resolveMarketSavedDetailId: () => 'saved', router: { replace: async () => {} },
|
||||
route: { query: {} }, marketRecordNavigationQuery: () => ({}),
|
||||
marketErrorMessage: (cause) => cause.message
|
||||
}
|
||||
const submit = vm.runInNewContext(`${submitSource}\nsubmit`, scope)
|
||||
return { submit, messages, requests, app, validations: () => validations }
|
||||
}
|
||||
|
||||
for (const role of ['specialist', 'manager']) {
|
||||
for (const mode of ['create', 'edit']) {
|
||||
test(`${role} ${mode}: a valid project reaches the save request`, async () => {
|
||||
const form = await mountForm({ role, mode })
|
||||
try {
|
||||
await form.submit()
|
||||
assert.equal(form.validations(), 1)
|
||||
assert.equal(form.requests.length, 1)
|
||||
assert.equal(form.requests[0][0].marketRole, role)
|
||||
assert.deepEqual(form.messages, ['保存成功'])
|
||||
} finally { form.app.unmount() }
|
||||
})
|
||||
}
|
||||
for (const [state, message] of [
|
||||
[{ projectId: '' }, '请选择所属项目'],
|
||||
[{ loading: true }, '项目选项加载中,请稍后保存'],
|
||||
[{ error: 'network' }, '项目选项加载失败,请重试后保存']
|
||||
]) {
|
||||
test(`${role}: ${message}`, async () => {
|
||||
const form = await mountForm({ role, ...state })
|
||||
try {
|
||||
await form.submit()
|
||||
assert.equal(form.validations(), 1)
|
||||
assert.equal(form.requests.length, 0)
|
||||
assert.deepEqual(form.messages, [message])
|
||||
} finally { form.app.unmount() }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
/** 附件下载:优先后端预签名 URL,保证跨域场景保留原始文件名 */
|
||||
/** 附件下载:小程序 WebView 走宿主原生页;其它环境用预签名 URL 打开 */
|
||||
|
||||
import { fetchAttachmentDownloadUrl } from '../api/oa.js'
|
||||
import { attachmentFullUrl } from './attachment.js'
|
||||
import {
|
||||
isWeChatMiniWebView,
|
||||
navigateToHostFilePreview
|
||||
} from './host-file-preview-nav.js'
|
||||
|
||||
function openDownloadUrl(href, fileName) {
|
||||
// 微信 web-view 对 a[download] 支持差;优先新开页打开带 Content-Disposition 的地址
|
||||
@@ -17,13 +21,19 @@ function openDownloadUrl(href, fileName) {
|
||||
document.body.removeChild(a)
|
||||
}
|
||||
|
||||
async function openViaHostPreview(fileUrl, fileName, deps = {}) {
|
||||
const navigate = deps.navigateToHostFilePreview || navigateToHostFilePreview
|
||||
const detectMini = deps.isWeChatMiniWebView || isWeChatMiniWebView
|
||||
if (!detectMini(deps.window)) return false
|
||||
const nav = await navigate({ fileUrl, fileName }, { window: deps.window })
|
||||
if (nav && nav.ok) return true
|
||||
throw new Error((nav && nav.message) || '无法打开附件预览页')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ fileUrl?: string, url?: string, fileName?: string, name?: string }} file
|
||||
*/
|
||||
export async function downloadAttachmentFile(
|
||||
file,
|
||||
options = {}
|
||||
) {
|
||||
export async function downloadAttachmentFile(file, options = {}) {
|
||||
const hasAuthorizedResolver = typeof options.fetchDownloadUrl === 'function'
|
||||
const fetchDownloadUrl = options.fetchDownloadUrl || fetchAttachmentDownloadUrl
|
||||
const fileUrl = String(file?.fileUrl || file?.url || '').trim()
|
||||
@@ -31,6 +41,10 @@ export async function downloadAttachmentFile(
|
||||
const explicitFileName = String(file?.fileName || file?.name || '').trim()
|
||||
const fallbackFileName = explicitFileName || fileUrl.split('/').pop() || 'download'
|
||||
|
||||
// 小程序 WebView(含鸿蒙):跳转宿主 downloadFile + openDocument,避免 window.open 静默失败
|
||||
const openedOnHost = await openViaHostPreview(fileUrl, fallbackFileName, options)
|
||||
if (openedOnHost) return
|
||||
|
||||
try {
|
||||
const res = await fetchDownloadUrl({
|
||||
fileUrl,
|
||||
|
||||
@@ -18,6 +18,7 @@ test('uses an injected authorized resolver before opening the signed download ur
|
||||
await downloadAttachmentFile(
|
||||
{ fileUrl: 'market/specialist/protocol/a.png', fileName: '协议扫描件.png' },
|
||||
{
|
||||
isWeChatMiniWebView: () => false,
|
||||
fetchDownloadUrl: async (payload) => {
|
||||
requested = payload
|
||||
return { downloadUrl: 'https://files.test/signed-download' }
|
||||
@@ -52,6 +53,7 @@ test('never falls back to the raw object url when an authorized resolver rejects
|
||||
downloadAttachmentFile(
|
||||
{ fileUrl: 'https://files.test/private.pdf', fileName: '私密附件.pdf' },
|
||||
{
|
||||
isWeChatMiniWebView: () => false,
|
||||
fetchDownloadUrl: async () => {
|
||||
throw new Error('附件不存在或无权下载')
|
||||
}
|
||||
@@ -65,3 +67,54 @@ test('never falls back to the raw object url when an authorized resolver rejects
|
||||
|
||||
assert.deepEqual(opened, [])
|
||||
})
|
||||
|
||||
test('mini program webview navigates to host file preview instead of window.open', async () => {
|
||||
const originalWindow = globalThis.window
|
||||
const opened = []
|
||||
const navCalls = []
|
||||
globalThis.window = {
|
||||
open(href) {
|
||||
opened.push(href)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadAttachmentFile(
|
||||
{ fileUrl: 'oa-approval/202610/task.xlsx', fileName: '生产任务.xlsx' },
|
||||
{
|
||||
isWeChatMiniWebView: () => true,
|
||||
navigateToHostFilePreview: async (payload) => {
|
||||
navCalls.push(payload)
|
||||
return { ok: true, via: 'test' }
|
||||
},
|
||||
fetchDownloadUrl: async () => {
|
||||
throw new Error('should not fetch download-url in mini path')
|
||||
}
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
globalThis.window = originalWindow
|
||||
}
|
||||
|
||||
assert.deepEqual(navCalls, [
|
||||
{ fileUrl: 'oa-approval/202610/task.xlsx', fileName: '生产任务.xlsx' }
|
||||
])
|
||||
assert.deepEqual(opened, [])
|
||||
})
|
||||
|
||||
test('mini program navigate failure surfaces as download error', async () => {
|
||||
await assert.rejects(
|
||||
downloadAttachmentFile(
|
||||
{ fileUrl: 'oa-approval/x.xlsx', fileName: 'x.xlsx' },
|
||||
{
|
||||
isWeChatMiniWebView: () => true,
|
||||
navigateToHostFilePreview: async () => ({
|
||||
ok: false,
|
||||
message: '无法跳转附件预览页'
|
||||
})
|
||||
}
|
||||
),
|
||||
/无法跳转附件预览页/
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 微信 web-view → 小程序附件预览/下载页
|
||||
* 对齐 OA 打印 PDF 的宿主跳转;避免 H5 window.open 在鸿蒙 ArkWeb 静默失败。
|
||||
*/
|
||||
|
||||
import {
|
||||
getOaPrintHostApis,
|
||||
isWeChatMiniWebView,
|
||||
printNavLog
|
||||
} from '../oa/print/oa-pdf-preview-nav.js'
|
||||
|
||||
const OPEN_DOCUMENT_EXTS = new Set([
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx'
|
||||
])
|
||||
|
||||
export function fileExtension(fileName) {
|
||||
const match = String(fileName || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.match(/\.([a-z0-9]+)$/)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
/** openDocument 支持的类型;其它扩展返回空串 */
|
||||
export function openDocumentFileType(fileName) {
|
||||
const ext = fileExtension(fileName)
|
||||
return OPEN_DOCUMENT_EXTS.has(ext) ? ext : ''
|
||||
}
|
||||
|
||||
/** query 只用 ASCII 名,避免 navigateTo 被中文撑爆;扩展名保留供宿主识别类型 */
|
||||
export function asciiHostFileName(fileName) {
|
||||
const ext = fileExtension(fileName) || 'bin'
|
||||
return `file.${ext}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ path: string, queryLen: number, fileUrlLen: number, name: string, fileType: string }}
|
||||
*/
|
||||
export function buildHostFilePreviewPath({ fileUrl, fileName } = {}) {
|
||||
const key = String(fileUrl || '').trim()
|
||||
const name = asciiHostFileName(fileName)
|
||||
const fileType = openDocumentFileType(name)
|
||||
const q =
|
||||
'fileUrl=' +
|
||||
encodeURIComponent(key) +
|
||||
'&f=' +
|
||||
encodeURIComponent(key) +
|
||||
'&n=' +
|
||||
encodeURIComponent(name)
|
||||
const path = '/pages/oa/file-preview/index?' + q
|
||||
return {
|
||||
path,
|
||||
queryLen: q.length,
|
||||
fileUrlLen: key.length,
|
||||
name,
|
||||
fileType
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<{ ok: boolean, via?: string, path?: string, message?: string, assumed?: boolean }>}
|
||||
*/
|
||||
export function navigateToHostFilePreview({ fileUrl, fileName } = {}, deps = {}) {
|
||||
const { path, queryLen, fileUrlLen, name, fileType } = buildHostFilePreviewPath({
|
||||
fileUrl,
|
||||
fileName
|
||||
})
|
||||
const win =
|
||||
deps.window !== undefined
|
||||
? deps.window
|
||||
: typeof window !== 'undefined'
|
||||
? window
|
||||
: undefined
|
||||
const log = deps.log || printNavLog
|
||||
const { wxApi, uniApi } = getOaPrintHostApis(win)
|
||||
|
||||
log('file-preview navigate start', {
|
||||
fileUrlLen,
|
||||
queryLen,
|
||||
pathLen: path.length,
|
||||
name,
|
||||
fileType,
|
||||
fileUrlPreview: String(fileUrl || '').slice(0, 120),
|
||||
hasWindowWx: !!(win && win.wx),
|
||||
hasWxMiniNavigate: !!(wxApi && wxApi.miniProgram && wxApi.miniProgram.navigateTo),
|
||||
hasUniWebViewNavigate: !!(uniApi && uniApi.webView && uniApi.webView.navigateTo),
|
||||
__wxjs_environment: win && win.__wxjs_environment
|
||||
})
|
||||
|
||||
if (!String(fileUrl || '').trim()) {
|
||||
log('file-preview navigate abort: empty fileUrl')
|
||||
return Promise.resolve({ ok: false, message: '缺少 fileUrl' })
|
||||
}
|
||||
|
||||
if (path.length > 900) {
|
||||
log('file-preview warn path long', { pathLen: path.length })
|
||||
}
|
||||
|
||||
const invokeNavigate = (label, navigateFn) =>
|
||||
new Promise((resolve) => {
|
||||
if (typeof navigateFn !== 'function') {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
let settled = false
|
||||
const done = (result) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
resolve(result)
|
||||
}
|
||||
try {
|
||||
navigateFn({
|
||||
url: path,
|
||||
success: (res) => {
|
||||
log(label + ' success', res)
|
||||
done({ ok: true, via: label, path })
|
||||
},
|
||||
fail: (err) => {
|
||||
log(label + ' fail', err)
|
||||
done({
|
||||
ok: false,
|
||||
via: label,
|
||||
path,
|
||||
message: (err && (err.errMsg || err.message)) || 'navigateTo fail'
|
||||
})
|
||||
}
|
||||
})
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
log(label + ' no-callback → assume ok', { pathLen: path.length })
|
||||
done({ ok: true, via: label, path, assumed: true })
|
||||
}
|
||||
}, 80)
|
||||
} catch (e) {
|
||||
log(label + ' throw', String(e && e.message ? e.message : e))
|
||||
done({
|
||||
ok: false,
|
||||
via: label,
|
||||
path,
|
||||
message: String(e && e.message ? e.message : e)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return invokeNavigate(
|
||||
'wx.miniProgram.navigateTo',
|
||||
wxApi && wxApi.miniProgram && wxApi.miniProgram.navigateTo
|
||||
? wxApi.miniProgram.navigateTo.bind(wxApi.miniProgram)
|
||||
: null
|
||||
).then((wxRes) => {
|
||||
if (wxRes && wxRes.ok) return wxRes
|
||||
if (wxRes && wxRes.ok === false) {
|
||||
log('file-preview wx failed, try uni', wxRes.message)
|
||||
}
|
||||
return invokeNavigate(
|
||||
'uni.webView.navigateTo',
|
||||
uniApi && uniApi.webView && uniApi.webView.navigateTo
|
||||
? uniApi.webView.navigateTo.bind(uniApi.webView)
|
||||
: null
|
||||
).then((uniRes) => {
|
||||
if (uniRes) return uniRes
|
||||
if (wxRes) return wxRes
|
||||
log('file-preview navigate unavailable')
|
||||
return {
|
||||
ok: false,
|
||||
message: '无法跳转附件预览页:未找到 wx/uni navigateTo'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export { isWeChatMiniWebView }
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
asciiHostFileName,
|
||||
buildHostFilePreviewPath,
|
||||
openDocumentFileType
|
||||
} from './host-file-preview-nav.js'
|
||||
|
||||
test('ascii host file name keeps extension only', () => {
|
||||
assert.equal(asciiHostFileName('26-10生产任务.xlsx'), 'file.xlsx')
|
||||
assert.equal(asciiHostFileName('report.PDF'), 'file.pdf')
|
||||
assert.equal(asciiHostFileName('plain'), 'file.bin')
|
||||
})
|
||||
|
||||
test('openDocument file types cover office docs', () => {
|
||||
assert.equal(openDocumentFileType('a.xlsx'), 'xlsx')
|
||||
assert.equal(openDocumentFileType('a.XLS'), 'xls')
|
||||
assert.equal(openDocumentFileType('a.docx'), 'docx')
|
||||
assert.equal(openDocumentFileType('a.zip'), '')
|
||||
})
|
||||
|
||||
test('host file preview path is short and points at file-preview page', () => {
|
||||
const built = buildHostFilePreviewPath({
|
||||
fileUrl: 'oa-approval/202610/abc.xlsx',
|
||||
fileName: '26-10生产任务.xlsx'
|
||||
})
|
||||
assert.ok(built.path.startsWith('/pages/oa/file-preview/index?'))
|
||||
assert.ok(built.path.includes('fileUrl='))
|
||||
assert.ok(built.path.includes('&f='))
|
||||
assert.ok(built.path.includes('&n=file.xlsx'))
|
||||
assert.equal(built.fileType, 'xlsx')
|
||||
assert.ok(!built.path.includes('%E7%94%9F'))
|
||||
assert.ok(built.queryLen < 400)
|
||||
assert.ok(built.path.length < 500)
|
||||
})
|
||||
@@ -46,7 +46,7 @@
|
||||
>
|
||||
<template #insert>
|
||||
<ProjectAssignmentField
|
||||
ref="projectAssignment"
|
||||
:ref="(instance) => (projectAssignment = instance)"
|
||||
embedded
|
||||
v-model="formData.projectId"
|
||||
:biz-type="formData.source"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-11-11
|
||||
@@ -0,0 +1,47 @@
|
||||
## Context
|
||||
|
||||
储存证质检信息由 `biz_sample_storage_cert_qc.payload` JSON 保存,Web 共用 `storage-cert-qc-dialog`,API 以菜单码控制读写且保存为整单覆盖。业务现要求按「质检 / 生产」拆分字段编辑权,并可由管理员配置人员。
|
||||
|
||||
已确认:方案 A(租户级配置 + 按人授权);配置入口独立菜单码;未配置前兼容旧写权限;同人字段并集;修改端为 web-admin + backend。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 租户可配置质检、生产两套「字段 + 人员」ACL
|
||||
- 前端按字段只读/可写;后端 merge 强制执行
|
||||
- 三处入口行为一致;配置 UI 符合 Fresh Mint 弹窗规范
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不按样本、不按工单单独配置 ACL
|
||||
- 不引入角色绑定作为本期主路径(人员勾选)
|
||||
- 不改造 mobile-h5 / mobile-oa-h5
|
||||
- 不改变 payload 字段语义与预填逻辑(仅限制谁能改)
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **存储**:主表 `biz_storage_cert_qc_acl`(`tenant_no`+`scope` 唯一,`field_keys` JSON,`configured_flag`)+ 子表 `biz_storage_cert_qc_acl_user`(`acl_id`+`user_id`,软删同步)。相对单 JSON 大包,子表便于人员校验与审计扩展。
|
||||
2. **权限解析**:用户可编辑字段 = 其所属各已配置 scope 的 field_keys 并集;严格模式在「任一 scope 的 configured_flag=1」时启用;否则有原 WRITE_MENUS 即可编辑全部合法字段。
|
||||
3. **写路径**:**BREAKING 行为**:`PUT` 从整单覆盖改为服务端 merge——仅更新 `editableFieldKeys` 内键;无任何可写字段拒绝保存;提交中含无权键且值相对库内有变化则 403。
|
||||
4. **配置权限**:独立菜单码(建议 `cell-storage:sample-info:storage-cert-qc-acl`);与业务填写菜单分离。按钮仅在 `canConfigureAcl=true` 时展示。
|
||||
5. **字段候选**:前后端共用类型字段清单(与 `STORAGE_CERT_QC_GROUPS` / 后端 type keys 对齐);配置 UI 展示两样本类型字段并集,非法 key 拒绝保存。
|
||||
6. **UI**:主弹窗 footer 左侧配置按钮、右侧取消/确定;配置子弹两个 Tab(字段勾选 / 人员多选)。先 Figma 确认再编码。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [未配置→已配置切换瞬间全员变严] → 上线说明;兼容期默认未配置仍可写;配置保存前可预览影响(可选,本期不做强制)。
|
||||
- [人员名单维护成本] → 接受方案 A;后续可加角色扩展,不阻塞本期。
|
||||
- [整单覆盖客户端旧代码] → 后端 merge 兼容旧客户端:无权字段被忽略或在变更时 403;推荐 403 以便发现错误集成。
|
||||
- [超级管理员] → 不默认绕过 ACL;仅配置菜单控制谁能改 ACL。若需超管旁路,另开需求。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 执行 SQL 建表;部署后端(读接口先返回兼容态 editableFieldKeys=全部或空+aclStrict=false)。
|
||||
2. 部署前端(无配置权限时 UI 与现网接近)。
|
||||
3. 管理员配置质检/生产后该租户进入严格模式。
|
||||
4. 回滚:保留表数据;回退代码后配置表闲置;严格模式逻辑随代码回退消失。
|
||||
|
||||
## Open Questions
|
||||
|
||||
- 无(关键项已在对话确认:租户级、独立配置菜单、兼容期、字段并集、先 Figma)。
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
「填写储存证质检信息」弹窗目前只要具备菜单写权限即可改全部字段,无法按质检 / 生产职责拆分。业务需要:质检与生产各自配置可编辑字段与人员,范围外用户仅可查看,且服务端必须强制校验。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增租户级储存证质检字段 ACL:两个 scope(`qc` / `production`),各自保存可编辑字段列表与可编辑人员。
|
||||
- 详情接口返回当前用户 `editableFieldKeys` 等权限上下文;保存改为服务端按权限 merge,拒绝无权字段篡改。
|
||||
- 主弹窗 footer 左侧增加「质检权限」「生产权限」入口(仅配置权限可见);配置子弹含「勾选字段」「配置人员」两个 Tab。
|
||||
- 未对任一 scope 保存过配置时,兼容现有菜单写权限(可改全部);任一 scope 首次配置后该租户进入严格 ACL。
|
||||
- **不改**小程序 / mobile-oa-h5;三处 Web 入口(客户服务、质检工单、生产工单)共用同一弹窗组件一并生效。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `storage-cert-qc-field-acl`: 储存证质检信息字段级编辑权限(配置、解析、读写强制)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- (无;`openspec/specs/` 尚未建立长期基线中的对应能力)
|
||||
|
||||
## Impact
|
||||
|
||||
- **backend**:新表与 CRUD/API;`sample_storage_cert_qc` 读/写路径;菜单码与鉴权
|
||||
- **web-admin**:`storage-cert-qc-dialog`、ACL 配置子弹、API 客户端
|
||||
- **docs/sql**:结构迁移脚本
|
||||
- **权限**:租户隔离;配置入口独立菜单码;写入不信任客户端字段权限声明
|
||||
@@ -0,0 +1,76 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 租户级质检与生产字段 ACL 配置
|
||||
|
||||
系统 SHALL 按租户维护储存证质检信息的两套编辑权限配置,scope 分别为 `qc`(质检)与 `production`(生产)。每套配置 SHALL 包含可编辑字段 key 列表与可编辑用户列表。字段 key MUST 属于该业务已定义的合法 payload 键集合。
|
||||
|
||||
#### Scenario: 管理员保存质检权限
|
||||
|
||||
- **WHEN** 具备 ACL 配置菜单权限的用户保存 scope=`qc` 的字段列表与人员列表
|
||||
- **THEN** 系统持久化该租户的质检 ACL,并将该 scope 标记为已配置
|
||||
|
||||
#### Scenario: 非法字段被拒绝
|
||||
|
||||
- **WHEN** 配置请求包含不在合法 payload 键集合中的 field key
|
||||
- **THEN** 系统拒绝保存并返回错误,不部分写入
|
||||
|
||||
### Requirement: 按当前用户解析可编辑字段
|
||||
|
||||
系统在返回储存证质检详情时 SHALL 附带当前用户可编辑字段集合。可编辑字段 MUST 为用户所属各 scope 字段列表的并集。未出现在任一 ACL 人员名单中的用户,在严格模式下 MUST 得到空的可编辑字段集合(仍可只读查看详情)。
|
||||
|
||||
#### Scenario: 同属两 scope 取并集
|
||||
|
||||
- **WHEN** 用户同时在质检与生产人员名单中,且两 scope 勾选了不同字段
|
||||
- **THEN** 详情中的可编辑字段为两 scope 字段的并集
|
||||
|
||||
#### Scenario: 未授权用户只读
|
||||
|
||||
- **WHEN** 租户已进入严格 ACL,且当前用户不在任一 scope 人员名单中
|
||||
- **THEN** 可编辑字段为空,用户可查看已有数据但不能修改
|
||||
|
||||
### Requirement: 未配置前兼容菜单写权限
|
||||
|
||||
当租户的质检与生产 scope 均未被标记为已配置时,系统 SHALL 保持兼容行为:只要用户具备既有储存证质检写菜单权限,即可编辑全部合法字段。当任一 scope 被标记为已配置后,该租户 MUST 进入严格 ACL,不再仅凭菜单写权限获得全字段编辑权。
|
||||
|
||||
#### Scenario: 全新租户未配置
|
||||
|
||||
- **WHEN** 租户两 scope 均未配置,且用户具备既有写菜单权限
|
||||
- **THEN** 详情返回全部合法字段为可编辑,保存可更新这些字段
|
||||
|
||||
#### Scenario: 首次配置后收紧
|
||||
|
||||
- **WHEN** 管理员首次成功保存任一 scope 的 ACL
|
||||
- **THEN** 之后该租户按严格 ACL 解析可编辑字段与保存权限
|
||||
|
||||
### Requirement: 保存时服务端强制 merge
|
||||
|
||||
储存证质检保存接口 MUST 仅更新当前用户有权编辑的字段;无权字段 MUST 保留库中原值。若请求试图修改无权字段的值,系统 MUST 拒绝该请求。当用户没有任何可编辑字段时,系统 MUST 拒绝保存。
|
||||
|
||||
#### Scenario: 部分字段可写
|
||||
|
||||
- **WHEN** 用户仅对字段集合 A 有编辑权并提交包含 A 与其他字段的 payload
|
||||
- **THEN** 仅 A 中相对原值有权限的变更被写入,其他键保持原值;若其他键相对原值发生变化则整单拒绝
|
||||
|
||||
#### Scenario: 无可写字段
|
||||
|
||||
- **WHEN** 当前用户可编辑字段为空并调用保存接口
|
||||
- **THEN** 系统拒绝保存
|
||||
|
||||
### Requirement: 配置入口与主弹窗交互
|
||||
|
||||
Web 管理端储存证质检弹窗在用户具备 ACL 配置权限时,SHALL 在 footer 左侧提供「质检权限」「生产权限」入口。配置界面 SHALL 提供两个 Tab:勾选可编辑字段、配置可编辑人员。不具备配置权限的用户 MUST 看不到上述入口。不具备字段编辑权的用户打开业务弹窗时 MUST 以只读方式展示字段,且不得出现可提交业务保存的主按钮(或等价禁用)。
|
||||
|
||||
#### Scenario: 管理员可见配置按钮
|
||||
|
||||
- **WHEN** 具备 ACL 配置菜单权限的用户打开填写弹窗
|
||||
- **THEN** footer 左侧显示质检权限与生产权限按钮
|
||||
|
||||
#### Scenario: 普通填写人不可见配置
|
||||
|
||||
- **WHEN** 不具备 ACL 配置菜单权限的用户打开填写弹窗
|
||||
- **THEN** 不显示质检权限与生产权限按钮
|
||||
|
||||
#### Scenario: 三处入口一致
|
||||
|
||||
- **WHEN** 用户从客户服务、质检工单或生产工单打开同一储存证质检弹窗
|
||||
- **THEN** 字段可编辑性与配置入口可见性均按同一 ACL 与菜单规则生效
|
||||
@@ -0,0 +1,26 @@
|
||||
## 1. 数据与后端 ACL
|
||||
|
||||
- [x] 1.1 新增 `docs/sql/` 迁移:`biz_storage_cert_qc_acl`、`biz_storage_cert_qc_acl_user`(租户、软删、标准审计字段)
|
||||
- [x] 1.2 新增 ORM / CRUD:按 tenant+scope 读写字段列表与人员;保存时校验合法 field key、同步子行
|
||||
- [x] 1.3 实现当前用户可编辑字段解析(兼容模式 vs 严格模式、两 scope 并集)
|
||||
- [x] 1.4 扩展 GET 详情:返回 `editableFieldKeys`、`canConfigureAcl`、`aclStrict`
|
||||
- [x] 1.5 改造 PUT 保存为服务端 merge,无权字段变更拒绝,无可写字段拒绝
|
||||
- [x] 1.6 新增 ACL GET/PUT API,鉴权独立配置菜单码
|
||||
- [x] 1.7 补充/调整 `backend/tests/test_sample_storage_cert_qc.py`(及 ACL 用例)覆盖兼容、严格、并集、拒绝篡改
|
||||
|
||||
## 2. Web 主弹窗与配置弹窗
|
||||
|
||||
- [x] 2.1 按已确认 Figma 原型调整 `storage-cert-qc-dialog` footer(左侧配置按钮、右侧取消/确定)与字段只读态
|
||||
- [x] 2.2 新增 ACL 配置弹窗(质检/生产):Tab1 分组勾选字段,Tab2 选人,保存调 ACL API
|
||||
- [x] 2.3 接入 API 客户端与菜单码;无配置权限不展示入口;无可写字段隐藏业务确定
|
||||
- [x] 2.4 核对客户服务 / 质检工单 / 生产工单三处入口共用行为
|
||||
|
||||
## 3. 权限种子与文档回写
|
||||
|
||||
- [x] 3.1 增加配置菜单码种子/SQL(命名与路由约定一致),说明仅管理员授予
|
||||
- [ ] 3.2 工单回写:OpenSpec 路径、Figma 链接、验收要点;原型确认结论
|
||||
|
||||
## 4. 验证
|
||||
|
||||
- [x] 4.1 在任务 worktree 运行受影响后端单元测试并记录结果
|
||||
- [x] 4.2 源码级对照已确认原型(footer 布局、双 Tab、按钮主次、只读态)
|
||||
@@ -12,6 +12,20 @@ export type SampleStorageCertQcDetail = {
|
||||
filledFlag: boolean
|
||||
payload: Record<string, unknown>
|
||||
prefill?: Record<string, unknown>
|
||||
editableFieldKeys?: string[]
|
||||
canConfigureAcl?: boolean
|
||||
aclStrict?: boolean
|
||||
}
|
||||
|
||||
export type StorageCertQcAclScope = 'qc' | 'production'
|
||||
|
||||
export type StorageCertQcAclDetail = {
|
||||
scope: StorageCertQcAclScope
|
||||
fieldKeys: string[]
|
||||
userIds: string[]
|
||||
users: Array<{ id: string; nickName: string; userName: string }>
|
||||
configuredFlag: boolean
|
||||
aclStrict: boolean
|
||||
}
|
||||
|
||||
export function fetchSampleStorageCertQc(sampleCode: string) {
|
||||
@@ -29,3 +43,19 @@ export function saveSampleStorageCertQc(
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchSampleStorageCertQcAcl(scope: StorageCertQcAclScope) {
|
||||
return request.get({
|
||||
url: `/web/admin/sample-storage-cert-qc-acl/${encodeURIComponent(scope)}`
|
||||
})
|
||||
}
|
||||
|
||||
export function saveSampleStorageCertQcAcl(
|
||||
scope: StorageCertQcAclScope,
|
||||
data: { fieldKeys: string[]; userIds: string[] }
|
||||
) {
|
||||
return request.put({
|
||||
url: `/web/admin/sample-storage-cert-qc-acl/${encodeURIComponent(scope)}`,
|
||||
data
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="dialogTitle"
|
||||
width="720px"
|
||||
class="dialog-form-layout storage-cert-qc-acl-dialog"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
modal-class="protocol-dialog-mask"
|
||||
@update:model-value="emit('update:visible', $event)"
|
||||
>
|
||||
<div v-loading="loading" class="storage-cert-qc-acl-dialog__body">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="勾选可编辑字段" name="fields">
|
||||
<div v-for="(group, gi) in fieldGroups" :key="gi" class="field-block">
|
||||
<div class="field-block__title">{{ group.title }}</div>
|
||||
<el-checkbox-group v-model="selectedKeys" class="field-check-grid">
|
||||
<el-checkbox
|
||||
v-for="field in group.fields"
|
||||
:key="field.key"
|
||||
:label="field.key"
|
||||
class="field-check"
|
||||
>
|
||||
{{ field.label }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="配置人员" name="users">
|
||||
<el-select
|
||||
v-model="selectedUserIds"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
reserve-keyword
|
||||
:remote-method="searchUsers"
|
||||
:loading="userLoading"
|
||||
placeholder="输入姓名或账号搜索"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="u in userOptions"
|
||||
:key="u.value"
|
||||
:label="u.label"
|
||||
:value="u.value"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">仅名单内用户可修改本权限勾选的字段。</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:visible', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchUserList } from '@/api/admin'
|
||||
import {
|
||||
fetchSampleStorageCertQcAcl,
|
||||
saveSampleStorageCertQcAcl,
|
||||
type StorageCertQcAclScope
|
||||
} from '@/api/sample-storage-cert-qc'
|
||||
import { STORAGE_CERT_QC_GROUPS } from '@/utils/storage-cert-qc-fields'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
scope: StorageCertQcAclScope
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', v: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const userLoading = ref(false)
|
||||
const activeTab = ref('fields')
|
||||
const selectedKeys = ref<string[]>([])
|
||||
const selectedUserIds = ref<string[]>([])
|
||||
const userOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const dialogTitle = computed(() => (props.scope === 'qc' ? '质检权限' : '生产权限'))
|
||||
|
||||
const fieldGroups = computed(() => {
|
||||
const map = new Map<string, { title: string; fields: Array<{ key: string; label: string }> }>()
|
||||
for (const groups of Object.values(STORAGE_CERT_QC_GROUPS)) {
|
||||
for (const group of groups) {
|
||||
const item = map.get(group.title) || { title: group.title, fields: [] }
|
||||
const seen = new Set(item.fields.map((f) => f.key))
|
||||
for (const field of group.fields) {
|
||||
if (seen.has(field.key)) continue
|
||||
seen.add(field.key)
|
||||
item.fields.push({ key: field.key, label: field.label })
|
||||
}
|
||||
map.set(group.title, item)
|
||||
}
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
function upsertUserOption(id: string, label: string) {
|
||||
if (!id) return
|
||||
if (userOptions.value.some((u) => u.value === id)) return
|
||||
userOptions.value.push({ value: id, label: label || id })
|
||||
}
|
||||
|
||||
async function searchUsers(keyword: string) {
|
||||
userLoading.value = true
|
||||
try {
|
||||
const res = await fetchUserList({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
status: '1',
|
||||
keyword: keyword || undefined
|
||||
})
|
||||
const list = (res as any)?.list || []
|
||||
for (const row of list) {
|
||||
const id = String(row.id || '')
|
||||
const name = String(row.nickName || row.userName || row.userNo || id)
|
||||
const account = String(row.userName || row.userNo || '')
|
||||
upsertUserOption(id, account ? `${name}(${account})` : name)
|
||||
}
|
||||
} finally {
|
||||
userLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
activeTab.value = 'fields'
|
||||
try {
|
||||
const detail = (await fetchSampleStorageCertQcAcl(props.scope)) as any
|
||||
selectedKeys.value = Array.isArray(detail?.fieldKeys) ? [...detail.fieldKeys] : []
|
||||
selectedUserIds.value = Array.isArray(detail?.userIds) ? [...detail.userIds] : []
|
||||
userOptions.value = []
|
||||
for (const u of detail?.users || []) {
|
||||
const id = String(u.id || '')
|
||||
const name = String(u.nickName || u.userName || id)
|
||||
const account = String(u.userName || '')
|
||||
upsertUserOption(id, account ? `${name}(${account})` : name)
|
||||
}
|
||||
if (!userOptions.value.length) {
|
||||
await searchUsers('')
|
||||
}
|
||||
} catch {
|
||||
selectedKeys.value = []
|
||||
selectedUserIds.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveSampleStorageCertQcAcl(props.scope, {
|
||||
fieldKeys: [...selectedKeys.value],
|
||||
userIds: [...selectedUserIds.value]
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
emit('saved')
|
||||
emit('update:visible', false)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.scope] as const,
|
||||
([vis]) => {
|
||||
if (vis) loadDetail()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.storage-cert-qc-acl-dialog__body {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.field-block {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.field-block__title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary-dark-2, #059669);
|
||||
}
|
||||
|
||||
.field-check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-right: 0;
|
||||
margin-bottom: 0;
|
||||
height: auto;
|
||||
white-space: normal;
|
||||
|
||||
:deep(.el-checkbox__label) {
|
||||
white-space: normal;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.field-check-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-top: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
</style>
|
||||
@@ -54,7 +54,7 @@
|
||||
'form-col-span-3': field.span === 3
|
||||
}"
|
||||
>
|
||||
<template v-if="mode === 'view'">
|
||||
<template v-if="mode === 'view' || !isFieldEditable(field.key)">
|
||||
<div
|
||||
class="readonly-box"
|
||||
:class="{ 'is-positive': field.control === 'select_yny' && isPositiveQual(form[field.key]) }"
|
||||
@@ -112,32 +112,56 @@
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="mode === 'edit'">
|
||||
<el-button @click="emit('update:visible', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button v-else type="primary" @click="emit('update:visible', false)">
|
||||
<el-icon><Check /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
<div class="storage-cert-qc-dialog__footer">
|
||||
<div class="footer-left">
|
||||
<template v-if="canConfigureAcl">
|
||||
<el-button @click="openAcl('qc')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
质检权限
|
||||
</el-button>
|
||||
<el-button @click="openAcl('production')">
|
||||
<el-icon><Setting /></el-icon>
|
||||
生产权限
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="footer-right">
|
||||
<template v-if="mode === 'edit' && canSaveAny">
|
||||
<el-button @click="emit('update:visible', false)">
|
||||
<el-icon><Close /></el-icon>
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">
|
||||
<el-icon><Check /></el-icon>
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
<el-button v-else type="primary" @click="emit('update:visible', false)">
|
||||
<el-icon><Check /></el-icon>
|
||||
关闭
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<StorageCertQcAclDialog
|
||||
v-model:visible="aclVisible"
|
||||
:scope="aclScope"
|
||||
@saved="loadDetail"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Check, Close } from '@element-plus/icons-vue'
|
||||
import { Check, Close, Setting } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
fetchSampleStorageCertQc,
|
||||
saveSampleStorageCertQc,
|
||||
type SampleStorageCertQcDetail
|
||||
type SampleStorageCertQcDetail,
|
||||
type StorageCertQcAclScope
|
||||
} from '@/api/sample-storage-cert-qc'
|
||||
import StorageCertQcAclDialog from '@/components/business/storage-cert-qc-acl-dialog/index.vue'
|
||||
import {
|
||||
STORAGE_CERT_QC_GROUPS,
|
||||
isPerinatalQualField,
|
||||
@@ -166,6 +190,10 @@
|
||||
const filledFlag = ref(false)
|
||||
const typeKey = ref<StorageCertQcTypeKey | null>(null)
|
||||
const form = reactive<Record<string, string>>({})
|
||||
const editableKeySet = ref<Set<string>>(new Set())
|
||||
const canConfigureAcl = ref(false)
|
||||
const aclVisible = ref(false)
|
||||
const aclScope = ref<StorageCertQcAclScope>('qc')
|
||||
const identity = reactive({
|
||||
labFlowCode: '',
|
||||
sampleCode: '',
|
||||
@@ -174,7 +202,7 @@
|
||||
})
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
props.mode === 'edit' ? '填写储存证质检信息' : '查看储存证的质检信息'
|
||||
props.mode === 'edit' && canSaveAny.value ? '填写储存证质检信息' : '查看储存证的质检信息'
|
||||
)
|
||||
|
||||
const codeFieldLabel = computed(() => {
|
||||
@@ -184,6 +212,19 @@
|
||||
|
||||
const groups = computed(() => (typeKey.value ? STORAGE_CERT_QC_GROUPS[typeKey.value] || [] : []))
|
||||
|
||||
const canSaveAny = computed(
|
||||
() => props.mode === 'edit' && editableKeySet.value.size > 0
|
||||
)
|
||||
|
||||
function isFieldEditable(key: string) {
|
||||
return props.mode === 'edit' && editableKeySet.value.has(key)
|
||||
}
|
||||
|
||||
function openAcl(scope: StorageCertQcAclScope) {
|
||||
aclScope.value = scope
|
||||
aclVisible.value = true
|
||||
}
|
||||
|
||||
function displayValue(key: string) {
|
||||
const v = form[key]
|
||||
if (v == null || String(v).trim() === '') return '—'
|
||||
@@ -213,6 +254,9 @@
|
||||
identity.motherName = detail.motherName || ''
|
||||
identity.sampleTypeName = detail.sampleTypeName || props.sampleTypeName || ''
|
||||
filledFlag.value = !!detail.filledFlag
|
||||
canConfigureAcl.value = !!detail.canConfigureAcl
|
||||
const keys = Array.isArray(detail.editableFieldKeys) ? detail.editableFieldKeys : []
|
||||
editableKeySet.value = new Set(keys.map((k) => String(k)))
|
||||
const resolved =
|
||||
(detail.sampleTypeKey as StorageCertQcTypeKey | null) ||
|
||||
resolveStorageCertQcTypeKey(identity.sampleTypeName)
|
||||
@@ -234,6 +278,8 @@
|
||||
} catch {
|
||||
typeKey.value = resolveStorageCertQcTypeKey(props.sampleTypeName)
|
||||
filledFlag.value = false
|
||||
editableKeySet.value = new Set()
|
||||
canConfigureAcl.value = false
|
||||
resetFormKeys(typeKey.value, {})
|
||||
identity.labFlowCode = ''
|
||||
identity.sampleCode = code
|
||||
@@ -249,6 +295,10 @@
|
||||
ElMessage.warning('无法保存:样本类型不支持或缺少样本编码')
|
||||
return
|
||||
}
|
||||
if (!canSaveAny.value) {
|
||||
ElMessage.warning('当前无字段编辑权限')
|
||||
return
|
||||
}
|
||||
const payload: Record<string, unknown> = {}
|
||||
for (const group of groups.value) {
|
||||
for (const field of group.fields) {
|
||||
@@ -283,6 +333,21 @@
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.storage-cert-qc-dialog__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ctx-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -405,5 +470,15 @@
|
||||
.form-col-span-3 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.storage-cert-qc-dialog__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.footer-left,
|
||||
.footer-right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
buildFreightMergeRanges,
|
||||
buildPresignLedgerRows,
|
||||
buildResearchLedgerRows,
|
||||
formatLedgerDateTime
|
||||
formatLedgerDateTime,
|
||||
groupFlatRowsByFreightBatch
|
||||
} from './collection-ledger-export'
|
||||
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
|
||||
|
||||
@@ -45,10 +46,25 @@ describe('buildPresignLedgerRows', () => {
|
||||
assert.equal(row.length, PRESIGN_HEADERS.length)
|
||||
assert.deepEqual(row.slice(0, 6), ['1', '2026', '4', '2', 'ZKZX1', '杨育蕾'])
|
||||
assert.equal(row[9], '趸交')
|
||||
assert.equal(row[12], '1000元')
|
||||
assert.equal(row[12], '1000元(定金)')
|
||||
assert.equal(row[13], '2026年4月2日16时49分42秒')
|
||||
assert.equal(row[18], '')
|
||||
})
|
||||
|
||||
it('fills refund amount for voided deposit orders', () => {
|
||||
const [row] = buildPresignLedgerRows([
|
||||
{
|
||||
orderDate: '20250919',
|
||||
motherName: '陈影',
|
||||
confirmedDepositAmount: 1000,
|
||||
transferTimes: ['2025-09-19 17:26:13'],
|
||||
orderStatus: 'void',
|
||||
remark: '悔存-申请退定金'
|
||||
}
|
||||
])
|
||||
assert.equal(row[12], '1000元(定金)')
|
||||
assert.equal(row[18], '1000元')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCellStorageLedgerRows', () => {
|
||||
@@ -77,6 +93,7 @@ describe('buildCellStorageLedgerRows', () => {
|
||||
freightId: 'fr-cell-1',
|
||||
freightTransportLabel: '专线车',
|
||||
freightAmountLabel: '120',
|
||||
monthlyVerifyNo: '202509012',
|
||||
paymentSummary: {
|
||||
payMode: 'fq',
|
||||
contractAmount: 22880,
|
||||
@@ -105,6 +122,7 @@ describe('buildCellStorageLedgerRows', () => {
|
||||
const row = rows[0]
|
||||
assert.equal(row.length, CELL_STORAGE_HEADERS.length)
|
||||
assert.equal(CELL_STORAGE_HEADERS[13], '涡阳县人民医院')
|
||||
assert.equal(row[4], '202509012')
|
||||
assert.equal(row[5], 'B0000004')
|
||||
assert.equal(row[6], '生效中')
|
||||
assert.equal(row[9], 'ZKZX34000849')
|
||||
@@ -113,6 +131,7 @@ describe('buildCellStorageLedgerRows', () => {
|
||||
assert.equal(row[14], '分期')
|
||||
assert.equal(row[16], '22880')
|
||||
assert.equal(row[17], '6480')
|
||||
assert.equal(row[20], '')
|
||||
assert.equal(row[22], '2025年9月15日11时13分01秒')
|
||||
assert.equal(row[24], '16400')
|
||||
assert.equal(row[25], '16400')
|
||||
@@ -122,6 +141,27 @@ describe('buildCellStorageLedgerRows', () => {
|
||||
assert.equal(row[53], '120')
|
||||
})
|
||||
|
||||
it('writes negative refund amount for voided protocols', () => {
|
||||
const { rows } = buildCellStorageLedgerRows([
|
||||
{
|
||||
protocol: { protocolStatus: 'yzf', remark: '', voidType: 'khhc', voidDesc: '悔存' },
|
||||
sampleCollectTime: '2025-09-07 13:45:00',
|
||||
motherName: '王晴晴',
|
||||
sampleCode: 'S-VOID',
|
||||
sampleTypeName: '围产期造血干细胞',
|
||||
paymentSummary: {
|
||||
payMode: 'dj',
|
||||
contractAmount: 5000,
|
||||
receivedAmount: 5000,
|
||||
refundAmount: 5000,
|
||||
paymentDetails: []
|
||||
}
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
])
|
||||
assert.equal(rows[0][19], '5000')
|
||||
assert.equal(rows[0][20], '-5000')
|
||||
})
|
||||
|
||||
it('merges transport and freight columns for the same freight batch', () => {
|
||||
const base = {
|
||||
protocol: { protocolStatus: 'sxz', remark: '', voidType: '', voidDesc: '' },
|
||||
@@ -155,6 +195,71 @@ describe('buildCellStorageLedgerRows', () => {
|
||||
{ startRow: 2, endRow: 3, startCol: 54, endCol: 54 }
|
||||
])
|
||||
})
|
||||
|
||||
it('groups non-adjacent same freight batch before merging cells', () => {
|
||||
const shared = {
|
||||
protocol: { protocolStatus: 'sxz', remark: '', voidType: '', voidDesc: '' },
|
||||
sampleCollectTime: '2025-09-11 18:13:00',
|
||||
sampleTypeName: 'NK',
|
||||
regionName: '常州',
|
||||
hospitalName: '溧阳市人民医院',
|
||||
freightTransportLabel: '专线车',
|
||||
freightAmountLabel: '100'
|
||||
}
|
||||
const { rows, merges } = buildCellStorageLedgerRows([
|
||||
{
|
||||
...shared,
|
||||
motherName: '甲',
|
||||
sampleCode: 'S1',
|
||||
labStorageCode: 'UBMC1',
|
||||
freightId: 'batch-cell'
|
||||
} as unknown as ProtocolAuditFlatRow,
|
||||
{
|
||||
...shared,
|
||||
motherName: '丙',
|
||||
sampleCode: 'S3',
|
||||
labStorageCode: 'UBMC3',
|
||||
freightId: 'other',
|
||||
freightTransportLabel: '快递',
|
||||
freightAmountLabel: '25'
|
||||
} as unknown as ProtocolAuditFlatRow,
|
||||
{
|
||||
...shared,
|
||||
motherName: '乙',
|
||||
sampleCode: 'S2',
|
||||
labStorageCode: 'UBMC2',
|
||||
freightId: 'batch-cell'
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
])
|
||||
assert.equal(rows[0][8], '甲')
|
||||
assert.equal(rows[1][8], '乙')
|
||||
assert.equal(rows[2][8], '丙')
|
||||
assert.equal(rows[0][52], '专线车')
|
||||
assert.equal(rows[0][53], '100')
|
||||
assert.equal(rows[1][52], '')
|
||||
assert.equal(rows[1][53], '')
|
||||
assert.equal(rows[2][52], '快递')
|
||||
assert.equal(rows[2][53], '25')
|
||||
assert.deepEqual(merges, [
|
||||
{ startRow: 2, endRow: 3, startCol: 53, endCol: 53 },
|
||||
{ startRow: 2, endRow: 3, startCol: 54, endCol: 54 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupFlatRowsByFreightBatch', () => {
|
||||
it('keeps first-seen batch order and leaves empty freight rows in place', () => {
|
||||
const arranged = groupFlatRowsByFreightBatch([
|
||||
{ motherName: 'A', freightId: 'f1' },
|
||||
{ motherName: 'B', freightId: '' },
|
||||
{ motherName: 'C', freightId: 'f1' },
|
||||
{ motherName: 'D', freightId: 'f2' }
|
||||
] as unknown as ProtocolAuditFlatRow[])
|
||||
assert.deepEqual(
|
||||
arranged.map((row) => row.motherName),
|
||||
['A', 'C', 'B', 'D']
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildResearchLedgerRows', () => {
|
||||
@@ -195,6 +300,7 @@ describe('buildResearchLedgerRows', () => {
|
||||
assert.equal(row[4], '202508001')
|
||||
assert.equal(row[6], '实验室废弃')
|
||||
assert.equal(row[7], '母血乙肝阳性')
|
||||
assert.equal(row[9], '实验室废弃')
|
||||
assert.equal(row[16], '专线车')
|
||||
assert.equal(row[17], '100')
|
||||
assert.equal(row[18], '女')
|
||||
@@ -203,6 +309,24 @@ describe('buildResearchLedgerRows', () => {
|
||||
assert.equal(row[23], '1')
|
||||
})
|
||||
|
||||
it('keeps sample type in column 10 for active research rows', () => {
|
||||
const { rows } = buildResearchLedgerRows([
|
||||
{
|
||||
protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '202508010' },
|
||||
sampleCollectTime: '2025-08-30 10:00:00',
|
||||
motherName: '正常',
|
||||
sampleTypeName: '围产期造血干细胞',
|
||||
regionName: '亳州',
|
||||
hospitalName: '涡阳县人民医院',
|
||||
freightId: '',
|
||||
freightTransportLabel: '',
|
||||
freightAmountLabel: ''
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
])
|
||||
assert.equal(rows[0][6], '')
|
||||
assert.equal(rows[0][9], '围产期造血干细胞')
|
||||
})
|
||||
|
||||
it('maps sdfq void type code to 属地废弃 labels', () => {
|
||||
const flat = {
|
||||
protocol: {
|
||||
@@ -237,6 +361,7 @@ describe('buildResearchLedgerRows', () => {
|
||||
const row = rows[0]
|
||||
assert.equal(row[6], '属地废弃')
|
||||
assert.equal(row[7], '属地废弃')
|
||||
assert.equal(row[9], '属地废弃')
|
||||
assert.equal(row[20], '剖腹产')
|
||||
assert.equal(row[22], '无母血')
|
||||
})
|
||||
@@ -276,6 +401,50 @@ describe('buildResearchLedgerRows', () => {
|
||||
{ startRow: 3, endRow: 4, startCol: 18, endCol: 18 }
|
||||
])
|
||||
})
|
||||
|
||||
it('groups non-adjacent same freight batch before merging research cells', () => {
|
||||
const shared = {
|
||||
protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '' },
|
||||
sampleCollectTime: '2025-09-11 18:13:00',
|
||||
sampleTypeName: 'NK',
|
||||
regionName: '常州',
|
||||
hospitalName: '溧阳市人民医院',
|
||||
freightTransportLabel: '专线车',
|
||||
freightAmountLabel: '100'
|
||||
}
|
||||
const { rows, merges } = buildResearchLedgerRows([
|
||||
{
|
||||
...shared,
|
||||
motherName: '甲',
|
||||
labStorageCode: 'A1',
|
||||
freightId: 'batch-a'
|
||||
} as unknown as ProtocolAuditFlatRow,
|
||||
{
|
||||
...shared,
|
||||
motherName: '散',
|
||||
labStorageCode: 'A9',
|
||||
freightId: '',
|
||||
freightTransportLabel: '',
|
||||
freightAmountLabel: ''
|
||||
} as unknown as ProtocolAuditFlatRow,
|
||||
{
|
||||
...shared,
|
||||
motherName: '乙',
|
||||
labStorageCode: 'A2',
|
||||
freightId: 'batch-a'
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
])
|
||||
assert.equal(rows[0][8], '甲')
|
||||
assert.equal(rows[1][8], '乙')
|
||||
assert.equal(rows[2][8], '散')
|
||||
assert.equal(rows[0][16], '专线车')
|
||||
assert.equal(rows[1][16], '')
|
||||
assert.equal(rows[1][17], '')
|
||||
assert.deepEqual(merges, [
|
||||
{ startRow: 2, endRow: 3, startCol: 17, endCol: 17 },
|
||||
{ startRow: 2, endRow: 3, startCol: 18, endCol: 18 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCustomerServiceLedgerRows', () => {
|
||||
|
||||
@@ -338,6 +338,36 @@ function researchMaternalBloodText(value: unknown): string {
|
||||
return text(value) ? text(value) : '无母血'
|
||||
}
|
||||
|
||||
/**
|
||||
* 将同一运费批次的行聚拢到一起(按首次出现顺序),无批次行保持相对位置。
|
||||
* 模版要求同批次运输方式/运费合并单元格,故导出前先归拢再合并。
|
||||
*/
|
||||
export function groupFlatRowsByFreightBatch(
|
||||
rows: ProtocolAuditFlatRow[]
|
||||
): ProtocolAuditFlatRow[] {
|
||||
const groups = new Map<string, ProtocolAuditFlatRow[]>()
|
||||
const sequence: Array<{ kind: 'batch'; id: string } | { kind: 'single'; row: ProtocolAuditFlatRow }> =
|
||||
[]
|
||||
for (const row of rows) {
|
||||
const freightId = text(row.freightId)
|
||||
if (!freightId) {
|
||||
sequence.push({ kind: 'single', row })
|
||||
continue
|
||||
}
|
||||
if (!groups.has(freightId)) {
|
||||
groups.set(freightId, [])
|
||||
sequence.push({ kind: 'batch', id: freightId })
|
||||
}
|
||||
groups.get(freightId)!.push(row)
|
||||
}
|
||||
const arranged: ProtocolAuditFlatRow[] = []
|
||||
for (const item of sequence) {
|
||||
if (item.kind === 'single') arranged.push(item.row)
|
||||
else arranged.push(...(groups.get(item.id) || []))
|
||||
}
|
||||
return arranged
|
||||
}
|
||||
|
||||
/**
|
||||
* 相邻且 freightId 相同的导出行,合并运输方式/运费列(Excel 行号含表头)。
|
||||
* @param transportCol 运输方式列(1-based)
|
||||
@@ -470,11 +500,26 @@ function transportText(row: ProtocolAuditFlatRow): string {
|
||||
return label(TRANSPORT, row.transportType || transport.transportType)
|
||||
}
|
||||
|
||||
function presignDepositText(amount: unknown): string {
|
||||
const money = formatLedgerMoney(amount)
|
||||
return money ? `${money}元(定金)` : ''
|
||||
}
|
||||
|
||||
function presignRefundText(row: Row): string {
|
||||
const explicit = formatLedgerMoney(row.refundAmount)
|
||||
if (explicit) return `${explicit}元`
|
||||
const status = text(row.orderStatus)
|
||||
const remark = text(row.remark)
|
||||
const voided = status === 'void' || remark.includes('悔存') || remark.includes('退定金')
|
||||
if (!voided) return ''
|
||||
const deposit = formatLedgerMoney(row.confirmedDepositAmount)
|
||||
return deposit ? `${deposit}元` : ''
|
||||
}
|
||||
|
||||
export function buildPresignLedgerRows(rows: Row[]): string[][] {
|
||||
return rows.map((row, index) => {
|
||||
const orderDate = row.orderDate
|
||||
const times = Array.isArray(row.transferTimes) ? row.transferTimes : []
|
||||
const received = formatLedgerMoney(row.confirmedDepositAmount)
|
||||
return [
|
||||
String(index + 1),
|
||||
yearText(orderDate, ''),
|
||||
@@ -488,17 +533,31 @@ export function buildPresignLedgerRows(rows: Row[]): string[][] {
|
||||
payModeText(row.payMode),
|
||||
formatLedgerDateTime(row.sampleCollectTime),
|
||||
formatLedgerMoney(row.contractAmount),
|
||||
received ? `${received}元` : '',
|
||||
presignDepositText(row.confirmedDepositAmount),
|
||||
times.map((item) => formatLedgerDateTime(item)).filter(Boolean).join(','),
|
||||
formatLedgerDate(row.expectedDeliveryDate),
|
||||
text(row.salesUserName),
|
||||
text(row.regionManagerUserName),
|
||||
text(row.remark),
|
||||
''
|
||||
presignRefundText(row)
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
/** 自存表退款金额:有退费记录写负值;无退费但已作废时写 -已缴金额。 */
|
||||
function cellStorageRefundText(summary: Row | null, voided: boolean, received: string): string {
|
||||
const explicit = summary?.refundAmount
|
||||
if (explicit != null && explicit !== '') {
|
||||
const amount = Number(explicit)
|
||||
if (Number.isFinite(amount) && amount !== 0) {
|
||||
const abs = formatLedgerMoney(Math.abs(amount))
|
||||
return abs ? `-${abs}` : ''
|
||||
}
|
||||
}
|
||||
if (!voided || !received) return ''
|
||||
return `-${received}`
|
||||
}
|
||||
|
||||
function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: boolean): string[] {
|
||||
const protocol = row.protocol
|
||||
const summary = (row.paymentSummary || protocol.paymentSummary) as Row | null
|
||||
@@ -525,7 +584,8 @@ function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: bo
|
||||
cells[6] = researchStorageStatus(status, protocol.voidType)
|
||||
cells[7] = voidReason(protocol)
|
||||
cells[8] = text(row.motherName)
|
||||
cells[9] = text(row.sampleTypeName)
|
||||
// 标准大表废弃行第 10 列复用储存状态文案;正常行写样本类型
|
||||
cells[9] = cells[6] || text(row.sampleTypeName)
|
||||
cells[10] = text(row.regionName)
|
||||
cells[11] = text(row.hospitalName)
|
||||
cells[12] = formatLedgerDateTime(row.newbornDeliveryTime)
|
||||
@@ -547,6 +607,7 @@ function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: bo
|
||||
cells[1] = yearText(collectTime, '')
|
||||
cells[2] = monthText(collectTime)
|
||||
cells[3] = dayText(collectTime)
|
||||
cells[4] = text(row.monthlyVerifyNo) || text(protocol.monthlyVerifyNo)
|
||||
cells[5] = text(row.labStorageCode)
|
||||
cells[6] = statusText(status, protocol.voidType)
|
||||
cells[7] = voidReason(protocol)
|
||||
@@ -562,6 +623,7 @@ function storageLedgerRow(row: ProtocolAuditFlatRow, index: number, research: bo
|
||||
cells[17] = formatLedgerMoney(first.due)
|
||||
cells[18] = received
|
||||
cells[19] = voided ? received : ''
|
||||
cells[20] = cellStorageRefundText(summary, voided, received)
|
||||
cells[21] = text(protocol.remark)
|
||||
cells[22] = formatLedgerDateTime(firstPaidTime)
|
||||
cells[23] = gapText(first.due, first.paid)
|
||||
@@ -591,9 +653,10 @@ export function buildCellStorageLedgerRows(rows: ProtocolAuditFlatRow[]): {
|
||||
rows: string[][]
|
||||
merges: LedgerMergeRange[]
|
||||
} {
|
||||
const freightIds = rows.map((row) => text(row.freightId))
|
||||
const built = rows.map((row, index) => storageLedgerRow(row, index, false))
|
||||
// 自存表:运输方式=53,运费=54
|
||||
const arranged = groupFlatRowsByFreightBatch(rows)
|
||||
const freightIds = arranged.map((row) => text(row.freightId))
|
||||
const built = arranged.map((row, index) => storageLedgerRow(row, index, false))
|
||||
// 自存表:运输方式=53,运费=54(对齐模版《自存表》)
|
||||
return { rows: built, merges: applyFreightMerges(built, freightIds, 53, 54) }
|
||||
}
|
||||
|
||||
@@ -601,9 +664,10 @@ export function buildResearchLedgerRows(rows: ProtocolAuditFlatRow[]): {
|
||||
rows: string[][]
|
||||
merges: LedgerMergeRange[]
|
||||
} {
|
||||
const freightIds = rows.map((row) => text(row.freightId))
|
||||
const built = rows.map((row, index) => storageLedgerRow(row, index, true))
|
||||
// 科研表:运输方式=17,运费=18
|
||||
const arranged = groupFlatRowsByFreightBatch(rows)
|
||||
const freightIds = arranged.map((row) => text(row.freightId))
|
||||
const built = arranged.map((row, index) => storageLedgerRow(row, index, true))
|
||||
// 科研表:运输方式=17,运费=18(对齐模版《科研表》)
|
||||
return { rows: built, merges: applyFreightMerges(built, freightIds, 17, 18) }
|
||||
}
|
||||
|
||||
@@ -669,6 +733,19 @@ function fileStamp(): string {
|
||||
return `${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}${pad(stamp.getHours())}${pad(stamp.getMinutes())}${pad(stamp.getSeconds())}`
|
||||
}
|
||||
|
||||
const LEDGER_CELL_BORDER = {
|
||||
top: { style: 'thin' as const },
|
||||
left: { style: 'thin' as const },
|
||||
bottom: { style: 'thin' as const },
|
||||
right: { style: 'thin' as const }
|
||||
}
|
||||
|
||||
const LEDGER_HEADER_FILL = {
|
||||
type: 'pattern' as const,
|
||||
pattern: 'solid' as const,
|
||||
fgColor: { argb: 'FFFFFF00' }
|
||||
}
|
||||
|
||||
export async function saveCollectionLedgerWorkbook(
|
||||
sheetName: string,
|
||||
headers: string[],
|
||||
@@ -680,25 +757,31 @@ export async function saveCollectionLedgerWorkbook(
|
||||
const FileSaver = (await import('file-saver')).default
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const sheet = workbook.addWorksheet(sheetName)
|
||||
const colCount = headers.length
|
||||
const headerRow = sheet.addRow(headers)
|
||||
headerRow.eachCell((cell) => {
|
||||
for (let col = 1; col <= colCount; col += 1) {
|
||||
const cell = headerRow.getCell(col)
|
||||
cell.numFmt = '@'
|
||||
cell.alignment = { wrapText: true, vertical: 'middle' }
|
||||
})
|
||||
cell.alignment = { wrapText: true, vertical: 'middle', horizontal: 'center' }
|
||||
cell.fill = LEDGER_HEADER_FILL
|
||||
cell.border = LEDGER_CELL_BORDER
|
||||
}
|
||||
rows.forEach((line) => {
|
||||
const added = sheet.addRow(line)
|
||||
line.forEach((value, index) => {
|
||||
const cell = added.getCell(index + 1)
|
||||
cell.value = value
|
||||
for (let col = 1; col <= colCount; col += 1) {
|
||||
const cell = added.getCell(col)
|
||||
cell.value = line[col - 1] ?? ''
|
||||
cell.numFmt = '@'
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
|
||||
})
|
||||
cell.border = LEDGER_CELL_BORDER
|
||||
}
|
||||
})
|
||||
merges.forEach((merge) => {
|
||||
if (merge.endRow <= merge.startRow) return
|
||||
sheet.mergeCells(merge.startRow, merge.startCol, merge.endRow, merge.endCol)
|
||||
const cell = sheet.getCell(merge.startRow, merge.startCol)
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
|
||||
cell.border = LEDGER_CELL_BORDER
|
||||
})
|
||||
headers.forEach((header, index) => {
|
||||
sheet.getColumn(index + 1).width = Math.min(28, Math.max(12, header.replace(/\n/g, '').length + 2))
|
||||
|
||||
@@ -42,10 +42,19 @@ describe('protocol-source-attachment', () => {
|
||||
})
|
||||
assert.deepEqual(
|
||||
groups.map((g) => g.label),
|
||||
['知情同意书', '孕妇健康调查表']
|
||||
['知情同意书', '孕妇健康调查表', '协议附件']
|
||||
)
|
||||
assert.ok(groups.every((g) => g.files.length === 1))
|
||||
assert.equal(groups.length, 2)
|
||||
})
|
||||
|
||||
it('accepts attachments alias for cell storage protocol files', () => {
|
||||
const groups = getSourceProtocolAttachmentGroups({
|
||||
sourceType: 'cell_storage',
|
||||
attachments: [{ id: 'alias' }]
|
||||
})
|
||||
const protocol = groups.find((g) => g.label === '协议附件')
|
||||
assert.ok(protocol)
|
||||
assert.equal(protocol.files.length, 1)
|
||||
})
|
||||
|
||||
it('groups research donation attachments for reception dialog', () => {
|
||||
|
||||
@@ -37,13 +37,17 @@ export function getConsentProtocolAttachment(
|
||||
|
||||
/**
|
||||
* 样本接收弹窗:按来源返回质检需查看的协议附件分组。
|
||||
* 细胞存储:仅知情同意书 / 孕妇健康调查表(不展示「协议附件」项)
|
||||
* 细胞存储:知情同意书 / 孕妇健康调查表 / 协议附件
|
||||
* 科研捐赠:知情同意书 / 传染病检测报告 / 其他附件
|
||||
* 兼容 attachments 别名(协议详情字段名)。
|
||||
*/
|
||||
export function getSourceProtocolAttachmentGroups(
|
||||
row: Record<string, unknown>
|
||||
): ProtocolSourceAttachmentMeta[] {
|
||||
const source = String(row.sourceType || 'cell_storage').trim()
|
||||
const protocolFiles = normalizeAttachmentFiles(
|
||||
row.protocolAttachmentFiles ?? row.attachments
|
||||
)
|
||||
if (source === 'research_donation') {
|
||||
return [
|
||||
{
|
||||
@@ -68,6 +72,10 @@ export function getSourceProtocolAttachmentGroups(
|
||||
{
|
||||
label: '孕妇健康调查表',
|
||||
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
|
||||
},
|
||||
{
|
||||
label: '协议附件',
|
||||
files: protocolFiles
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,8 @@ export function supportsStorageCertQc(sampleTypeName?: string | null): boolean {
|
||||
return resolveStorageCertQcTypeKey(sampleTypeName) != null
|
||||
}
|
||||
|
||||
const MOTHER_BLOOD: StorageCertQcField[] = [
|
||||
/** 围产期造血:母亲血液含三项核酸检测。 */
|
||||
const MOTHER_BLOOD_PERINATAL: StorageCertQcField[] = [
|
||||
{ key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
|
||||
{ key: 'mother_hbv_dna', label: '乙型肝炎病毒核酸(HBV DNA)', control: 'select_yny' },
|
||||
{ key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
|
||||
@@ -48,6 +49,15 @@ const MOTHER_BLOOD: StorageCertQcField[] = [
|
||||
}
|
||||
]
|
||||
|
||||
/** 脐带间充质:不展示 HBV DNA / HCV RNA / HIV-1RNA。 */
|
||||
const MOTHER_BLOOD_UC_MSC: StorageCertQcField[] = [
|
||||
{ key: 'mother_hbsag', label: '乙型肝炎病毒表面抗原(HBsAg)', control: 'select_yny' },
|
||||
{ key: 'mother_anti_hcv', label: '丙型肝炎病毒抗体(抗HCV)', control: 'select_yny' },
|
||||
{ key: 'mother_anti_tp', label: '梅毒螺旋体特异性抗体(抗TP)', control: 'select_yny' },
|
||||
{ key: 'mother_cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV IgM)', control: 'select_yny' },
|
||||
{ key: 'mother_anti_hiv', label: '人类免疫缺陷病毒抗体(抗HIV-1/2)', control: 'select_yny' }
|
||||
]
|
||||
|
||||
export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcGroup[]> = {
|
||||
perinatal_hsc: [
|
||||
{
|
||||
@@ -77,7 +87,7 @@ export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcG
|
||||
{ key: 'cmv_igm', label: '巨细胞病毒 IgM 抗体 (CMV-IgM)', control: 'select_yny' }
|
||||
]
|
||||
},
|
||||
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD },
|
||||
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD_PERINATAL },
|
||||
{
|
||||
title: '储存证收尾信息',
|
||||
variant: 'meta',
|
||||
@@ -120,7 +130,7 @@ export const STORAGE_CERT_QC_GROUPS: Record<StorageCertQcTypeKey, StorageCertQcG
|
||||
}
|
||||
]
|
||||
},
|
||||
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD },
|
||||
{ title: '母亲血液检测报告', variant: 'primary', fields: MOTHER_BLOOD_UC_MSC },
|
||||
{
|
||||
title: '储存证收尾信息',
|
||||
variant: 'meta',
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
<template #default="{ row }">{{ row.newbornIdCard || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="newbornSex" label="性别" min-width="70" align="center">
|
||||
<template #default="{ row }">{{ sexLabel(row.newbornSex) }}</template>
|
||||
<template #default="{ row }">{{ formatNewbornSex(row.newbornSex) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="newbornDeliveryTime" label="分娩时间" min-width="160" align="center" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.newbornDeliveryTime || '—' }}</template>
|
||||
@@ -510,6 +510,7 @@
|
||||
donationStatusTagType,
|
||||
handleDoneFlagLabel,
|
||||
handleDoneFlagTagType,
|
||||
newbornSexLabel,
|
||||
protocolStatusDisplayLabel,
|
||||
protocolStatusTagType,
|
||||
sampleCollectionFlagLabel,
|
||||
@@ -571,11 +572,11 @@
|
||||
const codeCorrectionVisible = ref(false)
|
||||
const codeCorrectionOldCode = ref('')
|
||||
|
||||
function sexLabel(sex: unknown) {
|
||||
const s = String(sex || '').trim()
|
||||
if (s === '1' || s === '男') return '男'
|
||||
if (s === '2' || s === '女') return '女'
|
||||
return s || '—'
|
||||
/** 与协议表单一致:'1'=男,'0'=女;空值显示 —(勿与客户 sexLabel 0男1女混淆) */
|
||||
function formatNewbornSex(sex: unknown) {
|
||||
const s = sex == null || sex === '' ? '' : String(sex).trim()
|
||||
if (!s) return '—'
|
||||
return newbornSexLabel(s) || '—'
|
||||
}
|
||||
|
||||
function openStorageCertQcEdit(row: Record<string, unknown>) {
|
||||
|
||||
@@ -78,15 +78,41 @@
|
||||
<el-icon><RefreshRight /></el-icon>重试
|
||||
</el-button>
|
||||
</el-alert>
|
||||
<div v-if="assignment" class="list-toolbar">
|
||||
<div class="list-toolbar-row">
|
||||
<div class="list-toolbar-selected">
|
||||
已选 <span class="sel-num">{{ selectedRows.length }}</span> 项
|
||||
</div>
|
||||
<el-button
|
||||
type="primary"
|
||||
class="btn-main"
|
||||
:disabled="!selectedRows.length || busy"
|
||||
@click="openBatch"
|
||||
>
|
||||
<el-icon><Edit /></el-icon>批量调整归属
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-body list-wrap"
|
||||
><el-table
|
||||
ref="tableRef"
|
||||
:key="query.bizType"
|
||||
:data="rows"
|
||||
v-loading="loading"
|
||||
class="list-table"
|
||||
stripe
|
||||
row-key="id"
|
||||
:empty-text="loading ? '正在加载…' : loadError ? '数据加载失败' : '暂无符合条件的单据'"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column
|
||||
v-if="assignment"
|
||||
type="selection"
|
||||
min-width="48"
|
||||
align="center"
|
||||
header-align="center"
|
||||
fixed
|
||||
/>
|
||||
<el-table-column
|
||||
type="index"
|
||||
label="序号"
|
||||
@@ -151,26 +177,26 @@
|
||||
>
|
||||
<el-form class="form-grid-2" label-position="top">
|
||||
<el-form-item label="原所属项目组">
|
||||
<el-input :model-value="selected.teamName || '未归属'" disabled />
|
||||
<el-input :model-value="originalTeamLabel" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="原所属项目">
|
||||
<el-input :model-value="selected.projectName || '未归属'" disabled />
|
||||
<el-input :model-value="originalProjectLabel" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="新项目组" required>
|
||||
<ProjectSelect
|
||||
:key="`team-${selected.id}`"
|
||||
:key="`team-${dialogSelectKey}`"
|
||||
team
|
||||
v-model="targetTeamId"
|
||||
:biz-type="selected.bizType"
|
||||
:biz-type="dialogBizType"
|
||||
@update:model-value="onTargetTeamChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新项目" required>
|
||||
<ProjectSelect
|
||||
:key="`project-${selected.id}-${targetTeamId}`"
|
||||
:key="`project-${dialogSelectKey}-${targetTeamId}`"
|
||||
v-model="target"
|
||||
:team-id="targetTeamId"
|
||||
:biz-type="selected.bizType"
|
||||
:biz-type="dialogBizType"
|
||||
:disabled="!targetTeamId"
|
||||
/>
|
||||
</el-form-item>
|
||||
@@ -262,6 +288,7 @@
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
|
||||
const rows = ref<ProjectRecord[]>([]),
|
||||
total = ref(0),
|
||||
statusCounts = ref<Record<string, number>>({}),
|
||||
@@ -273,6 +300,8 @@
|
||||
target = ref(''),
|
||||
reason = ref(''),
|
||||
selected = ref<ProjectRecord>({}),
|
||||
selectedRows = ref<ProjectRecord[]>([]),
|
||||
editTargets = ref<ProjectRecord[]>([]),
|
||||
historyVisible = ref(false),
|
||||
historyRows = ref<ProjectRecord[]>([])
|
||||
const visibleColumns = computed(() =>
|
||||
@@ -283,6 +312,39 @@
|
||||
const keywordHint = computed(() =>
|
||||
query.bizType === 'presign' ? '请输入单据编号' : assignmentKeywordHints[query.bizType]
|
||||
)
|
||||
const dialogBizType = computed(
|
||||
() =>
|
||||
(editTargets.value[0]?.bizType ||
|
||||
selected.value.bizType ||
|
||||
query.bizType) as AssignmentBizType
|
||||
)
|
||||
const dialogSelectKey = computed(() => {
|
||||
if (editTargets.value.length <= 1) {
|
||||
return String(editTargets.value[0]?.id || selected.value.id || 'empty')
|
||||
}
|
||||
const firstId = editTargets.value[0]?.id || ''
|
||||
const lastId = editTargets.value[editTargets.value.length - 1]?.id || ''
|
||||
return `batch-${editTargets.value.length}-${firstId}-${lastId}`
|
||||
})
|
||||
const originalTeamLabel = computed(() => {
|
||||
if (editTargets.value.length <= 1) {
|
||||
return editTargets.value[0]?.teamName || selected.value.teamName || '未归属'
|
||||
}
|
||||
return `共 ${editTargets.value.length} 条`
|
||||
})
|
||||
const originalProjectLabel = computed(() => {
|
||||
if (editTargets.value.length <= 1) {
|
||||
return editTargets.value[0]?.projectName || selected.value.projectName || '未归属'
|
||||
}
|
||||
return `共 ${editTargets.value.length} 条`
|
||||
})
|
||||
function clearSelection() {
|
||||
selectedRows.value = []
|
||||
tableRef.value?.clearSelection?.()
|
||||
}
|
||||
function handleSelectionChange(selection: ProjectRecord[]) {
|
||||
selectedRows.value = selection
|
||||
}
|
||||
let loadVersion = 0
|
||||
function statusLabel(status: string) {
|
||||
return query.bizType === 'presign'
|
||||
@@ -307,6 +369,7 @@
|
||||
loading.value = true
|
||||
loadError.value = false
|
||||
rows.value = []
|
||||
clearSelection()
|
||||
try {
|
||||
const fetchRecords = props.assignment
|
||||
? fetchAssignmentRecords
|
||||
@@ -344,6 +407,7 @@
|
||||
statusCounts.value = {}
|
||||
visible.value = false
|
||||
historyVisible.value = false
|
||||
clearSelection()
|
||||
search()
|
||||
}
|
||||
function switchBusiness(kind: AssignmentBizType) {
|
||||
@@ -360,16 +424,41 @@
|
||||
query.page = page
|
||||
load()
|
||||
}
|
||||
function open(row: ProjectRecord) {
|
||||
selected.value = row
|
||||
function resetDialogForm(targets: ProjectRecord[]) {
|
||||
editTargets.value = targets
|
||||
selected.value = targets[0] || {}
|
||||
targetTeamId.value = ''
|
||||
target.value = ''
|
||||
reason.value = ''
|
||||
visible.value = true
|
||||
}
|
||||
function open(row: ProjectRecord) {
|
||||
resetDialogForm([row])
|
||||
}
|
||||
function openBatch() {
|
||||
if (!selectedRows.value.length) {
|
||||
ElMessage.warning('请先选择要调整的记录')
|
||||
return
|
||||
}
|
||||
resetDialogForm([...selectedRows.value])
|
||||
}
|
||||
function onTargetTeamChange() {
|
||||
target.value = ''
|
||||
}
|
||||
async function assignOne(row: ProjectRecord, projectId: string, assignReason: string) {
|
||||
const preview = await previewAssignment({
|
||||
bizType: row.bizType,
|
||||
id: row.id,
|
||||
projectId
|
||||
})
|
||||
await saveAssignment({
|
||||
bizType: row.bizType,
|
||||
id: row.id,
|
||||
projectId,
|
||||
reason: assignReason,
|
||||
expectedAssignments: assignmentSnapshot(preview.records)
|
||||
})
|
||||
}
|
||||
async function commit() {
|
||||
if (!targetTeamId.value) {
|
||||
ElMessage.warning('请选择新项目组')
|
||||
@@ -383,33 +472,45 @@
|
||||
ElMessage.warning('请填写调整原因')
|
||||
return
|
||||
}
|
||||
const targets = editTargets.value.length ? [...editTargets.value] : [selected.value]
|
||||
if (!targets.length || !targets[0]?.id) {
|
||||
ElMessage.warning('请选择要调整的记录')
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
const commitTarget = target.value
|
||||
const commitRecord = selected.value
|
||||
const commitReason = reason.value.trim()
|
||||
const commitTargets = targets
|
||||
let successCount = 0
|
||||
let failCount = 0
|
||||
try {
|
||||
const preview = await previewAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value
|
||||
})
|
||||
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
|
||||
for (const row of commitTargets) {
|
||||
if (target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await assignOne(row, commitTarget, commitReason)
|
||||
successCount += 1
|
||||
} catch {
|
||||
failCount += 1
|
||||
// API layer already surfaces the conflict / validation message.
|
||||
}
|
||||
}
|
||||
if (target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
}
|
||||
await saveAssignment({
|
||||
bizType: selected.value.bizType,
|
||||
id: selected.value.id,
|
||||
projectId: target.value,
|
||||
reason: reason.value.trim(),
|
||||
expectedAssignments: assignmentSnapshot(preview.records)
|
||||
})
|
||||
if (selected.value !== commitRecord || target.value !== commitTarget || !visible.value) {
|
||||
return
|
||||
if (successCount > 0) {
|
||||
visible.value = false
|
||||
clearSelection()
|
||||
if (failCount === 0) {
|
||||
ElMessage.success(
|
||||
commitTargets.length > 1 ? `已调整 ${successCount} 条归属` : '归属已调整'
|
||||
)
|
||||
} else {
|
||||
ElMessage.warning(`成功 ${successCount} 条,失败 ${failCount} 条`)
|
||||
}
|
||||
await load()
|
||||
}
|
||||
visible.value = false
|
||||
ElMessage.success('归属已调整')
|
||||
await load()
|
||||
} catch {
|
||||
// API layer already surfaces the conflict / validation message.
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
+11
-23
@@ -70,21 +70,16 @@
|
||||
<dt>样本类型</dt>
|
||||
<dd>{{ row.sampleTypeName || '—' }}</dd>
|
||||
</div>
|
||||
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
|
||||
<dt>{{ attachmentMeta(row).label }}</dt>
|
||||
<div
|
||||
v-for="group in protocolAttachmentGroups(row)"
|
||||
:key="group.label"
|
||||
class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment"
|
||||
>
|
||||
<dt>{{ group.label }}</dt>
|
||||
<dd>
|
||||
<ProtocolAttachmentMiniReadonly
|
||||
:files="attachmentMeta(row).files"
|
||||
@preview="handleAttachmentPreview"
|
||||
@download="handleAttachmentDownload"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
|
||||
<dt>{{ consentAttachmentMeta(row).label }}</dt>
|
||||
<dd>
|
||||
<ProtocolAttachmentMiniReadonly
|
||||
:files="consentAttachmentMeta(row).files"
|
||||
:files="group.files"
|
||||
empty-text="暂无附件"
|
||||
@preview="handleAttachmentPreview"
|
||||
@download="handleAttachmentDownload"
|
||||
/>
|
||||
@@ -252,10 +247,7 @@
|
||||
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
|
||||
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
|
||||
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
|
||||
import {
|
||||
getConsentProtocolAttachment,
|
||||
getSourceProtocolAttachment
|
||||
} from '@/utils/protocol-source-attachment'
|
||||
import { getSourceProtocolAttachmentGroups } from '@/utils/protocol-source-attachment'
|
||||
import { useAttachmentActions } from '@/composables/useAttachmentActions'
|
||||
|
||||
defineOptions({ name: 'SampleReceptionCardList' })
|
||||
@@ -293,12 +285,8 @@
|
||||
|
||||
const { handleAttachmentPreview, handleAttachmentDownload } = useAttachmentActions()
|
||||
|
||||
function attachmentMeta(row: Record<string, unknown>) {
|
||||
return getSourceProtocolAttachment(row)
|
||||
}
|
||||
|
||||
function consentAttachmentMeta(row: Record<string, unknown>) {
|
||||
return getConsentProtocolAttachment(row)
|
||||
function protocolAttachmentGroups(row: Record<string, unknown>) {
|
||||
return getSourceProtocolAttachmentGroups(row)
|
||||
}
|
||||
|
||||
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
|
||||
|
||||
Reference in new issue
Block a user