Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e488b86c35 | ||
|
|
6a78c28121 | ||
|
|
c7905b5da0 | ||
|
|
67a209298c | ||
|
|
e2a50b33fe | ||
|
|
3d9a71da46 | ||
|
|
49e36e0bbb | ||
|
|
654f9a8904 | ||
|
|
b40e41bd87 | ||
|
|
1afbe06021 | ||
|
|
3ad1a08e30 | ||
|
|
a4425addb0 | ||
|
|
471a6823e2 | ||
|
|
d413398b3d | ||
|
|
6be55186b0 | ||
|
|
348c5c1d3c | ||
|
|
411a4cb57c | ||
|
|
6df6306ef0 | ||
|
|
57049f17b0 | ||
|
|
a214a35f4f | ||
|
|
faef6adf94 | ||
|
|
9f37181478 | ||
|
|
00635740f1 | ||
|
|
76316fad7a | ||
|
|
d4617b4988 | ||
|
|
c40feab76a | ||
|
|
9cf1110495 | ||
|
|
fefef41524 | ||
|
|
e97f47b06a | ||
|
|
a4defb2c94 | ||
|
|
8717702b58 | ||
|
|
d30dbad788 | ||
|
|
e92112e9fd |
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#问题展示)在当前回复写出「待你确认」,完成[发问前检查](references/planning.md#发问前检查)后,再按[本地确认语音](references/question-notification.md)提醒 |
|
||||
| 关闭/恢复声音提醒 | [本地确认语音](references/question-notification.md) |
|
||||
| 空白工程、大需求、技术栈或架构变化 | [建设与规格](references/planning.md) |
|
||||
| 拉取工单、理解评论和截图、续接工单或交付前核对补充 | [工单全文、评论与图片](references/issue-context.md) |
|
||||
| 正式功能、局部 Bug、界面实现 | [实施流程](references/implementation.md) |
|
||||
| 安装或更新依赖、下载工具链、配置 CI/Docker 构建源 | [依赖下载与镜像](references/dependency-downloads.md) |
|
||||
| 配置应用运行密钥,或使用服务器、仓库、设计平台等操作账号 | [运行凭据与 Agent 操作凭据](references/credentials.md) |
|
||||
| 界面实现、布局样式修改,含原型豁免与已有组件复用 | [界面一致性、原型与验收](references/ui-design.md) |
|
||||
| 切换本机运行版本、集成冒烟、恢复环境或验证失败后修复 | [本机环境验证与集成](references/local-validation.md) |
|
||||
| 界面设计或原型(含只做 Figma/其他工具设计)、界面实现、布局样式修改 | 在调用设计/生成工具前读取[界面一致性、原型与验收](references/ui-design.md);免除原型仍需核对现有界面依据 |
|
||||
| 单元测试后合入集成分支、避免多个 worktree 同时合入,或验证失败后修复 | [本机环境验证与集成](references/local-validation.md) |
|
||||
| 创建/复用隔离目录、同名工程或 worktree 命名冲突 | [Worktree 路径与命名](references/worktree-layout.md) |
|
||||
| 交付后回收或巡检遗留 worktree | [Worktree 生命周期与清理](references/worktree-lifecycle.md) |
|
||||
| 选择合并方式、创建/续接 PR、检查审查意见或合并状态 | [PR 合并方式与证据](references/pull-requests.md) |
|
||||
|
||||
@@ -23,10 +23,10 @@ python3 .agents/skills/personal-dev/scripts/pda.py delivery-record --project . -
|
||||
| `--stage` | `--evidence` 必须能定位的事实 |
|
||||
| --- | --- |
|
||||
| `waiting_user` | 待测或反馈失败,写明任务分支/worktree、提交、运行入口和待办;清除本轮通过记录 |
|
||||
| `accepted` | 测试责任、对应提交、Agent 真实测试或用户明确通过的记录,以及适用的审查结果 |
|
||||
| `integration_verified` | 最终合并内容对应的集成环境验证;纯文档可记录不适用及依据 |
|
||||
| `accepted` | 测试责任、对应提交,以及单元测试命令和结果,或用户确认该提交的定位。没有可跑的单元测试时写「无单元测试,留给用户验收」。只写「已通过」或「不适用」会被命令拒绝 |
|
||||
| `integration_verified` | 写明单元测试仍覆盖即将合入的代码树,并写上「构建验收留给用户」。这不是构建或运行已经通过 |
|
||||
| `remote_synced` | 实际仓库、集成分支与远端最终提交;PR 模式附 PR 链接、base/head、实时 merged 状态及内容映射;直接合并附明确选择依据 |
|
||||
| `environment_restored` | 实际运行版本恢复/沿用相同已验收环境的依据;不适用须有理由 |
|
||||
| `environment_restored` | 本单未切换运行环境时写明未改动;曾经切换或启动过服务时写明已恢复到的版本。缺少这条事实时保留缺项 |
|
||||
| `issue_updated` | 已写入工单的验收/交付评论链接或 ID,包含测试责任、提交及对应 PR(直接合并时说明选择依据) |
|
||||
|
||||
后五项使用相同命令形态,将 `--stage` 和 `--evidence` 换为对应真实结果。证据不确定时保留缺项,不能填 `N/A` 绕过。更新较早阶段且证据发生变化会清除下游记录;相同阶段/证据重试不重复推进。提交改变会归档旧记录并清空本轮证据,包括 squash/rebase 或合并 SHA 改变;先核对内容与环境等价性,再逐项记录可复用依据,不能自动继承旧验收。后续失败即使提交未变,也先记录 `waiting_user` 或更新相关阶段使旧完成证据失效,说明实际阻碍。
|
||||
@@ -55,6 +55,6 @@ python3 .agents/skills/personal-dev/scripts/pda.py delivery-check --project . --
|
||||
|
||||
## 最终回复与完成边界
|
||||
|
||||
每次工单交付回复必须包含:工单链接/编号、PR 链接及实际状态(直接合并则给出选择依据)、最终提交和目标分支、验收依据、工单远端状态,以及 worktree 清理结果或保留原因。状态只能写实际结果:已关闭并读回确认、待用户测试、待集成验证、待回写/关单、关单结果待核对,或用户明确暂不关闭。尚有步骤缺失时说明缺口及下一动作,不能只用“已 push、工作区干净”结束任务。
|
||||
每次工单交付回复必须包含三行结果:单元测试命令和结果,或「无单元测试,留给用户验收」;合入提交与 PR 状态;构建与运行尚未由 Agent 验收。同时写工单链接或编号、远端关闭状态,以及 worktree 清理结果或保留原因。状态只能写实际结果:已关闭并读回确认、待用户测试、待串行合入、待回写/关单、关单结果待核对,或用户明确暂不关闭。尚有步骤缺失时说明缺口及下一动作,不能只用“已 push、工作区干净”结束任务。
|
||||
|
||||
用户说“验证通过,请 push”,在已有自动收尾授权、最终版本及环境证据满足时,应继续回写、关单和读回确认;用户不必再提醒一次。必要前提缺失或请求失败时报告“代码已推送,工单收尾未完成”及具体原因。最终回复不是后台调度器;结束会话后不会自行补关单,必须在本轮完成已授权可执行的收尾,或明确保留阻碍。
|
||||
@@ -6,18 +6,18 @@
|
||||
|
||||
用户明确将“测试通过后自动合并、提交推送、关闭工单”作为工程常规流程时,按[权限与作用域](permissions.md)记录已明确授予的长期动作,含任务分支推送、创建 PR、工单回写、关闭及合入指定集成分支;占用与标签另按控制授权核对。已有明确授权沿用,不重复逐项批准。仅针对当前单的决定写本 owner/issue 记录,不提升为工程长期授权;当前 deny/撤销始终优先于旧授权。仅安装本 Skill 不构成外部操作授权,未采用此流程的工程仍按实际授权处理。Tag、部署及服务启停不在这项授权中。
|
||||
|
||||
完成条件按[测试责任与验收](testing.md)判断:默认 Agent 测试通过即收尾;用户测试模式按测试责任中的反馈/默认转测流程取得真实通过证据后收尾。重要业务变更仍需独立审查;无子代理时使用独立会话或人工审查,执行者自查不计为独立审查。
|
||||
完成条件按[测试责任与验收](testing.md)判断:默认是受影响代码的单元测试通过后收尾;用户测试模式按测试责任取得该提交的确认后收尾。整体构建验收由用户在合入后进行,不作为本步门槛。重要业务变更仍需独立审查;无子代理时使用独立会话或人工审查,执行者自查不计为独立审查。
|
||||
|
||||
## 合并、推送与关单
|
||||
|
||||
先按[工单隔离](issue-claims.md#共享环境与合并排队)核对本 owner,占用共享环境;已验收且集成验证有效时进入 ready_to_merge。取得目标分支集成锁进入 merging 后才执行远端合并/集成推送及收尾,其他会话保持待合并。锁不代替权限、测试或分支保护。
|
||||
先按[工单隔离](issue-claims.md#共享环境与合并排队)核对本 owner。单元测试证据覆盖候选代码树后进入 ready_to_merge。取得目标分支集成锁进入 merging 后才执行远端合并或集成分支推送,其他 worktree 保持等待。没有另行要求操作共享运行环境时,不申请 environment 锁,也不启动服务。锁不代替权限、单元测试或分支保护。
|
||||
|
||||
先读[PR 合并方式与证据](pull-requests.md):默认 PR,只有工程或用户明确选择才直接合并;已有明确选择保留。方式不扩大授权。Agent 每次业务 push、PR 写入、合并、工单评论和关闭前,除 queue-guard 外执行对应 [permissions-check](permissions.md#每次外部写入前检查);CLI 不自动执行这些动作。
|
||||
|
||||
1. 保存实现和证据到任务提交;按选定方式和授权推送任务分支、创建或复用 PR,并回写关联工单。未完成测试时明确标注待 Agent 测试或待用户测试,修复实现完成不等于验收通过。
|
||||
2. 按[本机环境验证与集成](local-validation.md)核对最新主分支和验收提交,准备合并结果,完成适用的集成环境冒烟;失败在任务分支修复,保留现场,不推进远端主版本。
|
||||
3. 必要验证通过或明确不适用后,记录同提交 accepted/integration_verified 并进入 ready_to_merge;申请 merging 成功后再次核对目标未变化,按选定方式及授权合并 PR 并读回合并结果,或在明确的直接合并模式推送集成分支;PR 模式必须先验证本地等价候选,不能等远端合并后才首次验证。
|
||||
4. 同步本地主分支与远端,按本机验证流程完成运行环境恢复及版本核对。证据覆盖最终代码和实际环境后,在工单记录测试责任、验收/冒烟结果、合并提交与 PR 并关闭,按[完成检查](completion.md#回写关闭和恢复)读回远端状态;推送失败、环境恢复未完成或验证缺失时保持开放。
|
||||
1. 保存实现和证据到任务提交;按选定方式和授权推送任务分支、创建或复用 PR,并回写关联工单。单元测试尚未通过时明确标注待 Agent 测试或待用户测试,代码写完不等于可以合入。
|
||||
2. 按[本机环境验证与集成](local-validation.md)核对最新集成分支和已通过单元测试的提交,准备合并结果。单元测试失败或冲突未解决时留在任务分支修复,不推进远端集成分支。不在这一步执行构建或运行冒烟。
|
||||
3. 单元测试证据覆盖候选代码树后,记录同提交 accepted/integration_verified 并进入 ready_to_merge。`integration_verified` 在这里表示串行合入前的代码树核对,以及构建验收留给用户;申请 merging 成功后再次核对目标未变化,按选定方式及授权合并 PR 并读回合并结果,或在明确的直接合并模式推送集成分支。同一集成分支同时只合入一个 worktree。PR 模式必须先让单元测试覆盖本地合并候选,不能等远端合并后才第一次跑单元测试。
|
||||
4. 同步本地集成分支与远端。本单未切换运行环境时记录未改动;改过则先恢复。工单写明单元测试结果、合并提交与 PR,并写明构建与整体验收留给用户,再按[完成检查](completion.md#回写关闭和恢复)关闭并读回远端状态。推送失败、锁未释放或单元测试证据缺失时保持开放。
|
||||
|
||||
收尾结果用 queue-finish 核验并同步 ai-done;尚有步骤缺失时保留原占用,不因已关闭、已 push 或标签错误而重新开发。
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@ python3 .agents/skills/personal-dev/scripts/pda.py queue-resource-release --proj
|
||||
|
||||
占用失败就等待,不切换别人的运行版本,不换一个名称绕过同一物理资源。资源锁不授予启停服务、迁移或部署权限。释放前按[本机环境规则](local-validation.md)恢复并记录实际版本;释放命令不执行或证明恢复。blocked 阶段若仍持有集成锁,先核对实际合并状态及环境恢复,再用同一 release 命令指定 `integration:实际集成分支` 释放;该资源不能通过 resource-acquire 取得。停止/中断不自动释放,另一会话不能按时间抢占。
|
||||
|
||||
进入 `merging` 会自动原子申请 `integration:<集成分支>` 锁;忙时保持待合并,稍后重新核对目标后尝试。取得锁后再次检查最新主分支、PR head 和候选;若变更影响验收,回实际开发/验证阶段,补验后重排。锁持续保护合并、远端同步、环境恢复、工单回写及 finish;退出 merging 阶段会释放集成锁,必须先保证没有仍在执行的集成动作。按[交付](delivery.md)执行实际 Git/平台操作,CLI 不自动合并 PR。
|
||||
进入 `merging` 会自动原子申请 `integration:<集成分支>` 锁;忙时保持待合并,稍后重新核对目标后尝试。取得锁后再次检查最新主分支、PR head 和候选;代码树相对已通过的单元测试有变化时,回任务分支重跑受影响单元测试后再排队。锁持续保护合并、远端同步、工单回写及 finish;退出 merging 阶段会释放集成锁,必须先保证没有仍在执行的集成动作。按[交付](delivery.md)执行实际 Git/平台操作,CLI 不自动合并 PR。
|
||||
|
||||
同一仓库的同一集成分支,同一时刻只允许一个 worktree 写入合并结果。其他 worktree 可以继续开发并运行各自的单元测试,合入必须等当前持锁任务离开 merging 并释放锁之后再申请。不得并行推送同一集成分支,不得换成另一个锁名,不得强推覆盖刚合入的提交。取得锁后发现目标已前进时,先退出本次合并,把最新目标合回任务分支,重跑受影响单元测试,再重新排队。这项互斥只防止同时改写集成分支,不代替单元测试,也不表示构建已经验收。
|
||||
|
||||
## 停止、转交与恢复
|
||||
|
||||
|
||||
@@ -1,48 +1,45 @@
|
||||
# 本机环境验证与集成
|
||||
|
||||
本文件定义任务版本验证、集成环境冒烟、失败修复和环境恢复的顺序。测试责任遵循[测试规则](testing.md),远端动作遵循[交付授权](delivery.md)。这是 Agent 工作流程,不是自动启停服务或部署脚本。
|
||||
本文件定义 worktree 里单元测试通过后,如何串行合入集成分支。测什么以[测试规则](testing.md)为准:worktree 只要求受影响代码的单元测试通过。整体构建、启动和关键路径由用户在合入后的集成分支上自行验收。远端动作遵循[交付授权](delivery.md)。这是 Agent 工作流程,不是自动启停服务或部署脚本。
|
||||
|
||||
## 适用范围与准备
|
||||
|
||||
应用代码、依赖、配置、构建、数据库迁移或启动方式受影响时,执行本机环境验证。纯文档等不影响运行的任务,记录“不适用”及依据,执行对应文档/脚本检查,不为走流程额外启动应用。相同代码树可复用完整业务测试证据,但不能代替环境切换后的冒烟检查。
|
||||
应用代码有行为变化时,在对应 worktree 运行受影响的单元测试。纯文档等不影响运行的任务,记录“不适用”及依据,执行对应文档或静态检查,不为此构建或启动应用。相同代码树可复用已通过的单元测试证据;冲突处理或目标更新改变代码树时重跑受影响单元测试。
|
||||
|
||||
开始前在当前工单或已有任务记录中保留最小环境清单:集成主分支与当前远端提交、任务分支/worktree、实际运行目录、前后端入口与端口、启动/停止/构建命令、配置来源、依赖/镜像/数据环境、测试责任及服务启停权限。只记录来源和可核对版本,不记录密钥值。未知项先核实,不从浏览器页面正常或 Git 当前分支推断运行版本。
|
||||
多个 worktree 可以同时开发、同时跑各自的单元测试。写入同一个集成分支必须按下一节串行,避免同时合入造成冲突或互相覆盖。
|
||||
|
||||
worktree 只隔离代码;端口、Docker 项目名/容器/卷、数据库和构建产物需另行核对。优先按工程现有方式隔离任务环境。只有一套可复用环境时,按授权切换运行目录并记录恢复方法;不覆盖其他任务配置、抢占端口、共用可破坏的数据卷或擅自清理用户改动。需要迁移、重建镜像或调整数据时单独核对权限与恢复方案,切回代码不会自动还原数据库。
|
||||
|
||||
共享环境使用前按[资源互斥](issue-claims.md#共享环境与合并排队)取得 environment:约定名称,忙则等待;同一物理环境所有会话必须用相同名称。独立任务环境可并行,共享主目录运行版本、端口及测试数据不能因有独立 worktree 就并用。释放前记录恢复版本及证据,命令不证明服务已恢复;停止或故障保留资源,未经恢复不能交给下一会话。
|
||||
默认在合入前不切换共享端口、容器、数据库或构建产物,也不为本单启动服务。用户另行要求操作共享环境时,先保留最小环境清单:集成分支与当前远端提交、任务分支/worktree、实际运行目录、入口与端口、配置来源、数据环境和启停权限。只记录来源和可核对版本,不记录密钥值。使用前按[资源互斥](issue-claims.md#共享环境与合并排队)取得 `environment:约定名称`,忙则等待;同一物理环境所有会话必须用相同名称。worktree 分开不等于运行环境已经分开。
|
||||
|
||||
## 跨工程运行环境隔离
|
||||
|
||||
同机多个仓库或同仓库多个 clone 并行时,先记录各环境的实际主机/容器运行上下文、监听地址与端口、Docker Compose 项目名、显式容器名、网络/卷、数据库/schema、缓存键前缀和对象存储路径,以及当前使用者。只记录与任务有关的来源与定位,不存凭据,也不遍历无关工程的私有文件。
|
||||
只有实际操作共享运行环境时才适用本节。同机多个仓库或同仓库多个 clone 并行时,先记录各环境的实际主机/容器运行上下文、监听地址与端口、Docker Compose 项目名、显式容器名、网络/卷、数据库/schema、缓存键前缀和对象存储路径,以及当前使用者。只记录与任务有关的来源与定位,不存凭据,也不遍历无关工程的私有文件。
|
||||
|
||||
- 默认给不同工程/任务独立的运行标识,可用[工程标识与任务短 ID](worktree-layout.md#新建命名)组合,并适配实际工具的长度与字符限制。Compose 显式使用独立项目名;固定 `container_name`、external 网络/卷和主机绑定目录仍须单独核对,不因项目名不同认定全部隔离。已有稳定环境配置优先保留,不擅自改名迁移数据。
|
||||
- 端口按工程约定分配并检查实际占用,记录最终入口;预检查不等于保留端口。启动时绑定失败则报告/换用获准的空闲端口,不停止其他工程进程抢占。服务启停仍需原有授权。
|
||||
- 测试写入使用独立数据库/schema、缓存命名空间及存储路径;是否真正隔离以应用配置和实际连接为准。不把生产数据或共享可变测试数据当作临时环境,迁移/重置另核对权限。
|
||||
- `environment:*` 和 `integration:*` 的远端锁都只在各自 Git 仓库生效。不同仓库拿到同名锁,不能证明互斥;本机停止令牌也不控制其他工程。必须共用同一物理资源时,先落实覆盖全部使用者的既有协调/排他机制,并保留实际占用证据。缺少可靠跨工程机制时暂停该环境操作、安排独立环境或交用户协调,可继续独立的代码工作;不凭口头空闲、一次端口扫描或本仓库锁宣称安全。
|
||||
- 端口按工程约定分配并检查实际占用,记录最终入口;预检查不等于保留端口。启动时绑定失败则报告或换用获准的空闲端口,不停止其他工程进程抢占。服务启停仍需原有授权。
|
||||
- 测试写入使用独立数据库/schema、缓存命名空间及存储路径;是否真正隔离以应用配置和实际连接为准。不把生产数据或共享可变测试数据当作临时环境,迁移或重置另核对权限。
|
||||
- `environment:*` 和 `integration:*` 的远端锁都只在各自 Git 仓库生效。不同仓库拿到同名锁,不能证明互斥;本机停止令牌也不控制其他工程。必须共用同一物理资源时,先落实覆盖全部使用者的既有协调机制,并保留实际占用证据。缺少可靠跨工程机制时暂停该环境操作,可继续独立的代码和单元测试。
|
||||
|
||||
当前个人智能体没有跨仓库的全局环境锁或自动端口分配器。以上是执行规则,不是安装后自动完成环境隔离;共享资源的名称不能替代真实占用核验。
|
||||
当前个人智能体没有跨仓库的全局环境锁或自动端口分配器。以上是执行规则,不是安装后自动完成环境隔离。
|
||||
|
||||
## 验证与集成顺序
|
||||
|
||||
1. **任务版本验证**:在任务分支/worktree 修复,按影响执行测试与必要构建,核对进程工作目录、容器挂载/镜像、前后端配套版本和实际入口对应待验收提交。修改后产生的新提交须有相应验证证据,不能以旧结果代替。失败继续在任务分支修复,不推进集成。
|
||||
2. **准备合并结果**:读取最新远端集成分支,与已验收提交比较,在任务或临时集成分支处理冲突。代码树变化时按原测试责任补验;相同代码树不机械重复全量测试。保留基线和候选提交,使用 Git 合并,不复制 worktree 文件覆盖主目录。
|
||||
3. **验证集成本机环境**:按[合并方式](pull-requests.md),默认 PR 走下节候选流程;工程明确选择且允许直接合并时,可先本地合并、暂不推送,然后把本机环境切到该集成版本。核对实际运行版本,按权限执行必要重建/重启。检查受影响服务启动/健康状态、前后端连通、BUG 或需求的关键操作路径及相关错误日志。依赖、配置、数据库或产物有变化时补做对应检查,不能只验证页面能打开;与任务无关的功能不要求全量重测。
|
||||
4. **更新远端主版本**:集成环境冒烟通过或有明确不适用依据后,保存同提交证据,进入 ready_to_merge,取得 integration:集成分支 锁进入 merging 后再核对目标分支未变化,按选定方式和授权合并 PR,或在明确直接合并模式推送集成分支。若目标又有新提交,退回适用阶段、释放集成锁,重新准备候选、核对证据并补验,不能用强推绕过。任务分支提前推送用于备份/审查不受此门槛限制,不能把它当作主版本已交付。
|
||||
5. **恢复与交付核对**:同步本地主分支和远端,确认本机实际运行目录/版本已恢复到本次集成结果,不遗留任务 worktree 服务或旧产物冒充主版本。环境完全沿用刚才已验收实例且代码树、产物、配置和数据条件未变时,记录等价证据,不再重复冒烟;若更换了目录、实例、挂载、镜像或其他运行条件则补做相应冒烟。全部条件满足后才按交付流程回写并关闭工单、清理本任务环境。
|
||||
1. **任务版本验证**:在任务分支/worktree 运行受影响代码的单元测试,记录命令、提交和结果。不执行构建、打包、启动或集成冒烟。失败留在任务分支修复,不进入合入。
|
||||
2. **准备合并结果**:读取最新远端集成分支,与已通过单元测试的提交比较,在任务分支上用 Git 合并处理冲突。不把 worktree 文件复制覆盖主目录。代码树变化时重跑受影响单元测试;相同代码树沿用原证据。
|
||||
3. **串行合入**:单元测试证据覆盖即将合入的代码树后,进入 ready_to_merge,按[合并排队](issue-claims.md#共享环境与合并排队)取得 `integration:集成分支` 锁再进入 merging。同一仓库、同一集成分支,同一时刻只允许一个 worktree 写入合并结果;其他任务保持等待。取得锁后再次核对目标未变化,按选定方式和授权合并 PR,或在明确直接合并模式推送集成分支。目标又有新提交时退出本次合并,重新准备候选并补跑单元测试,不能强推。任务分支提前推送只用于备份和审查,不算主版本已交付。
|
||||
4. **留给用户的整体验收**:合入成功后在工单写明构建、启动、前后端连通和关键路径由用户在该集成分支上后续验收。这项记录表示尚未由 Agent 执行,不能写成通过。同步本地集成分支与远端。本单没有切换本机运行环境时,记录未改动及这条理由;曾经改动过运行环境时,先恢复到本次集成结果再继续收尾。
|
||||
5. **回写与关单**:上述单元测试、串行合入和记录都完成后,才按交付流程回写并关闭工单。构建验收未做不阻止这一步,但工单评论必须保留“构建与整体验收留给用户”。
|
||||
|
||||
## PR 合并前的候选验证
|
||||
|
||||
先以最新目标分支和任务提交准备本地临时集成候选,用工程约定的主分支运行条件验证该候选,记录目标 SHA、候选代码树和环境证据;PR 流程不能变成“先远端合并,再首次验证”。本地主分支可以暂留原提交,避免生成与平台合并策略不同的本地历史。
|
||||
先以最新目标分支和任务提交准备本地合并候选,确认单元测试证据覆盖该候选代码树,再合入远端。记录目标 SHA、候选代码树和单元测试证据。本地主分支可以暂留原提交,避免生成与平台合并策略不同的本地历史。
|
||||
|
||||
合并前核对 PR 的 base/head、拟合并内容及所需 CI 检查;平台使用 squash/rebase 时提交号可变化,但最终内容必须对应已验收候选。无法证明结果一致或目标已更新时重新准备并验证,不把未验证的变化直接合入远端。合并后拉取平台结果,安全同步本地主分支,按上节完成运行环境恢复核对。不要通过自动关单关键字提前绕过环境恢复与交付核对。
|
||||
合并前核对 PR 的 base/head、拟合并内容及仓库已有的必需检查。平台使用 squash/rebase 时提交号可变化,最终内容必须对应已跑过单元测试的候选。无法证明结果一致或目标已更新时重新准备并补跑受影响单元测试。合并后拉取平台结果,安全同步本地集成分支。不通过自动关单关键字跳过收尾记录。
|
||||
|
||||
## 失败、用户自测与停止
|
||||
|
||||
- **任务测试或集成冒烟失败**:记录失败阶段、提交、入口和原因,暂停远端主分支更新与关单。保留未推送合并、用户改动和诊断依据,在原任务分支/worktree 修复后重新进行必要测试、准备合并结果和冒烟;不直接在主分支补丁式修改。恢复本地主分支或运行环境时先核查未提交/未推送内容,不用破坏性重置掩盖失败;数据迁移失败另按已授权方案处理。
|
||||
- **远端已合并但恢复检查失败**:如实记录远端已变化,保持工单未完成,停止后续发布,继续任务分支修复或按已有授权制定回退方案;不强制改写远端历史、不宣称尚未推送。仓库级环境问题也不能在队列中跳单继续使用故障环境。
|
||||
- **用户自测**:任务验证和集成冒烟都由用户执行,Agent 只做源码/Git 核对并交付对应分支、提交、运行入口和步骤。用户仅确认任务版本通过,不能代替不同环境中的集成冒烟;分别取得证据。是否转由 Agent 测试仍按既有反馈规则,用户要求必须等回复时保持等待。
|
||||
- **无服务启停权限**:说明需要用户执行的准确操作及验证入口,记录待操作/待验证;不自行启停、不因超时绕过工程禁令。可完成独立工作,必要环境验证未完成前不更新远端主分支或关单。
|
||||
- **用户停止队列**:停止优先,保存阶段和当前运行环境说明,不继续测试、合并、推送或为收尾擅自重启;必要环境恢复按已有授权或交用户操作。续接先核对实际环境和远端状态。
|
||||
- **单元测试失败或冲突未解决**:记录失败阶段、提交和原因,暂停远端集成分支更新与关单。保留未推送合并和用户改动,在原任务分支/worktree 修复后重新跑必要的单元测试并重新排队合入。不直接在集成分支上打补丁。恢复时先核查未提交内容,不用破坏性重置掩盖失败。
|
||||
- **远端已合并但本地同步失败**:如实记录远端已变化,保持工单未完成,继续按已有授权处理;不强制改写远端历史。
|
||||
- **用户自测**:单元测试和约定检查由用户执行,Agent 只做源码和 Git 核对,并交付分支、提交和步骤。用户确认该提交通过后才进入串行合入。构建与整体运行验收仍在合入之后由用户完成。用户要求必须等回复时保持等待。
|
||||
- **用户停止队列**:停止优先,保存阶段说明,不继续测试、合并或推送。续接先核对实际分支、锁和远端状态。
|
||||
|
||||
交付证据统一保留在既有工单/任务记录中:任务与集成提交、验证责任、运行来源与入口、测试/冒烟结果或不适用依据、环境恢复状态和未解决项。无需为每次切分支新建永久报告。
|
||||
交付证据统一保留在既有工单或任务记录中:任务与集成提交、单元测试命令和结果、串行合入结果、构建验收留给用户的说明,以及未解决项。无需为每次合入新建永久报告。
|
||||
@@ -24,24 +24,46 @@
|
||||
4. **决定后落实**:在现有工单或决策入口记录答案、范围及依据,区分“用户明确确认”和“按默认策略超时采用推荐”,更新相关文档后继续,不新增重复报告、不重复询问同一已确认事项。已有文档不构成业务规则已获批准的证据。
|
||||
5. **中途出现疑问**:暂停受影响部分,说明未知项及影响并澄清;保留独立工作进度。若新结论改变原有范围或已批准原型,按对应确认流程处理,不能用前一次确认覆盖新的决定。
|
||||
|
||||
发出确认问题后按[本地语音提醒](question-notification.md)播放固定音频;一次提醒不改变该问题的等待或批准条件。
|
||||
发出确认问题后按[本地语音提醒](question-notification.md)播放固定音频;一次提醒不改变该问题的等待或批准条件。问题正文先按下一节展示,再播放。
|
||||
|
||||
## 问题展示
|
||||
|
||||
需要用户确认时,先把全部题目写进**当前这条用户可见回复**,标题用「待你确认」。这份清单就是问题本身。写在工单草稿、任务记录、工具参数或语音里,都还不算已经发问。清单完成后再播放提示音,并从此刻开始计时。
|
||||
|
||||
每题占一段,包含问题、带「(推荐)」的推荐项、理由与主要影响、未回复时的处理。还有其他可选项时写上「备选」。多题一次列完。示例:
|
||||
|
||||
```text
|
||||
待你确认
|
||||
|
||||
1. 问题:这次修改哪些端?
|
||||
推荐:Web 与移动 OA(推荐)
|
||||
理由:工单截图来自这两端,共用接口也要一起改。
|
||||
未回复时:发送后 60 秒按此推荐继续。其余端本次不改。
|
||||
```
|
||||
|
||||
必须等待的题,最后一行写「未回复时:本题保持等待。」宿主问题卡片可以同时使用,卡片内容与这份清单一致。回复正文里没有「待你确认」时,不播放提示音,也不开始 60 秒计时。
|
||||
|
||||
## 发问前检查
|
||||
|
||||
每次发送确认问题(含调用宿主提问工具)前,逐题完成以下检查;缺项先改题,再发送。这里是 Agent 的执行步骤,尚无自动拦截宿主问题卡片的校验器。
|
||||
每次发送确认问题(含调用宿主提问工具)前,逐题完成以下检查;缺项先改题,再按[问题展示](#问题展示)写入当前回复。这里是 Agent 的执行步骤,尚无自动拦截宿主问题卡片的校验器。
|
||||
|
||||
1. **区分决定与事实**:明确本题让用户选择的下一步,以及仍待核实的事实。“已放行”“已登录”“已测试通过”只能由用户回复或实际核实支持;改为推荐核实路径或已授权的独立工作,不把事实确认设为超时默认答案。
|
||||
2. **展示推荐**:有选项时,推荐项放第一项,标签明确带“(推荐)”;宿主指定其他标记格式时遵循宿主格式。题干写出推荐理由、主要影响和未完成项,选项与题干指向同一动作。纯文本/自由输入题也在题干写出推荐下一步;没有可执行动作时推荐保留待办并等待所需信息。默认排序、预选和语音提醒均不能代替可见推荐。
|
||||
2. **展示推荐**:按[问题展示](#问题展示)把推荐写入「待你确认」。有选项时推荐项放第一项,标签明确带“(推荐)”;宿主指定其他标记格式时,卡片里遵循宿主格式,回复正文仍保留这份清单。题干写出推荐理由、主要影响和未完成项,选项与题干指向同一动作。纯文本/自由输入题也在题干写出推荐下一步;没有可执行动作时推荐保留待办并等待所需信息。默认排序、预选和语音提醒不能代替这份可见清单。
|
||||
3. **写清等待方式**:对当前授权内可委托选择的下一步,写明“发送后 60 秒未回复,按默认策略采用此推荐”;缺授权、用户明确等待、宿主要求必答或其他前置条件不满足时,写明“本题保持等待,不能通过超时继续受阻操作”。若存在已授权独立工作,明确其范围和仍受阻部分,必要时将“下一步怎么安排”与“操作是否完成”分开;没有独立工作时不承诺到期推进。
|
||||
4. **确认能执行计时**:确认有可用的异步提问与时间工具;实际发送后记录发送时间、题目版本、推荐动作和截止时间,继续处理新回复并按下一节决定是否执行。若宿主提问会阻塞到用户答复、会话即将结束或无法可靠计时,先说明不能保证 60 秒自动继续,默认采用是 Agent 调整后续动作,不代用户点击、提交答复或假定卡片自动关闭;也不擅自启动后台调度。
|
||||
5. **修复漏项旧题**:已发送的题若遗漏推荐、推荐标记或超时说明,补齐后重新发送完整问题,注明替代原题、原题不作超时选择;从重发时重新计时,不能按旧发送时间直接采用。重发前先检查新回复:用户已有明确答案时直接沿用,不重复询问。
|
||||
|
||||
端口放行示例(已授权服务器内部只读检查,仍有内部检查未完成):
|
||||
|
||||
> 当前公网入口未验证,缺少云控制台操作条件。推荐先完成剩余内部检查,将公网访问保留为待放行后复验,避免阻塞独立工作。发送后 60 秒未回复,按默认策略采用该安排;不会据此认为端口已经放行,也不标记公网验收通过。
|
||||
>
|
||||
> 1. 继续内部检查,保留公网待验证项(推荐)
|
||||
> 2. 我已放行端口,请复验公网入口
|
||||
```text
|
||||
待你确认
|
||||
|
||||
1. 问题:公网入口尚未验证,下一步怎么安排?
|
||||
推荐:继续内部检查,保留公网待验证项(推荐)
|
||||
备选:我已放行端口,请复验公网入口
|
||||
理由:缺少云控制台操作条件,内部检查尚未完成。按推荐继续不会把端口记为已放行,也不标记公网验收通过。你明确选择备选时再按已有授权复验。
|
||||
未回复时:发送后 60 秒按此推荐继续。
|
||||
```
|
||||
|
||||
收到第二项明确答复后按已有授权复验;原任务已授权独立探测时,也可据真实结果核实公网状态,不写成用户确认。内部检查已全部完成时,不虚构剩余任务:推荐保存部署结果和公网待验证项,等待用户放行;已授权的内部部署与尚未通过的公网验收分别报告。
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
| --- | --- | --- |
|
||||
| 未完成的需求/领域澄清 | Matt Pocock grilling | 只处理尚未确认决策;已有阶段证据直接移交 |
|
||||
| Bug 根因定位 | Superpowers systematic-debugging | 核对实际正文与依赖,使用一个调试主流程 |
|
||||
| 实现测试循环 | Superpowers test-driven-development | 仅 Agent 测试模式按任务范围执行;用户明确自测时不运行,不另叠加 Matt tdd |
|
||||
| 最终结果核验 | Superpowers verification-before-completion | 按测试责任读取命令证据或用户验收确认,不把用户测试模式变成自动测试,不代替独立审查 |
|
||||
| 实现测试循环 | Superpowers test-driven-development | 仅 Agent 测试模式按任务范围执行;范围为受影响代码的单元测试,不包含构建或整体运行验收。用户明确自测时不运行,不另叠加 Matt tdd |
|
||||
| 最终结果核验 | Superpowers verification-before-completion | 按测试责任读取单元测试命令证据或用户对应该提交的确认;构建与整体验收留给用户,不把未执行的构建记为通过,也不代替独立审查 |
|
||||
| 规格记录 | OpenSpec CLI/宿主入口 | 读取工程 config,按工程指定产物归属保存 |
|
||||
| 新页面、新弹窗或视觉改版 | Figma MCP | 按[原型与确认流程](ui-design.md)核对用户选择、默认策略到期采用或明确豁免;实际授权与连接可用,按宿主必需技能操作 |
|
||||
| 新页面、新弹窗或视觉改版 | Figma MCP | 调用前按[界面一致性、原型与确认流程](ui-design.md)取得并传递现行视觉依据,再核对版本选择或明确豁免;实际授权与连接可用,按宿主必需技能操作 |
|
||||
|
||||
读取实际候选技能及它引用的依赖后才决定调用。本版 doctor 的入口摘要匹配只说明一个文件相同,不能证明完整依赖已固定或行为兼容;候选组合仍待场景验收。
|
||||
|
||||
|
||||
@@ -13,19 +13,19 @@ PR 创建/合并权限不足、连接失败或平台不支持时,保留任务
|
||||
## 创建与续接
|
||||
|
||||
1. 核对工程实际仓库、工单、配置的目标分支与任务 head。先查同仓库、同 base/head 的现有 PR,再核对工单和差异;复用属于本任务的 PR,多个候选先澄清。中断重试也先查远端,不能仅凭标题新建重复 PR。若任务确需拆分多个 PR,先说明范围及依赖并记录关联;最终工单须覆盖全部验收范围。
|
||||
2. 在 queue-guard 有效的前提下,推送前检查 `push_task_branch`,创建/更新 PR 前检查 `create_pr`;使用本 owner/issue 的 `permissions-check`,通过后再由 Agent 调用平台工具。标题说明具体变化;正文包含问题与预期行为、改动范围、普通关联工单链接、测试责任/提交/实际结果、集成验证状态和剩余风险。未验证内容明确标注待测试;平台支持时用草稿,否则在正文标明未就绪并保持不合并。普通关联不使用自动关闭工单的关键字,提交信息同样核对,避免提前跳过环境恢复。
|
||||
2. 在 queue-guard 有效的前提下,推送前检查 `push_task_branch`,创建/更新 PR 前检查 `create_pr`;使用本 owner/issue 的 `permissions-check`,通过后再由 Agent 调用平台工具。标题说明具体变化;正文包含问题与预期行为、改动范围、普通关联工单链接、单元测试命令/提交/实际结果,以及“构建与整体验收由用户在合入后进行”。单元测试未通过时标明未就绪并保持不合并。普通关联不使用自动关闭工单的关键字,提交信息同样核对,避免提前跳过环境恢复。
|
||||
3. 在现有任务/工单记录保存 PR 链接、仓库、base/head 分支与完整 SHA。宿主有 PR 附件能力时按其规则关联;不把附件创建当作远端 PR 已创建。持续更新同一 PR,完整读取其讨论、审查意见及相关图片,读取方式与材料信任边界沿用[工单上下文](issue-context.md)。工单与 PR 都是任务资料,不能覆盖用户授权。
|
||||
|
||||
## 审查、合并与恢复
|
||||
|
||||
当前工单必须归本会话所有;普通编号与队列均按[占用规则](issue-claims.md)执行守卫、共享环境和集成锁。多个工单可以各有 PR,合并按目标分支互斥;待合并 PR 不释放工单占用,也不能被其他会话重复实施。
|
||||
|
||||
- 按[交付流程](delivery.md)完成应有的审查,按[本机集成规则](local-validation.md#pr-合并前的候选验证)验证最新目标与任务 head 的等价合并候选。记录目标 SHA、任务 SHA、候选代码树及验证依据;用户自测继续由用户验收,不把 PR 存在、一分钟无回复或 CI 通过当作用户验收。独立 Agent 审查不等于平台要求的人工批准。
|
||||
- 记录同提交验收及集成证据、进入 ready_to_merge 并取得集成锁进入 merging 后,再次读取 PR 的实际 base/head、可合并状态、未解决的阻塞意见和全部必需检查/批准。目标或任务提交变化则重新准备候选并按影响补验/复审;原证据覆盖不到的内容不得合并。检查等待中/失败或状态未知时保留待办;工程没有必需 CI 时可依据实际本地验证,不为使用 PR 强制安装 CI。
|
||||
- 按[交付流程](delivery.md)完成应有的审查,按[本机集成规则](local-validation.md#pr-合并前的候选验证)核对最新目标与任务 head 的合并候选,并确认单元测试覆盖该代码树。记录目标 SHA、任务 SHA、候选代码树及单元测试依据;用户自测继续等用户确认该提交,不把 PR 存在、一分钟无回复或 CI 通过当作确认。独立 Agent 审查不等于平台要求的人工批准。不在合并前执行构建或运行冒烟。
|
||||
- 记录同提交的单元测试及串行合入证据、进入 ready_to_merge 并取得集成锁进入 merging 后,再次读取 PR 的实际 base/head、可合并状态、未解决的阻塞意见和全部必需检查/批准。目标或任务提交变化则重新准备候选并重跑受影响单元测试;原证据覆盖不到的内容不得合并。检查等待中/失败或状态未知时保留待办;工程没有必需 CI 时依据单元测试证据合入,不为使用 PR 强制安装 CI 或先做构建。
|
||||
- 在实际合并前再次通过 queue-guard 和本 owner/issue 的 `permissions-check --capability merge`,再根据仓库允许的 merge/squash/rebase 策略合并,使用工具可用的预期 head 等并发保护。无法保证目标仍对应验收候选时停止重算,不用强推补救。已有授权且条件齐全时 Agent 执行合并,无需额外人工点击。
|
||||
- 请求超时或续接时先读回实际状态。必须确认 PR 已 merged、目标正确,并取得平台实际合并结果 SHA 和验收内容映射;仅 closed 且未合并不算完成,保留工单及恢复线索,查明关闭原因后按授权续接。合并结果还须可达目标远端分支。squash/rebase 允许 SHA 不同,须证明最终内容等价,不能拿任务 SHA 冒充最终提交。
|
||||
- 远端合并后按[完成检查](completion.md)同步本地、恢复并核对环境,回写工单、关闭且读回,再按[worktree 生命周期](worktree-lifecycle.md)清理。环境恢复失败仍为未完成;平台若已自动关单也不能据此宣布成功,记录真实状态和缺口。PR 合并不授权删除远端分支;沿用现有清理策略。
|
||||
- 远端合并后按[完成检查](completion.md)同步本地、回写工单、关闭且读回,再按[worktree 生命周期](worktree-lifecycle.md)清理。本单未改运行环境时记录未改动;改过而恢复失败则仍为未完成。平台若已自动关单也不能据此宣布成功,记录真实状态和缺口。PR 合并不授权删除远端分支;沿用现有清理策略。
|
||||
|
||||
直接合并例外仍执行同等测试、审查、集成验证、远端同步和关单检查,在证据中注明选择来源与无 PR 原因。PR 模式的合并证明写入 remote_synced 证据,最终报告给出 PR 链接及实际状态;缺证据时不能借直接模式补写完成。
|
||||
直接合并例外仍执行同等单元测试、审查、串行合入、远端同步和关单检查,在证据中注明选择来源与无 PR 原因。构建验收同样留给用户。PR 模式的合并证明写入 remote_synced 证据,最终报告给出 PR 链接及实际状态;缺证据时不能借直接模式补写完成。
|
||||
|
||||
工单队列输入仍排除 PR,PR 是当前工单的交付记录。当前单完成或按队列规则标记阻塞后才推进下一单;停止优先于 PR 创建、推送、合并与清理。
|
||||
@@ -1,10 +1,10 @@
|
||||
# 确认问题的本地语音提醒
|
||||
|
||||
向用户发出需要答复的确认问题后,默认在用户使用的本机播放一次固定提示:“有问题待确认,请及时处理”。语音只提醒有待办,内容不含问题正文、账号或业务信息;实际选项与推荐仍通过文字发送。
|
||||
向用户发出需要答复的确认问题后,默认在用户使用的本机播放一次固定提示:“有问题待确认,请及时处理”。语音只提醒有待办,内容不含问题正文、账号或业务信息。要确认的题目、推荐和未回复时的处理,按[问题展示](planning.md#问题展示)写在同一条用户可见回复的「待你确认」清单里。
|
||||
|
||||
## 触发与顺序
|
||||
|
||||
1. 先核对用户/工程的静音选择。实际发送问题后,记录该批问题的发送时间、推荐及范围,再播放;不能在问题尚未发出时声称已提醒。
|
||||
1. 先核对用户/工程的静音选择。当前回复已经写上「待你确认」清单后,记录该批问题的发送时间、推荐及范围,再播放。回复里还没有这份清单时不播放,也不把这一轮当作已经发问。
|
||||
2. 每批问题只播放一次,多题合并发送共用一次提醒。将“已尝试提醒/结果”记在现有任务记录;同一批等待、轮询、重试和会话恢复不重复播放。真正重新提出的新问题或用户要求再提醒时才播放下一次。
|
||||
3. 执行以下已安装入口。它只读取随包预生成的本地 WAV,并调用播放器;不联网、不调用语音生成服务、不在提问时临时生成语音。命令最多等待播放器 12 秒,不另开后台常驻进程。
|
||||
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
|
||||
## 选择模式
|
||||
|
||||
默认由 Agent 测试。用户明确“我来测试/我自己验收”时,该任务切为用户测试;不是全工程永久切换。将模式、任务分支、worktree 路径和待验收提交记录在工单中,不重复询问已明确的选择。
|
||||
默认由 Agent 在任务 worktree 运行单元测试。用户明确“我来测试/我自己验收”时,该任务切为用户测试;不是全工程永久切换。将模式、任务分支、worktree 路径和待验收提交记录在工单中,不重复询问已明确的选择。
|
||||
|
||||
模式决定执行责任,不改变验收条件。缺少环境、平台或必要证据时如实报告阻碍,不自动把未完成测试视为通过。
|
||||
模式决定谁运行单元测试。整体构建、启动和关键路径由用户在代码合入集成分支之后自行验收。Agent 不把构建或运行冒烟当作 worktree 的完成条件,也不写成已经通过。缺少单元测试条件时如实报告阻碍,不把未运行的测试记为通过。
|
||||
|
||||
## Agent 测试(默认)
|
||||
|
||||
Agent 在保留本地改动的前提下自动切换到任务分支或进入对应 worktree,核对实际服务目录、端口和运行代码。分支切换与服务启停分别受授权约束;工程禁止启停服务时仍遵守,不能因自动测试而自行重启。
|
||||
Agent 在保留本地改动的前提下进入任务分支或对应 worktree,对本次改动影响的代码运行单元测试,记录实际命令、提交、结果和缺口。到此为止:不执行构建、打包、启动服务、界面运行验收或集成冒烟。工程没有可运行的单元测试时,记录不适用及依据,并完成与改动相符的静态检查。
|
||||
|
||||
按影响运行测试、构建及必要的平台检查,记录实际命令、提交、结果和缺口。测试通过且审查无阻塞后执行[交付流程](delivery.md),不额外等待用户再说“合并”。失败或未完成时继续处理,不能合并或关单。
|
||||
单元测试通过且审查无阻塞后,按[本机环境验证与集成](local-validation.md)串行合入工程配置的集成分支。这项流程里说的主分支,就是该集成分支。不额外等待用户再说“合并”。单元测试失败或尚未完成时留在该 worktree 修复,不合入、不关单。
|
||||
|
||||
## 用户测试(明确指定)
|
||||
|
||||
Agent 完成实现和代码审查,不运行自动测试、构建或替代性的业务验收;Git 状态、源码差异及提交核对仍执行。工单标注“待用户测试”,不能宣称已测试通过,也不提前合并、关闭。
|
||||
Agent 完成实现和代码审查,不运行单元测试、构建或替代性的业务验收;Git 状态、源码差异及提交核对仍执行。工单标注“待用户测试”,不能宣称单元测试已通过,也不提前合入、关闭。
|
||||
|
||||
交付时明确提醒用户切到哪个任务分支,提供 worktree 路径、提交、测试入口、覆盖点和预期结果。核对已知运行目录,指出需要配套运行的前端/H5/后端;不能仅提示刷新页面而让用户测试旧主分支。用户要求 Agent 帮忙切分支时直接执行并保留本地改动,按工程规则由用户启停服务。
|
||||
交付时明确提醒用户切到哪个任务分支,提供 worktree 路径、提交、测试入口、覆盖点和预期结果。用户要求 Agent 帮忙切分支时直接执行并保留本地改动。
|
||||
|
||||
交付用户测试前按[收尾记录](completion.md#关联与进度)保存待测版本和入口。用户明确确认任务提交通过后恢复原工单,记录通过依据并进入下述集成验证与收尾流程,仍需取得适用的集成环境验收证据。按默认启用的一分钟策略,且没有明确关闭或必须等待约束时,反馈问题同时推荐“若 60 秒无回复,转由 Agent 完成该版本的真实测试及视觉验证,通过后收尾”,按[统一策略](planning.md#提问推荐与一分钟默认选择)展示并计时;到期可切换并记录测试责任,再实际执行必要测试。不得将沉默记为“用户验收通过”,测试未做、失败或环境缺失时不合并关单。用户新近要求“必须等我”或继续报告问题时尊重指令与失败事实,修复后重新提供推荐与验证安排。
|
||||
用户明确确认该提交的检查通过后,再进入串行合入。整体构建验收仍在合入之后由用户完成。按默认启用的一分钟策略,且没有明确关闭或必须等待约束时,反馈问题同时推荐“若 60 秒无回复,转由 Agent 完成该版本受影响代码的单元测试,通过后串行合入”,按[统一策略](planning.md#提问推荐与一分钟默认选择)展示并计时;到期只补跑单元测试。不得将沉默记为用户验收通过或构建已通过。用户新近要求“必须等我”或继续报告问题时尊重指令与失败事实,修复后重新提供推荐与验证安排。
|
||||
|
||||
## 合并与本机环境验证
|
||||
## 合入与后续验收
|
||||
|
||||
按[本机环境验证与集成](local-validation.md)安排任务验证、合并结果检查、集成环境冒烟和运行环境恢复。相同代码树可复用完整测试结果,不能免除不同运行环境的验证;目标更新或冲突处理改变代码时按同一测试责任补验。用户模式下集成冒烟仍由用户执行,不把任务分支的通过自动推广到另一运行环境。必要验证未完成前不更新远端主分支或关闭工单。
|
||||
按[本机环境验证与集成](local-validation.md)准备合并结果,并按[共享环境与合并排队](issue-claims.md#共享环境与合并排队)保证同一集成分支同时只有一个 worktree 在合入。目标更新或冲突处理改变代码时,重跑受影响单元测试,通过后再合入。合入记录写明构建与整体运行验收留给用户。必要单元测试未完成,或合并尚未按串行规则完成前,不更新远端集成分支,不关闭工单。
|
||||
@@ -1,21 +1,30 @@
|
||||
# 界面一致性、原型与验收
|
||||
|
||||
适用于界面实现和布局样式修改。新页面、新弹窗及用户要求的视觉改版须经过下述 Figma 确认;纯文案纠错、无视觉变化的后台逻辑和恢复已确认设计的局部修复不新增原型,按影响验证。
|
||||
适用于界面设计、原型生成、界面实现和布局样式修改,包括只做 Figma 设计、临时 HTML 预览或用户明确免除原型后的直接编码。在调用设计/生成工具前就读取本规则,不等业务实现时再补参考。只要求设计时保持设计范围,不因此开始业务开发。新页面、新弹窗及用户要求的视觉改版须经过下述 Figma 确认;纯文案纠错、无视觉变化的后台逻辑和恢复已确认设计的局部修复不新增原型,按影响验证。
|
||||
|
||||
## 先确定视觉依据
|
||||
|
||||
在现有工单或设计记录中留下简短的“视觉依据”,不为每次任务新增永久报告:
|
||||
已有工程默认延续目标应用现行风格。新增页面或“美化一下”本身不代表重做全站视觉;已有明确改版决定时按其范围执行。先完成以下核对,再开始画原型或生成界面:
|
||||
|
||||
- **同端同类参考**:查目标端规范、组件目录及相近业务,记录可用参考的路径/节点、适用场景。区分确认提示、表单编辑、选择器和详情;按交互类型选择,不能仅因名称含 Dialog 就套用。已有参考可运行时按测试责任查看实际效果;不可查看时说明缺口。
|
||||
- **复用决定**:记录复用的组件与变体、Token 来源,以及本次差异及原因。优先复用符合规范的完整组件;只有能力缺口明确时才局部扩展或新增,避免复制一组 CSS 后混搭原生控件。没有同类参考时,以目标端规范建立候选设计。
|
||||
- **冲突处理**:已明确的本任务用户决定与原型、工程现行规范共同作为依据;旧实现和旧 Token 是待核对证据。发现冲突先追溯适用范围,无法判定再询问具体差异,不凭最近找到的页面定标准。任务级视觉例外不自动修改全局规范。
|
||||
1. **定位现行规则与代码**:查目标端规则、UI 规范、布局壳、组件目录、主题与 Token 配置,以及同类页面的实际调用。确认当前主题、组件变体和局部样式覆盖;不能只读框架名称或默认组件文档,就把库的默认样式当成工程风格。
|
||||
2. **查看同端同类实际界面**:优先选一个最接近本任务的页面或组件,必要时补充参考,不要求扫描全站。区分确认提示、表单编辑、选择器和详情,不能只因名称含 Dialog 就套用。工程已在运行且允许查看时,先核对访问地址、路由、应用和可确定的运行版本,再按[测试责任](testing.md)查看实际效果;记录视口、主题、代表状态及截图或其他可回看的视觉证据。未知版本明确标注,不把其他应用、旧构建或设计稿冒充当前界面。不为取参考擅自启停或重启服务;环境操作遵循[本机环境规则](local-validation.md)。
|
||||
3. **提取复用决定**:将布局与信息密度、导航/页头、字体层级、颜色、间距、圆角、边框/阴影、图标,以及受影响控件的尺寸与状态,映射到实际组件、变体和 Token 来源。只记录本任务相关项;没有 Token 的工程引用有效样式定义,不强制先建设设计系统。优先复用完整组件,能力缺口明确时才局部扩展或新增,不复制一组 CSS 后混搭默认控件。
|
||||
4. **处理差异与缺口**:已明确的任务决定和工程现行规范共同限定范围;旧页面、旧 Figma 与旧 Token 都须核对适用性。存在多套风格时优先采用目标模块现行规范及有效参考,不从其他端随意混搭。无法判断且会影响设计时,只询问具体差异;无关部分可继续。新工程无参考时建立候选视觉方向并按原型流程确认,不能把已有工程误当空白工程自由发挥。
|
||||
|
||||
颜色、字号、间距、圆角及控件状态来自所选依据;Figma 也复用这些依据。跨端共享业务语义,各端保留其已确认设计体系。
|
||||
在现有工单、设计记录或本次任务上下文中留下简短“视觉依据”,并在设计前向用户概述参考与复用选择,不另开一轮批准或新增永久报告。至少可追溯到:参考路由/页面/节点与视觉证据、组件和主题/样式路径、本次保留的特征、必要差异及原因、尚未验证项。单写“保持现有风格”不算完成。已有同任务证据仍适用时直接复用,主题、范围或版本改变后只补核对受影响部分。
|
||||
|
||||
无法访问运行界面时,先利用已有可信截图、组件示例和代码查证,并说明未观察的部分。用户自测或禁止 Agent 浏览/运行时遵守限制,使用已有材料或交付需要用户核对的项目,不擅自转成 Agent 测试。已有证据足以支持的设计可继续,不能声称已完成实际对照;关键风格无法确定时暂停依赖该选择的设计,只补必要信息,不用通用模板填补未知。
|
||||
|
||||
### 把视觉依据传给设计工具
|
||||
|
||||
向 Figma、其他已授权的设计/生成工具或执行者交接时,带上上述参考及复用映射、保留项和允许变更范围,不能只传业务需求或一句“现代简洁”。使用目标工具实际可访问的节点、素材或脱敏视觉说明;不可访问的链接不当作已读。参考采集不授权把运行中的业务数据上传到外部工具。
|
||||
|
||||
Figma 有匹配的组件库、变量或样式时优先复用并核对与当前工程的一致性;只有代码资产时,据已查证的视觉依据构建可编辑原型并保留映射,不虚称已经导入组件库。其他工具及直接编码沿用相同依据;更换工具或免除 Figma 不免除风格继承,也不自动替代下述 Figma 原型要求。跨端共享业务语义,各端保留其已确认设计体系。
|
||||
|
||||
## 先设计,再实现
|
||||
|
||||
1. 按上述步骤确定视觉依据与影响范围。简单、单字段、已有匹配组件或时间紧,都不免除本流程;复用资产应体现在原型中。
|
||||
2. 使用 Figma MCP 创建可编辑原型,覆盖任务涉及的主要尺寸、输入、加载、错误和提交状态。检查文字、间距、按钮、滚动及安全区;向用户提供具体文件/节点链接和可查看的预览。
|
||||
2. 使用 Figma MCP 创建可编辑原型,覆盖任务涉及的主要尺寸、输入、加载、错误和提交状态。检查文字、间距、按钮、滚动及安全区,并与选定的同端参考对照风格;修正未经决定的偏离后,向用户提供具体文件/节点链接和可查看的预览。展示复用依据及有意差异,不能把“Figma 生成成功”当成风格一致的证据。
|
||||
3. 在工单或工程指定的设计入口记录原型链接、版本及“待原型确认”。原型完成并通过视觉检查后,给出采用该版本或修正问题的推荐,按[统一一分钟策略](planning.md#提问推荐与一分钟默认选择)处理;用户选择或满足默认策略条件到期采用后可进入业务界面实现,不再把原型列为必须人工回复的例外。记录具体版本与决定来源,已有同版决定直接沿用;工具生成成功本身不算验收。
|
||||
4. 将原型元素映射到已选组件、变体和 Token,按确认的原型实施并完成下述视觉验收。若后续明显改变布局或交互,更新原型并按同一策略重新提交受影响部分;原型确认不替代最终功能验证。
|
||||
|
||||
@@ -31,11 +40,9 @@ Figma 连接或权限不可用时,说明实际阻碍,继续独立工作;
|
||||
|
||||
## 实现后的视觉验收
|
||||
|
||||
按[测试责任](testing.md)执行:Agent 模式实际打开待验收版本,在工程要求的尺寸与必要状态下截图,逐项比较“批准原型(若有)—实际界面—同端参考”;用户模式不代跑测试或构建,交付同样的视觉核对点、原型及准确版本,反馈与超时后的真实验证按测试责任处理。编译和功能测试通过不能替代视觉验收。
|
||||
按[测试责任](testing.md)执行:worktree 内以受影响代码的单元测试为完成条件。实现时对照已确认原型、同端参考、组件和主题做源码级核对,列出布局、层级、字体、间距、颜色、按钮主次和受影响状态上的对应关系。不为此构建、启动应用或截取运行界面。运行后的整体视觉验收由用户在合入后的集成分支上完成;工单保留需要用户看的核对点,不把源码对照写成运行界面已经通过。
|
||||
|
||||
核对内容包括布局与信息层级、字体/间距/圆角/颜色、按钮尺寸与主次、原生控件默认边框/行高/内边距,以及空值、加载、失败、保存中、长内容、键盘和安全区等受影响状态。记录截图位置、运行版本、视口、差异与结论;临时截图遵守工程临时目录约定,使用合成或脱敏数据。必要渲染环境不可用时标为待视觉验证,不凭源码或 Figma 截图宣称实际界面通过。
|
||||
|
||||
实现偏离已确认设计时先修复;设计本身需要改变时按原型确认规则处理。明确免除 Figma 只省去原型,不省去视觉依据、组件复用和实际效果验证。
|
||||
实现偏离已确认设计时先修复;设计本身需要改变时按原型确认规则处理。新原型若已偏离现行风格且没有对应改版决定,先纠正设计,不能因为实现与该原型相同就判定一致性通过。明确免除 Figma 只省去原型,不省去视觉依据和组件复用。运行后的效果由用户在合入后的集成分支上验收。
|
||||
|
||||
## 将有效设计留给下一次任务
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ python3 .agents/skills/personal-dev/scripts/pda.py update --project . --revision
|
||||
python3 .agents/skills/personal-dev/scripts/pda.py update --project . --revision <完整提交> --apply
|
||||
```
|
||||
|
||||
四条命令是不同选择,不能顺序全部执行。更新先预览具体差异;取得该版本的更新选择后才 apply。期间 main 前进仍固定原候选;来源改变、候选失效、本地受管文件有修改或安装协议不兼容时保留现场,重新核对。跳过只针对这一提交,稍后提醒延后 24 小时。更新不自动提交工程改动、不更新第三方插件或 MCP。
|
||||
四条命令是不同选择,不能顺序全部执行。更新先预览具体差异;取得该版本的更新选择后才 apply。期间 main 前进仍固定原候选;来源改变、候选失效、本地受管文件有修改或安装协议不兼容时保留现场,重新核对。工程内 updater 遇到不认识的 `scripts/pda/*.py` 时停止,不改用半截下载;这种候选改从已审阅的个人仓库、固定提交执行 `init` 预览,确认后再 `init --apply`。受管文件和安装锁不一致时两边都不写入,先人工合并,不删除 lock。跳过只针对这一提交,稍后提醒延后 24 小时。更新不自动提交工程改动、不更新第三方插件或 MCP。
|
||||
|
||||
成功更新(或使用完整源码完成接入)后,读取工程 `docs/agents/README.md` 的“快速开始”,在最终答复展示至少一个适合当前用户的可复制案例、指南位置和必要注意事项。已有任务优先展示续接案例,说明新会话加载与占用核对;无任务时展示工单入口。提示词以指南为唯一来源,不把独立接入检查当成开发前必做步骤。旧版 updater 首次升级可能未输出快速开始,仍从已安装指南读取;预览、检查更新、跳过和失败不报告安装完成或引导立即续跑。
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
每次操作前重新核对,`eligible` 仅是上次检查结果。任一条件不满足或无法确认,记为 retained 并说明原因:
|
||||
|
||||
1. **交付已完成**:按[工单收尾](completion.md)取得真实验收、集成验证、远端同步、环境恢复和关单读回结果。完整运行时的普通单与队列单均完成 queue-finish;未建立远端占用的遗留普通单可用 delivery-check;未安装运行时的 Skill-only 普通单按 completion.md 完成同等实时证据的人工核对并标明方式,不能把现有脚本失败改称 Skill-only 绕过。用户自测未通过不符合。PR 模式另按[PR 证据规则](pull-requests.md)读回确已合并;closed 未合并或仍待合并的 PR 保留目录。再次核对关联工单未重开、最新反馈无未处理问题,目标远端提交可达且本地已同步;网络失败不能沿用旧成功记录。
|
||||
1. **交付已完成**:按[工单收尾](completion.md)取得五项证据和关单读回结果。集成证据指单元测试仍覆盖最终代码树,并写明构建验收留给用户。完整运行时的普通单与队列单均完成 queue-finish;未建立远端占用的遗留普通单可用 delivery-check;未安装运行时的 Skill-only 普通单按 completion.md 完成同等实时证据的人工核对并标明方式,不能把现有脚本失败改称 Skill-only 绕过。用户自测未通过不符合。PR 模式另按[PR 证据规则](pull-requests.md)读回确已合并;closed 未合并或仍待合并的 PR 保留目录。再次核对关联工单未重开、最新反馈无未处理问题,目标远端提交可达且本地已同步;网络失败不能沿用旧成功记录。
|
||||
2. **归属与占用明确**:目标是登记的同仓库 linked worktree,实际分支/提交与任务记录一致;不是主目录、共享工作区、其他活跃会话、用户保留或 Git locked 项。按[工单占用](issue-claims.md)核对远端 owner/阶段/资源、所有本地 worker 与台账,不能把本会话 current 为空当作其他会话没有使用。核对宿主附件及其他任务引用,路径改用、新分支、脱离预期仓库时保留。当前 shell 先切到主目录;当前会话仍必须在该目录继续执行或宿主无法安全解除占用时,延后清理。
|
||||
3. **所有改动已保存**:检查暂存、未暂存、未跟踪、被忽略文件以及独有提交;子模块、嵌套仓库也要辨认,不以外层 status 干净替代。工作树有未提交修改时默认保留,不为清理自动提交、stash、reset 或丢弃。任务提交应已被目标远端集成分支包含;squash/rebase 不能仅凭祖先关系判断失败或成功,核对最终变更映射、PR 和验收,并为原任务提交保留可恢复分支/宿主快照。无法证明完整交付则保留。
|
||||
4. **非版本文件已有去向**:逐项识别未跟踪及忽略文件。唯一 `.env`、Agent 凭据、上传文件、数据卷、报告和日志证据需移交到工程既有受控位置,验证完整性与引用后才可清理,已有文件不覆盖。仅有可再生成的依赖/缓存/产物时,确认无需保留及无运行引用后按工程已有本地文件清理授权逐项处理;未获授权的文件或用途不明项保留。符号链接只处理本链接,不递归清理链接目标;不能为了让 remove 成功执行全目录强制清空。
|
||||
|
||||
@@ -16,7 +16,7 @@ def main():
|
||||
'queue-start', 'queue-stop', 'queue-status', 'queue-next', 'queue-claim',
|
||||
'queue-resource-acquire', 'queue-resource-release', 'queue-legacy-stop', 'queue-transfer',
|
||||
'queue-guard', 'queue-cleanup-guard', 'queue-checkpoint', 'queue-block', 'queue-retry', 'queue-finish',
|
||||
'delivery-status', 'delivery-record', 'delivery-check',
|
||||
'delivery-status', 'delivery-record', 'delivery-check', 'task-next',
|
||||
'watch-start', 'watch-bind', 'watch-tick', 'watch-stop', 'watch-status'))
|
||||
parser.add_argument('--project', required=True, type=Path)
|
||||
parser.add_argument('--apply', action='store_true', help='apply previewed init/recover/update or queue-start/watch-start/queue-transfer/permissions-grant/permissions-revoke changes')
|
||||
@@ -42,17 +42,17 @@ def main():
|
||||
parser.add_argument('--capability', help='comma-separated capabilities to check')
|
||||
args = parser.parse_args()
|
||||
queue_fields = {'thread': ('watch-start', 'watch-tick'), 'scheduler': ('watch-bind', 'watch-tick'),
|
||||
'generation': ('watch-bind', 'watch-tick'), 'interval': ('watch-start',), 'owner': ('permissions-status','permissions-grant','permissions-revoke','permissions-check','queue-start','queue-next','queue-guard','queue-cleanup-guard','queue-checkpoint','queue-block','queue-retry','queue-finish','queue-status','queue-stop','queue-claim','queue-resource-acquire','queue-resource-release','queue-transfer'),
|
||||
'generation': ('watch-bind', 'watch-tick'), 'interval': ('watch-start',), 'owner': ('permissions-status','permissions-grant','permissions-revoke','permissions-check','queue-start','queue-next','queue-guard','queue-cleanup-guard','queue-checkpoint','queue-block','queue-retry','queue-finish','queue-status','queue-stop','queue-claim','queue-resource-acquire','queue-resource-release','queue-transfer','task-next'),
|
||||
'stage': ('queue-checkpoint', 'delivery-record'), 'branch': ('queue-checkpoint',),
|
||||
'worktree': ('queue-checkpoint',), 'commit': ('queue-checkpoint','queue-finish','delivery-record','delivery-check'),
|
||||
'reason': ('queue-block',), 'issue': ('permissions-grant','permissions-revoke','permissions-check','queue-start','queue-claim','queue-retry','delivery-status','delivery-record','delivery-check'),
|
||||
'reason': ('queue-block',), 'issue': ('permissions-grant','permissions-revoke','permissions-check','queue-start','queue-claim','queue-retry','delivery-status','delivery-record','delivery-check','task-next'),
|
||||
'evidence': ('permissions-grant','permissions-revoke','queue-finish','delivery-record','queue-transfer'), 'target_owner': ('queue-transfer',),
|
||||
'resource': ('queue-resource-acquire','queue-resource-release'),
|
||||
'allow': ('permissions-grant',), 'deny': ('permissions-grant',), 'capability': ('permissions-check',)}
|
||||
for field, actions in queue_fields.items():
|
||||
if getattr(args, field) is not None and args.action not in actions:
|
||||
parser.error('--' + field + ' is not valid for this action')
|
||||
if args.action in queue_fields['owner'] and args.action not in ('queue-status','queue-stop') and not args.owner:
|
||||
if args.action in queue_fields['owner'] and args.action not in ('queue-status','queue-stop','task-next') and not args.owner:
|
||||
parser.error('--owner is required for this workflow action')
|
||||
if args.all and (args.action != 'queue-stop' or args.owner):
|
||||
parser.error('--all requires queue-stop without --owner')
|
||||
@@ -78,6 +78,10 @@ def main():
|
||||
from pda.notifications import notify
|
||||
print(json.dumps(notify(package), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.action == 'task-next':
|
||||
from pda.task_next import task_next
|
||||
print(json.dumps(task_next(root, args.owner, args.issue), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if args.action.startswith('watch-'):
|
||||
from pda.issue_watch import dispatch
|
||||
print(json.dumps(dispatch(root, args), ensure_ascii=False, indent=2))
|
||||
|
||||
@@ -38,7 +38,14 @@ def validate(config, template):
|
||||
'rules': None, 'validation': checks}
|
||||
def shape(value, sample, key):
|
||||
if isinstance(sample, dict):
|
||||
if not isinstance(value, dict) or value.keys() != sample.keys():
|
||||
if not isinstance(value, dict):
|
||||
raise InstallError("invalid config keys: " + key)
|
||||
# Project-owned figma may keep extra authority/archive/notes fields;
|
||||
# require template keys and leave unknown siblings untouched.
|
||||
if key == "config.figma":
|
||||
if not set(sample).issubset(value):
|
||||
raise InstallError("invalid config keys: " + key)
|
||||
elif value.keys() != sample.keys():
|
||||
raise InstallError("invalid config keys: " + key)
|
||||
for name in sample:
|
||||
shape(value[name], sample[name], key + "." + name)
|
||||
|
||||
@@ -97,7 +97,7 @@ def plan(root, package, source=None):
|
||||
}
|
||||
blocks = {
|
||||
"AGENTS.md": (BEGIN, END,
|
||||
"工程建设、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,读取 "
|
||||
"工程建设、界面设计或原型、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,读取 "
|
||||
"[个人开发入口](.agents/skills/personal-dev/SKILL.md),"
|
||||
"并按需查阅 [工程配置](docs/agents/README.md)。"
|
||||
"业务约束继续遵循目标目录规则;纯咨询无需启动开发流程。"),
|
||||
|
||||
@@ -381,6 +381,24 @@ class Queue:
|
||||
|
||||
DELIVERY_DIRECTORY = '.local/agent/delivery'
|
||||
DELIVERY_STEPS = ('accepted', 'integration_verified', 'remote_synced', 'environment_restored', 'issue_updated')
|
||||
_UNIT_TEST_LOCATOR = re.compile(
|
||||
r'单元测试命令|pytest|go test|npm test|pnpm test|yarn test|cargo test|mvn test|gradle test|ctest|python3 -m unittest|用户确认')
|
||||
_SKIPPED_TEST = re.compile(r'不适用|not applicable|(?<![A-Za-z])N/A(?![A-Za-z])', re.I)
|
||||
UNIT_TEST_DEFERRED = '无单元测试,留给用户验收'
|
||||
INTEGRATION_NOTE = 'integration_verified 只表示单元测试仍覆盖即将合入的代码树,不是构建验收。'
|
||||
|
||||
|
||||
def validate_stage_evidence(stage, evidence):
|
||||
"""Reject placeholders. This does not run tests or judge that the locator is true."""
|
||||
text = evidence.strip()
|
||||
if stage == 'accepted':
|
||||
if _SKIPPED_TEST.search(text):
|
||||
raise InstallError('skipped unit tests must say ' + UNIT_TEST_DEFERRED)
|
||||
if UNIT_TEST_DEFERRED not in text and not _UNIT_TEST_LOCATOR.search(text):
|
||||
raise InstallError('accepted evidence must locate the unit-test command and result, name 用户确认, or say ' + UNIT_TEST_DEFERRED)
|
||||
elif stage == 'integration_verified':
|
||||
if '单元测试' not in text or '构建验收留给用户' not in text:
|
||||
raise InstallError('integration_verified must say the unit-test tree still matches and 构建验收留给用户')
|
||||
|
||||
|
||||
class Delivery:
|
||||
@@ -444,6 +462,7 @@ class Delivery:
|
||||
raise InstallError('unknown delivery stage')
|
||||
if not isinstance(evidence, str) or not evidence.strip() or not evidence.isprintable() or len(evidence) > 1000:
|
||||
raise InstallError('evidence locator required; never store credentials or issue bodies')
|
||||
validate_stage_evidence(stage, evidence)
|
||||
with self._locked():
|
||||
old = self.status(number)['record']
|
||||
if old is None or old['commit'] != commit:
|
||||
@@ -463,7 +482,10 @@ class Delivery:
|
||||
value['steps'][stage] = evidence
|
||||
value.update(waiting=None, last_verified=None)
|
||||
self._save(number, value)
|
||||
return self.status(number)
|
||||
result = self.status(number)
|
||||
if stage == 'integration_verified':
|
||||
result = dict(result, integration_verified_means=INTEGRATION_NOTE)
|
||||
return result
|
||||
|
||||
def check(self, number, commit, tracker, lease):
|
||||
with self._locked():
|
||||
|
||||
@@ -240,6 +240,11 @@ class Tracker:
|
||||
if not value:
|
||||
return records
|
||||
records.extend(value)
|
||||
# Gitea comment pages end when a page is shorter than the limit.
|
||||
# The following request is not a reliable empty page.
|
||||
if (self.kind == 'gitea' and suffix.endswith('/comments')
|
||||
and len(value) < PAGE_SIZE):
|
||||
return records
|
||||
raise InstallError('issue tracker pagination limit exceeded')
|
||||
|
||||
def issues(self):
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Read-only next step for one issue. Does not claim, test, merge, or write state."""
|
||||
from .issue_queue import Delivery, INTEGRATION_NOTE, configuration, git, main_project
|
||||
from .issue_workers import WorkerQueue
|
||||
from .transaction import InstallError
|
||||
|
||||
ASK = '需要新的决定时,先在当前回复写出「待你确认」,再播放提示音。本命令看不到聊天正文。'
|
||||
GUIDE = {
|
||||
'claimed': ('references/implementation.md', '核对验收条件、集成分支和修改端。修改端未知时先写「待你确认」。'),
|
||||
'planning': ('references/planning.md', '范围已明确则进入实现。仍有关键未知项时先写「待你确认」。'),
|
||||
'implementing': ('references/implementation.md', '在任务 worktree 完成实现。然后只跑受影响代码的单元测试。'),
|
||||
'testing': ('references/testing.md', '把单元测试命令和结果写入 accepted。没有测试可跑时写明无单元测试,留给用户验收。'),
|
||||
'waiting_user': ('references/planning.md', '等待已经展示的确认。不重复播放提示音,不把沉默写成验收通过。'),
|
||||
'reviewing': ('references/roles.md', '重要变更做独立审查。审查没有阻塞后准备串行合入。'),
|
||||
'ready_to_merge': ('references/local-validation.md', '取得该集成分支的集成锁后再合入。其他 worktree 继续等待。'),
|
||||
'merging': ('references/issue-claims.md', '完成这一次合入、同步和回写后再释放集成锁。'),
|
||||
'blocked': ('references/issue-queue.md', '按已记录的阻塞原因处理。未恢复前不领取下一单。'),
|
||||
'done': ('references/worktree-lifecycle.md', '核对关单读回,再检查本任务 worktree 是否可以回收。'),
|
||||
}
|
||||
DELIVERY_GUIDE = {
|
||||
'accepted': ('references/testing.md', '记录单元测试命令和结果,或写明无单元测试,留给用户验收。'),
|
||||
'integration_verified': ('references/local-validation.md', '写明单元测试仍覆盖即将合入的代码树,并且构建验收留给用户。'),
|
||||
'remote_synced': ('references/pull-requests.md', '同步远端集成分支,并保存 PR 或直接合并的读回结果。'),
|
||||
'environment_restored': ('references/local-validation.md', '本单没有切换运行环境时,记录未改动。'),
|
||||
'issue_updated': ('references/completion.md', '回写并关闭工单。回复写明单元测试结果、合入提交,以及构建验收留给用户。'),
|
||||
}
|
||||
|
||||
|
||||
def _quiet(root, *args):
|
||||
try:
|
||||
return git(root, *args)
|
||||
except InstallError:
|
||||
return None
|
||||
|
||||
|
||||
def _git_facts(root):
|
||||
porcelain = _quiet(root, 'status', '--porcelain')
|
||||
return {
|
||||
'branch': _quiet(root, 'rev-parse', '--abbrev-ref', 'HEAD'),
|
||||
'head': _quiet(root, 'rev-parse', 'HEAD'),
|
||||
'dirty': None if porcelain is None else bool(porcelain),
|
||||
}
|
||||
|
||||
|
||||
def _current(root, owner, issue):
|
||||
if owner:
|
||||
state = WorkerQueue(root, owner=owner).status()
|
||||
current = state.get('current') or {}
|
||||
if issue is not None and current.get('number') not in (None, issue):
|
||||
raise InstallError('owner current issue does not match --issue')
|
||||
number = issue if issue is not None else current.get('number')
|
||||
if current.get('number') == number:
|
||||
return number, current.get('stage'), current.get('commit'), None
|
||||
return number, None, None, None
|
||||
if issue is not None:
|
||||
return issue, None, None, None
|
||||
currents = []
|
||||
for state in WorkerQueue(root).status().get('workers', []):
|
||||
current = state.get('current')
|
||||
if current:
|
||||
currents.append((state.get('owner'), current))
|
||||
if len(currents) > 1:
|
||||
return None, None, None, [item[0] for item in currents]
|
||||
if len(currents) == 1:
|
||||
owner_name, current = currents[0]
|
||||
return current.get('number'), current.get('stage'), current.get('commit'), None
|
||||
return None, None, None, None
|
||||
|
||||
|
||||
def task_next(root, owner=None, issue=None):
|
||||
root = main_project(root)
|
||||
facts = _git_facts(root)
|
||||
number, stage, commit, owners = _current(root, owner, issue)
|
||||
base = {
|
||||
'status': 'ok', 'issue': number, 'step': stage, 'owner': owner,
|
||||
'can_merge': False, 'missing': [], 'git': facts,
|
||||
'ask': ASK, 'merge': INTEGRATION_NOTE,
|
||||
}
|
||||
if owners:
|
||||
base.update(status='ambiguous', step='claim', missing=['owner'],
|
||||
next='多个会话各有当前工单。指定 --owner 后再看下一步。',
|
||||
read='references/issue-claims.md', owners=owners)
|
||||
return base
|
||||
if number is None:
|
||||
base.update(status='idle', step='claim', missing=['issue'],
|
||||
next='没有当前工单。用户给出编号后领取;没有编号时先创建工单。',
|
||||
read='references/issue-claims.md')
|
||||
return base
|
||||
try:
|
||||
_, _, repository, branch = configuration(root)
|
||||
delivery = Delivery(root, repository, branch).status(number)
|
||||
except InstallError as exc:
|
||||
base.update(status='needs_config', missing=['project configuration'],
|
||||
next='先补齐工程的工单仓库和集成分支配置,再继续这个工单。',
|
||||
read='references/planning.md', config_error=str(exc))
|
||||
return base
|
||||
record = delivery.get('record') or {}
|
||||
steps = record.get('steps') or {}
|
||||
missing = list(delivery.get('missing') or [])
|
||||
evidence_commit = record.get('commit')
|
||||
if commit and evidence_commit and commit != evidence_commit:
|
||||
missing = ['evidence_commit_mismatch', *missing]
|
||||
covered = bool(steps.get('accepted') and steps.get('integration_verified')
|
||||
and evidence_commit and 'evidence_commit_mismatch' not in missing
|
||||
and stage not in ('waiting_user', 'blocked', 'done'))
|
||||
if stage in GUIDE:
|
||||
read, nxt = GUIDE[stage]
|
||||
else:
|
||||
read, nxt = DELIVERY_GUIDE['issue_updated']
|
||||
if stage in (None, 'testing', 'reviewing') or (stage in ('ready_to_merge', 'merging') and not covered):
|
||||
focus = next((DELIVERY_GUIDE[item] for item in missing if item in DELIVERY_GUIDE), None)
|
||||
if focus:
|
||||
read, nxt = focus
|
||||
if stage == 'waiting_user':
|
||||
base['ask'] = '等待已经展示的确认。不重复播放提示音。'
|
||||
base.update(step=stage or 'delivery', next=nxt, read=read, missing=missing,
|
||||
can_merge=covered, evidence_commit=evidence_commit)
|
||||
return base
|
||||
@@ -9,7 +9,7 @@ import stat
|
||||
import tempfile
|
||||
|
||||
RUNTIME = ('scripts/pda.py',) + tuple('scripts/pda/' + name + '.py' for name in (
|
||||
'__init__', 'config', 'install', 'transaction', 'source', 'remote', 'updates', 'issue_queue', 'issue_tracker', 'issue_watch', 'notifications', 'issue_claims', 'issue_workers', 'worker_dispatch', 'permissions'))
|
||||
'__init__', 'config', 'install', 'transaction', 'source', 'remote', 'updates', 'issue_queue', 'issue_tracker', 'issue_watch', 'notifications', 'issue_claims', 'issue_workers', 'worker_dispatch', 'permissions', 'task_next'))
|
||||
ASSETS = ('assets/question-pending.wav',)
|
||||
|
||||
JOURNAL = ".local/agent/install/transaction.json"
|
||||
|
||||
@@ -3,4 +3,6 @@ description: 个人开发体系的工程任务入口
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
工程建设、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,先读根 AGENTS.md,再读取工程根目录的 .agents/skills/personal-dev/SKILL.md 进行任务分级。仅咨询概念时直接回答。业务约束仍按目标目录的 AGENTS.md 查阅。
|
||||
工程建设、界面设计或原型、需求开发、Bug 修复、验收反馈或提交/push/合并续聊时,先读根 AGENTS.md,再读取工程根目录的 .agents/skills/personal-dev/SKILL.md 进行任务分级。仅咨询概念时直接回答。业务约束仍按目标目录的 AGENTS.md 查阅。
|
||||
|
||||
需要用户确认时,先在当前回复写出「待你确认」清单,再播放提示音。清单的写法以该 Skill 的问题展示规则为准。回复里没有这份清单时,不播放提示音。
|
||||
@@ -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 -->
|
||||
@@ -1291,10 +1291,23 @@ def list_collection_protocols(
|
||||
)
|
||||
|
||||
from app.services.qc_work_order_fee_summary import build_protocol_payment_summaries
|
||||
from app.crud import sample_freight as sample_freight_svc
|
||||
|
||||
payment_summary_map = build_protocol_payment_summaries(
|
||||
db, protocol_ids, tenant_no, include_sample_payment_modes=True
|
||||
)
|
||||
all_list_sample_ids = [
|
||||
s.id
|
||||
for samples in sample_map.values()
|
||||
for s in samples
|
||||
if getattr(s, "id", None)
|
||||
]
|
||||
freight_by_sample = sample_freight_svc.freight_map_for_samples(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_source=sample_freight_svc.SAMPLE_SOURCE_CELL,
|
||||
sample_ids=all_list_sample_ids,
|
||||
)
|
||||
|
||||
# 合并主表 + 子表数据
|
||||
result = []
|
||||
@@ -1376,11 +1389,13 @@ def list_collection_protocols(
|
||||
}
|
||||
sample_items.append(
|
||||
{
|
||||
"id": s.id,
|
||||
"sampleTypeName": type_name,
|
||||
"sampleCode": code,
|
||||
"sampleStatus": (s.sample_status or "").strip(),
|
||||
"labStorageCode": lab_code_map.get(code, "") if code else "",
|
||||
"transport": transport_dict,
|
||||
"freight": freight_by_sample.get(s.id),
|
||||
**sample_special_to_dict(s),
|
||||
**build_sample_item_reception_qc_fields(
|
||||
reception_summary_map.get(code) if code else None,
|
||||
|
||||
@@ -17,7 +17,9 @@ from app.crud.biz_no_generator import allocate_next_biz_no
|
||||
from app.crud.collection_protocol import urls_to_attachment_list
|
||||
from app.models.models import (
|
||||
BizCellSampleType,
|
||||
BizCollectionHospital,
|
||||
BizCollectionProtocol,
|
||||
BizCollectionRegion,
|
||||
BizPresignOrder,
|
||||
BizPresignOrderSampleType,
|
||||
BizPresignTransfer,
|
||||
@@ -503,9 +505,107 @@ def list_presign_orders(
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
order_to_dict(db, o, tenant_no, with_children=False) for o in rows
|
||||
], int(total)
|
||||
items = [order_to_dict(db, o, tenant_no, with_children=False) for o in rows]
|
||||
attach_presign_ledger_fields(db, items, tenant_no)
|
||||
return items, int(total)
|
||||
|
||||
|
||||
def attach_presign_ledger_fields(
|
||||
db: Session, items: List[Dict[str, Any]], tenant_no: str
|
||||
) -> None:
|
||||
"""补列表导出需要的地区、医院、样本类型和转账时间。含已停用主数据名称。"""
|
||||
if not items:
|
||||
return
|
||||
order_ids = [str(item.get("id") or "") for item in items if item.get("id")]
|
||||
region_ids = {str(item.get("regionId") or "") for item in items if item.get("regionId")}
|
||||
hospital_ids = {
|
||||
str(item.get("hospitalId") or "") for item in items if item.get("hospitalId")
|
||||
}
|
||||
region_names: Dict[str, str] = {}
|
||||
if region_ids:
|
||||
region_names = {
|
||||
str(row.id): str(row.region_name or "").strip()
|
||||
for row in db.query(BizCollectionRegion).filter(
|
||||
BizCollectionRegion.id.in_(region_ids),
|
||||
BizCollectionRegion.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
hospital_names: Dict[str, str] = {}
|
||||
if hospital_ids:
|
||||
hospital_names = {
|
||||
str(row.id): str(row.hospital_name or "").strip()
|
||||
for row in db.query(BizCollectionHospital).filter(
|
||||
BizCollectionHospital.id.in_(hospital_ids),
|
||||
BizCollectionHospital.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
type_ids_by_order: Dict[str, List[str]] = {order_id: [] for order_id in order_ids}
|
||||
if order_ids:
|
||||
links = (
|
||||
db.query(BizPresignOrderSampleType)
|
||||
.filter(
|
||||
BizPresignOrderSampleType.presign_order_id.in_(order_ids),
|
||||
BizPresignOrderSampleType.tenant_no == tenant_no,
|
||||
BizPresignOrderSampleType.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizPresignOrderSampleType.order_num.asc(),
|
||||
BizPresignOrderSampleType.create_time.asc(),
|
||||
BizPresignOrderSampleType.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for link in links:
|
||||
if link.sample_type_id:
|
||||
type_ids_by_order.setdefault(link.presign_order_id, []).append(
|
||||
str(link.sample_type_id)
|
||||
)
|
||||
type_ids = {
|
||||
type_id
|
||||
for order_type_ids in type_ids_by_order.values()
|
||||
for type_id in order_type_ids
|
||||
}
|
||||
type_names: Dict[str, str] = {}
|
||||
if type_ids:
|
||||
type_names = {
|
||||
str(row.id): str(row.type_name or "").strip()
|
||||
for row in db.query(BizCellSampleType).filter(
|
||||
BizCellSampleType.id.in_(type_ids),
|
||||
BizCellSampleType.tenant_no == tenant_no,
|
||||
)
|
||||
}
|
||||
times_by_order: Dict[str, List[str]] = {}
|
||||
if order_ids:
|
||||
transfers = (
|
||||
db.query(BizPresignTransfer)
|
||||
.filter(
|
||||
BizPresignTransfer.presign_order_id.in_(order_ids),
|
||||
BizPresignTransfer.tenant_no == tenant_no,
|
||||
BizPresignTransfer.del_flag == 0,
|
||||
)
|
||||
.order_by(
|
||||
BizPresignTransfer.transfer_time.asc(),
|
||||
BizPresignTransfer.create_time.asc(),
|
||||
BizPresignTransfer.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for transfer in transfers:
|
||||
if not transfer.transfer_time:
|
||||
continue
|
||||
times_by_order.setdefault(transfer.presign_order_id, []).append(
|
||||
transfer.transfer_time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
for item in items:
|
||||
order_id = str(item.get("id") or "")
|
||||
item["regionName"] = region_names.get(str(item.get("regionId") or ""), "")
|
||||
item["hospitalName"] = hospital_names.get(str(item.get("hospitalId") or ""), "")
|
||||
names = [
|
||||
type_names.get(type_id, "")
|
||||
for type_id in type_ids_by_order.get(order_id, [])
|
||||
]
|
||||
item["sampleTypeNames"] = [name for name in names if name]
|
||||
item["transferTimes"] = times_by_order.get(order_id, [])
|
||||
|
||||
|
||||
def get_presign_order(
|
||||
|
||||
@@ -409,6 +409,8 @@ def list_protocol_sample_collections(
|
||||
item["newbornSex"] = (party.newborn_sex or "") if party else ""
|
||||
item["newbornDeliveryTime"] = _fmt_dt(party.newborn_delivery_time) if party else ""
|
||||
item["protocolRemark"] = (proto.remark or "") if proto else ""
|
||||
item["voidType"] = (proto.void_type or "") if proto else ""
|
||||
item["voidDesc"] = (proto.void_desc or "") if proto else ""
|
||||
item["protocolAttachments"] = (proto.protocol_attachments or "") if proto else ""
|
||||
item["consentFormUrls"] = (proto.consent_form_urls or "") if proto else ""
|
||||
item["healthSurveyFormUrls"] = (proto.health_survey_form_urls or "") if proto else ""
|
||||
|
||||
@@ -879,6 +879,20 @@ def list_donations(
|
||||
if t.express_company_id
|
||||
],
|
||||
)
|
||||
from app.crud import sample_freight as sample_freight_svc
|
||||
|
||||
all_list_sample_ids = [
|
||||
s.id
|
||||
for samples in sample_map.values()
|
||||
for s in samples
|
||||
if getattr(s, "id", None)
|
||||
]
|
||||
freight_by_sample = sample_freight_svc.freight_map_for_samples(
|
||||
db,
|
||||
tenant_no=tenant_no,
|
||||
sample_source=sample_freight_svc.SAMPLE_SOURCE_DONATION,
|
||||
sample_ids=all_list_sample_ids,
|
||||
)
|
||||
|
||||
result = []
|
||||
for r in rows:
|
||||
@@ -957,11 +971,13 @@ def list_donations(
|
||||
}
|
||||
sample_items.append(
|
||||
{
|
||||
"id": s.id,
|
||||
"sampleTypeName": type_name,
|
||||
"sampleCode": code,
|
||||
"sampleStatus": (s.sample_status or "").strip(),
|
||||
"labStorageCode": lab_code_map.get(code, "") if code else "",
|
||||
"transport": transport_dict,
|
||||
"freight": freight_by_sample.get(s.id),
|
||||
**sample_special_to_dict(s),
|
||||
**build_sample_item_reception_qc_fields(
|
||||
reception_summary_map.get(code) if code else None,
|
||||
|
||||
@@ -287,9 +287,15 @@ def _donation_rows_to_sample_list_items(
|
||||
),
|
||||
"sourceType": "research_donation",
|
||||
"sourceTypeLabel": "科研捐赠",
|
||||
"consentAttachmentFiles": urls_to_attachment_list(
|
||||
donation.consent_form_urls if donation else None, db, tenant_no
|
||||
),
|
||||
"infectiousDiseaseReportAttachmentFiles": urls_to_attachment_list(
|
||||
donation.infectious_disease_report_urls if donation else None, db, tenant_no
|
||||
),
|
||||
"attachmentFiles": urls_to_attachment_list(
|
||||
donation.attachment_urls if donation else None, db, tenant_no
|
||||
),
|
||||
"etaTime": _fmt_dt(transport.eta_time) if transport and transport.eta_time else "",
|
||||
"transportType": (transport.transport_type if transport else "") or "",
|
||||
"waybillNo": (transport.waybill_no if transport else "") or "",
|
||||
@@ -462,6 +468,12 @@ def _rows_to_sample_list_items(
|
||||
**sample_special_to_dict(sample),
|
||||
"sourceType": "cell_storage",
|
||||
"sourceTypeLabel": "细胞存储",
|
||||
"protocolAttachmentFiles": urls_to_attachment_list(
|
||||
protocol.protocol_attachments if protocol else None, db, tenant_no
|
||||
),
|
||||
"consentAttachmentFiles": urls_to_attachment_list(
|
||||
protocol.consent_form_urls if protocol else None, db, tenant_no
|
||||
),
|
||||
"healthSurveyAttachmentFiles": urls_to_attachment_list(
|
||||
protocol.health_survey_form_urls if protocol else None, db, tenant_no
|
||||
),
|
||||
|
||||
@@ -984,11 +984,14 @@ def list_protocols(
|
||||
for row in cell_rows
|
||||
)
|
||||
if source in (None, SOURCE_DONATION):
|
||||
if status or collected_from or collected_until:
|
||||
donation_enabled = False
|
||||
else:
|
||||
donation_enabled = True
|
||||
if not donation_enabled:
|
||||
# 混合列表(source=None)下状态/采集时间只覆盖细胞存储,捐赠侧跳过。
|
||||
# 科研捐赠单源列表支持 protocolStatus→donation_status;采集时间筛仍不支持捐赠。
|
||||
skip_donation = False
|
||||
if source is None and (status or collected_from or collected_until):
|
||||
skip_donation = True
|
||||
elif source == SOURCE_DONATION and (collected_from or collected_until):
|
||||
skip_donation = True
|
||||
if skip_donation:
|
||||
query = None
|
||||
else:
|
||||
query = apply_donation_scope(
|
||||
@@ -1001,6 +1004,8 @@ def list_protocols(
|
||||
if query is None:
|
||||
donation_rows = []
|
||||
else:
|
||||
if status and source == SOURCE_DONATION:
|
||||
query = query.filter(BizResearchDonation.donation_status == status)
|
||||
if created_from:
|
||||
query = query.filter(BizResearchDonation.create_time >= created_from)
|
||||
if created_until:
|
||||
|
||||
@@ -127,6 +127,7 @@ def _payment_detail_row(d: BizProtocolPaymentDetail, pay_mode: str, idx: int) ->
|
||||
"installmentDueDate": _fmt_ymd(d.installment_due_date),
|
||||
"dueAmount": _dec(d.due_amount),
|
||||
"transferAmount": _dec(d.transfer_amount),
|
||||
"refundAmount": _dec(getattr(d, "refund_amount", None)),
|
||||
"payStatus": st,
|
||||
"payStatusLabel": _PAY_STATUS_LABELS.get(st, st or "—"),
|
||||
"transferTime": _fmt_dt(getattr(d, "transfer_time", None)),
|
||||
@@ -156,6 +157,14 @@ def build_protocol_payment_summary(
|
||||
detail_rows = [_payment_detail_row(d, pay_mode, i) for i, d in enumerate(sorted_details)]
|
||||
received = _received_amount_from_details(sorted_details)
|
||||
pending = max(contract - received, 0) if contract is not None else None
|
||||
refund_total = 0.0
|
||||
has_refund = False
|
||||
for d in sorted_details:
|
||||
amt = _dec(getattr(d, "refund_amount", None))
|
||||
if amt is None:
|
||||
continue
|
||||
has_refund = True
|
||||
refund_total += float(amt)
|
||||
statuses = [(d.pay_status or "").strip() for d in sorted_details]
|
||||
overall = _protocol_overall_status(statuses, contract, pending)
|
||||
hint = _protocol_status_hint(pay_mode, statuses) or overall["overallPayStatusLabel"]
|
||||
@@ -165,6 +174,7 @@ def build_protocol_payment_summary(
|
||||
"contractAmount": contract,
|
||||
"receivedAmount": received,
|
||||
"pendingAmount": pending,
|
||||
"refundAmount": refund_total if has_refund else None,
|
||||
"statusHint": hint,
|
||||
"installmentCount": len(detail_rows) if pay_mode in ("fq", "djfq") else None,
|
||||
"paymentDetails": detail_rows,
|
||||
|
||||
@@ -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())
|
||||
@@ -2550,3 +2550,41 @@ process.stdout.write(JSON.stringify([
|
||||
'¥9,999,999,999,999,999.99',
|
||||
'¥9,999,999,999,999,999.99',
|
||||
]
|
||||
|
||||
|
||||
def test_web_presign_list_includes_ledger_fields():
|
||||
from datetime import datetime
|
||||
|
||||
from app.crud.presign_order import list_presign_orders
|
||||
|
||||
db = _session()
|
||||
_add_master_data(db)
|
||||
order = _order('ledger-1', region_id='region-a', sales_user_id='u1')
|
||||
order.hospital_id = 'hospital-a'
|
||||
order.mother_name = '陈影'
|
||||
transfer = _transfer('ledger-transfer', order_id='ledger-1', pay_status='yjf')
|
||||
transfer.transfer_time = datetime(2026, 4, 2, 16, 49, 42)
|
||||
db.add_all(
|
||||
[
|
||||
order,
|
||||
transfer,
|
||||
BizPresignOrderSampleType(
|
||||
id='ledger-type',
|
||||
presign_order_id='ledger-1',
|
||||
sample_type_id='sample-a',
|
||||
tenant_no='tenant-a',
|
||||
create_dept_id='',
|
||||
order_num=1,
|
||||
del_flag=0,
|
||||
),
|
||||
]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
items, total = list_presign_orders(db, tenant_no='tenant-a', page_size=10)
|
||||
|
||||
assert total == 1
|
||||
assert items[0]['regionName'] == 'A区'
|
||||
assert items[0]['hospitalName'] == 'A医院'
|
||||
assert items[0]['sampleTypeNames'] == ['样本A']
|
||||
assert items[0]['transferTimes'] == ['2026-04-02 16:49:42']
|
||||
@@ -3733,6 +3733,29 @@ def test_protocol_list_filters_status_create_time_and_sample_collect_time():
|
||||
assert [item['id'] for item in items] == ['hit']
|
||||
|
||||
|
||||
def test_donation_list_filters_by_protocol_status_alias():
|
||||
from app.services.market_protocol import list_protocols
|
||||
|
||||
db = _session()
|
||||
_masters(db)
|
||||
hit = _donation(db, 'don-hit', created=datetime(2026, 10, 8, 9, 0))
|
||||
hit.donation_status = 'sxz'
|
||||
miss = _donation(db, 'don-miss', created=datetime(2026, 10, 8, 9, 0))
|
||||
miss.donation_status = 'dsx'
|
||||
db.commit()
|
||||
|
||||
items, total = list_protocols(
|
||||
db,
|
||||
ctx=_ctx('specialist'),
|
||||
user_info=_user(),
|
||||
source='research_donation',
|
||||
protocol_status='sxz',
|
||||
)
|
||||
|
||||
assert total == 1
|
||||
assert [item['id'] for item in items] == ['don-hit']
|
||||
|
||||
|
||||
def test_cell_create_rolls_back_main_customer_and_children_on_child_failure():
|
||||
from app.services.market_protocol import create_protocol
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""样本接收列表项应携带协议侧附件字段,供质检查看。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class _FakeQuery:
|
||||
def filter(self, *args, **kwargs):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return []
|
||||
|
||||
def first(self):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeDb:
|
||||
def query(self, *args, **kwargs):
|
||||
return _FakeQuery()
|
||||
|
||||
|
||||
class TestSampleReceptionProtocolAttachments(unittest.TestCase):
|
||||
def test_cell_storage_item_includes_protocol_consent_health_files(self):
|
||||
from app.crud import sample_reception as svc
|
||||
|
||||
protocol = SimpleNamespace(
|
||||
id="proto-1",
|
||||
protocol_no="P1",
|
||||
protocol_status="effective",
|
||||
protocol_attachments="http://a/p.pdf",
|
||||
consent_form_urls="http://a/c.pdf",
|
||||
health_survey_form_urls="http://a/h.pdf",
|
||||
)
|
||||
sample = SimpleNamespace(
|
||||
id="sample-1",
|
||||
sample_code="S001",
|
||||
sample_type_id="t1",
|
||||
collect_amount=None,
|
||||
sample_status="",
|
||||
)
|
||||
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
|
||||
transport = SimpleNamespace(
|
||||
eta_time=None,
|
||||
transport_type="",
|
||||
waybill_no="",
|
||||
express_company_id="",
|
||||
carrier_name="",
|
||||
carrier_phone="",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
"urls_to_attachment_list",
|
||||
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
|
||||
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
|
||||
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
|
||||
), patch(
|
||||
"app.services.qc_work_order_fee_summary.build_protocol_payment_summaries",
|
||||
return_value={},
|
||||
):
|
||||
items = svc._rows_to_sample_list_items(
|
||||
_FakeDb(),
|
||||
[(sample, protocol, party, transport)],
|
||||
"t1",
|
||||
reception_map={},
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
item = items[0]
|
||||
self.assertEqual(item["protocolAttachmentFiles"][0]["fileUrl"], "http://a/p.pdf")
|
||||
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
|
||||
self.assertEqual(item["healthSurveyAttachmentFiles"][0]["fileUrl"], "http://a/h.pdf")
|
||||
|
||||
def test_donation_item_includes_consent_infectious_other_files(self):
|
||||
from app.crud import sample_reception as svc
|
||||
|
||||
donation = SimpleNamespace(
|
||||
id="don-1",
|
||||
donation_no="D1",
|
||||
donation_status="effective",
|
||||
consent_form_urls="http://a/c.pdf",
|
||||
infectious_disease_report_urls="http://a/i.pdf",
|
||||
attachment_urls="http://a/o.pdf",
|
||||
)
|
||||
sample = SimpleNamespace(
|
||||
id="ds-1",
|
||||
sample_code="D001",
|
||||
sample_type_id="t1",
|
||||
collect_amount=None,
|
||||
)
|
||||
party = SimpleNamespace(mother_name="妈妈", newborn_name="宝宝")
|
||||
transport = SimpleNamespace(
|
||||
eta_time=None,
|
||||
transport_type="",
|
||||
waybill_no="",
|
||||
express_company_id="",
|
||||
carrier_name="",
|
||||
carrier_phone="",
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
svc,
|
||||
"urls_to_attachment_list",
|
||||
side_effect=lambda urls, *_a, **_k: [{"fileUrl": urls}] if urls else [],
|
||||
), patch.object(svc, "sample_special_to_dict", return_value={}), patch.object(
|
||||
svc.prod_reception_svc, "get_map_by_reception_ids", return_value={}
|
||||
):
|
||||
items = svc._donation_rows_to_sample_list_items(
|
||||
_FakeDb(),
|
||||
[(sample, donation, party, transport)],
|
||||
"t1",
|
||||
reception_map={},
|
||||
)
|
||||
|
||||
self.assertEqual(len(items), 1)
|
||||
item = items[0]
|
||||
self.assertEqual(item["consentAttachmentFiles"][0]["fileUrl"], "http://a/c.pdf")
|
||||
self.assertEqual(
|
||||
item["infectiousDiseaseReportAttachmentFiles"][0]["fileUrl"], "http://a/i.pdf"
|
||||
)
|
||||
self.assertEqual(item["attachmentFiles"][0]["fileUrl"], "http://a/o.pdf")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+24
-17
@@ -54,7 +54,7 @@ Agent 会核对工程仓库、集成主分支、应用目录、测试入口、
|
||||
|
||||
## 确认问题会有语音提醒
|
||||
|
||||
Agent 发出需要你答复的问题后,默认在本机播放一次“有问题待确认,请及时处理”。语音已预先生成并随包安装,后续只播放本地文件;一批多个问题提醒一次,等待期间不循环播放。
|
||||
Agent 需要你做决定时,会在当前回复里直接写出「待你确认」,逐题列出问题、推荐、理由,以及 60 秒未回复会怎样。请看这段文字作答。随后默认在本机播放一次“有问题待确认,请及时处理”,提醒你去看这段清单。语音已预先生成并随包安装,后续只播放本地文件,不会念出题目;一批多个问题提醒一次,等待期间不循环播放。没有「待你确认」时不应播放。
|
||||
|
||||
“必须等我回复”的问题也会提醒,但仍保持等待。可以说“本次任务静音”或“本工程关闭语音提醒”,恢复时说“恢复语音提醒”;声音开关和一分钟默认选择互相独立,升级保留工程自己的选择。播放失败不影响文字提问,也不会延长或跳过 60 秒计时。
|
||||
|
||||
@@ -74,7 +74,11 @@ Agent 发出需要你答复的问题后,默认在本机播放一次“有问
|
||||
|
||||
新页面、新弹窗和视觉改版默认先做 Figma 原型。确实无需原型时明确说“本次不用生成 Figma 原型”;仍按已有界面规范实现。需要等待你亲自确认时说“这个决定必须等我回复”。
|
||||
|
||||
任务通常依次经过:原子领取并读取工单 → 确认范围/原型 → 任务分支与 worktree 实现 → 推送任务分支并创建/复用 PR → 测试与审查 → 合并候选检查及本机集成环境冒烟 → 按授权合并 PR → 恢复核对本机环境并关单。worktree 是独立工作目录,里面有对应任务分支;它不是集成主分支,不能只刷新原来的页面就认定正在测试修复版。
|
||||
已有工程默认沿用当前界面风格,无需每张工单重复提醒。Agent 应在设计前核对同端同类页面、实际可查看的运行效果、组件和主题样式,简要说明参考与复用选择;生成原型和最终实现都要与参考对照。只做 Figma、采用其他设计工具或免除原型,也保留这项要求。无法查看运行界面时说明证据缺口,不把通用模板当成现行风格。步骤见安装目录 `references/ui-design.md`。
|
||||
|
||||
需要指定参考时可以说:“新增这个页面,沿用当前订单列表和编辑弹窗的风格;先查看现有界面及组件,再做设计,说明复用项与差异,不改全局主题。” 明确要改版时说明端、模块和范围,已有决定直接沿用。
|
||||
|
||||
任务通常依次经过:原子领取并读取工单 → 确认范围/原型 → 任务分支与 worktree 实现 → 推送任务分支并创建/复用 PR → 受影响代码的单元测试与审查 → 同一时刻只合入一个 worktree → 按授权合并 PR → 回写并关单。构建、启动和关键路径由你在合入后的集成分支上自行验收。worktree 是独立工作目录,里面有对应任务分支;它不是集成主分支。
|
||||
|
||||
## 仅允许本会话处理一张工单
|
||||
|
||||
@@ -90,42 +94,45 @@ Agent 发出需要你答复的问题后,默认在本机播放一次“有问
|
||||
|
||||
工程已明确选择直接合并则沿用;希望切换时可说:“本工程使用 PR 合并”,或“本工程明确采用本地合并后直接推送集成分支,保留相同测试与关单流程”。Agent 将选择记录在工程自己的 AGENTS.md 非受管区或已有工程规则中;当前 project.json 无合并方式字段,不要自行新增。升级不覆盖这些规则,也不修改仓库保护或启用 CI。
|
||||
|
||||
PR 合并后仍需同步本机、核对环境、回写并关闭工单,再检查清理 worktree。使用普通工单链接关联,避免自动关闭关键字使工单提前关闭。具体规则见安装目录 `references/pull-requests.md`;PR 操作由 Agent 使用平台工具完成,pda.py 不提供 PR 操作命令。
|
||||
PR 合并后仍需同步本地集成分支、回写并关闭工单,再检查清理 worktree。评论里写明构建验收留给你。使用普通工单链接关联,避免自动关闭关键字使工单提前关闭。具体规则见安装目录 `references/pull-requests.md`;PR 操作由 Agent 使用平台工具完成,pda.py 不提供 PR 操作命令。
|
||||
|
||||
## 谁来测试,何时合并
|
||||
|
||||
默认由 Agent 执行适用的测试、构建和界面验证。若工程已授权常规自动收尾,通过后会合入指定集成分支、推送并关闭工单;仅安装智能体不会授予这些权限。你希望采用此方式时,可以明确发送:
|
||||
默认由 Agent 在 worktree 运行受影响代码的单元测试。通过且审查没有阻塞后,串行合入指定集成分支并关闭工单;整体构建和运行验收留给你。仅安装智能体不会授予推送或合并权限。你希望采用此方式时,可以明确发送:
|
||||
|
||||
```text
|
||||
本工程常规需求和修复默认由 Agent 测试。
|
||||
本工程常规需求和修复默认由 Agent 在 worktree 跑单元测试。
|
||||
长期授权本工程常规任务维护工单占用及同步流程标签。
|
||||
测试与审查通过后,授权推送任务分支、创建 PR、合入已确认的集成分支,
|
||||
同步本地与远端并回写、关闭对应工单。
|
||||
单元测试与审查通过后,授权推送任务分支、创建 PR、合入已确认的集成分支,
|
||||
同步本地与远端并回写、关闭对应工单。同一时刻只合入一个 worktree。
|
||||
构建、启动和关键路径由我在合入后自行验收。
|
||||
打 Tag、部署及服务启停仍按工程单独授权。
|
||||
```
|
||||
|
||||
你自己测试时发送:
|
||||
你自己跑单元测试时发送:
|
||||
|
||||
```text
|
||||
这次我来测试。完成实现和代码审查后,给我任务分支、worktree 路径、提交号、
|
||||
前后端运行入口和验证步骤。必须等我明确反馈,不要自动测试、构建或合并。
|
||||
这次单元测试我来做。完成实现和代码审查后,给我任务分支、worktree 路径、
|
||||
提交号和检查步骤。必须等我明确反馈,不要自动测试或合并。
|
||||
```
|
||||
|
||||
任务版本验证通过后回复“该提交测试通过,请继续集成环境验证与收尾”;失败则发复现步骤和截图。服务实际运行的目录、端口和提交须对应待测版本,服务启停仍遵守工程规则。
|
||||
该提交的检查通过后回复“该提交检查通过,请串行合入并收尾”;失败则发复现步骤。合入后的构建验收另行进行,服务启停仍遵守工程规则。
|
||||
|
||||
“一分钟推荐选择”默认启用且未被关闭时,没有回复可能按已展示的推荐转为 Agent 实际测试,但沉默不会被记为用户验收通过;上面的“必须等我”会要求本次保持等待。提问、原型及测试细则分别见安装目录 `references/planning.md`、`ui-design.md`、`testing.md`。
|
||||
|
||||
## 修复后,本机环境怎样切回主分支
|
||||
## 代码完成后怎样合入主分支
|
||||
|
||||
任务版本先在独立分支/worktree 中验证。通过后结合最新主分支准备合并结果,把本机环境切到集成版本做启动、前后端连通和关键功能冒烟检查;通过后才更新远端主分支。默认 PR 模式先用本地等价集成候选验证,再合并远端并安全同步本地。
|
||||
这里的主分支是本工程配置的集成分支,不固定叫 main。worktree 里受影响代码的单元测试通过后,Agent 先把最新集成分支合进任务分支;代码有变化就重跑受影响的单元测试,再用 Git 合入集成分支。不在 worktree 里做构建或启动验收,这部分由你在合入之后自行验收。
|
||||
|
||||
失败时保留现场,回原任务分支修复、重新验证和集成,不在主分支直接补丁式修改,也不关闭工单。相同代码不重复全部测试,但目录、容器、配置、数据库或产物变化仍要验证;纯文档等无运行影响时可记录不适用。
|
||||
多个 worktree 可以同时写代码、同时跑单元测试。写入同一个集成分支时一次只允许一个:当前有任务正在合入,其他任务等待,合完并释放后再继续下一个。目标分支在等待期间又有新提交时,先把新提交接回来并补跑单元测试,再合入。不会用强推覆盖别人刚合进去的代码。
|
||||
|
||||
你选择自己测试时,任务验证和集成环境冒烟都由你完成。禁止 Agent 启停服务的工程,仍由你操作;未完成必要环境验证前暂不更新远端主分支、关单。worktree 不自动隔离端口或数据库,也不会自动重启服务。可以说:“请提供任务版本和切回主分支后的两阶段验证入口,服务由我启停。”
|
||||
单元测试失败或冲突还没解决时,留在原来的任务分支修,不直接在主分支上打补丁,也不关单。你选择自己做单元测试时,确认该提交通过后才进入这条合入队列;构建验收同样留在合入之后。
|
||||
|
||||
完整步骤见安装目录 `references/local-validation.md`。合并后还要核对本机真正运行的是集成版本,避免仍在访问旧 worktree 或旧构建产物。
|
||||
完整步骤见安装目录 `references/local-validation.md` 和 `references/issue-claims.md`。
|
||||
|
||||
普通工单和队列共用收尾检查。你说“验证通过,请提交并 push”时,智能体应续接原工单,在已有授权且必要验证完成后回写、关闭并重新读取确认。最终回复会列出工单、PR 状态或直接合并依据、提交、验收依据及关闭状态;只推送成功不代表工单完成。要暂缓可明确说“只推送,先不要关闭工单”。
|
||||
进行中的工单可以让 Agent 先运行 `task-next`。它只报告下一步、缺的证据、能不能合入,以及该看的一份规则,不代替测试或合并。
|
||||
|
||||
普通工单和队列共用收尾检查。你说“验证通过,请提交并 push”时,智能体应续接原工单,在已有授权且单元测试证据有效后回写、关闭并重新读取确认。最终回复分成三行:单元测试结果、合入提交、构建与运行尚未由 Agent 验收。只推送成功不代表工单完成。要暂缓可明确说“只推送,先不要关闭工单”。
|
||||
|
||||
本地收尾进度保存在 Git 忽略的 `.local/agent/delivery/`,由 Agent 使用 `delivery-status`、`delivery-record`、`delivery-check`;普通工单与队列取得占用后都使用 `queue-finish`,无需你手动运行一串命令。中断后先核对已完成动作,不重复发表评论。详见安装目录 `references/completion.md`。这些命令不自动测试、关单或后台续跑。
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -6,7 +6,7 @@
|
||||
*
|
||||
* 微信公众平台须为对应环境配置 request / 业务域名(HTTPS)
|
||||
*/
|
||||
export const APP_ENV = 'local' // 'local' | 'test' | 'prod'
|
||||
export const APP_ENV = 'test' // 'local' | 'test' | 'prod'
|
||||
|
||||
const ENV_PRESETS = {
|
||||
local: {
|
||||
|
||||
@@ -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')
|
||||
@@ -28,25 +28,27 @@
|
||||
class="market-form-fields"
|
||||
:class="{ 'market-form-fields--grid-2': section.fieldsLayout === 'grid-2' }"
|
||||
>
|
||||
<MarketFormField
|
||||
v-for="field in visibleFields"
|
||||
:key="field.key"
|
||||
:field="field"
|
||||
:model-value="value?.[field.key]"
|
||||
:options="fieldOptions(field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(field)"
|
||||
:search-customers="searchCustomers"
|
||||
@update:model-value="setValue(field, $event)"
|
||||
@upload="$emit('upload', { section, field, file: $event })"
|
||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||
/>
|
||||
<template v-for="item in fieldsWithInsert" :key="item.field.key">
|
||||
<slot v-if="item.insertBefore" name="insert" />
|
||||
<MarketFormField
|
||||
:field="item.field"
|
||||
:model-value="value?.[item.field.key]"
|
||||
:options="fieldOptions(item.field)"
|
||||
:uploading="uploading"
|
||||
:disabled="fieldDisabled(item.field)"
|
||||
:search-customers="searchCustomers"
|
||||
@update:model-value="setValue(item.field, $event)"
|
||||
@upload="$emit('upload', { section, field: item.field, file: $event })"
|
||||
@customer-select="$emit('customer-select', { section, ...$event })"
|
||||
/>
|
||||
</template>
|
||||
<slot v-if="appendInsert" name="insert" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, useSlots } from 'vue'
|
||||
import MarketFormField from './MarketFormField.vue'
|
||||
import { sampleCodeOptionsForRow } from '../reference-options.js'
|
||||
|
||||
@@ -58,9 +60,12 @@ const props = defineProps({
|
||||
mode: { type: String, default: 'create' },
|
||||
uploading: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
searchCustomers: { type: Function, default: null }
|
||||
searchCustomers: { type: Function, default: null },
|
||||
/** 在首个匹配可见字段前插入 #insert 插槽(如协议状态下的项目归属) */
|
||||
insertBeforeKeys: { type: Array, default: () => [] }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue', 'upload', 'field-change', 'customer-select'])
|
||||
const slots = useSlots()
|
||||
const value = computed(() => props.modelValue || {})
|
||||
const rows = computed(() => Array.isArray(props.modelValue) ? props.modelValue : [])
|
||||
const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
@@ -71,6 +76,19 @@ const visibleFields = computed(() => (props.section.fields || []).filter(
|
||||
!(typeof field.showWhen === 'function' && field.showWhen(value.value) === false) &&
|
||||
!(field.createOnly && props.mode !== 'create')
|
||||
))
|
||||
const fieldsWithInsert = computed(() => {
|
||||
const keys = props.insertBeforeKeys || []
|
||||
let inserted = false
|
||||
return visibleFields.value.map((field) => {
|
||||
const insertBefore = Boolean(slots.insert) && !inserted && keys.includes(field.key)
|
||||
if (insertBefore) inserted = true
|
||||
return { field, insertBefore }
|
||||
})
|
||||
})
|
||||
const appendInsert = computed(() => {
|
||||
if (!slots.insert || !(props.insertBeforeKeys || []).length) return false
|
||||
return !fieldsWithInsert.value.some((item) => item.insertBefore)
|
||||
})
|
||||
|
||||
function fieldOptions(field, rowIndex = -1) {
|
||||
const base = field.options || props.options[field.optionKey] || (field.optionKey === 'users' ? props.options.staff : []) || []
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
<template>
|
||||
<section class="market-form-section">
|
||||
<h2>{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
|
||||
<component :is="embedded ? 'div' : 'section'" :class="rootClass">
|
||||
<h2 v-if="!embedded">{{ filter ? '项目筛选' : '项目归属' }}<span v-if="required && !editing && !filter"> *</span></h2>
|
||||
<label v-else :id="labelId">所属项目<b v-if="required && !editing && !filter">*</b></label>
|
||||
<template v-if="editing">
|
||||
<van-cell title="所属项目" :value="projectLabel(record)" />
|
||||
<van-cell title="项目组" :value="record.teamName || '—'" />
|
||||
<van-cell label="归属调整请使用 Web 项目归属维护" />
|
||||
<template v-if="embedded">
|
||||
<div class="market-form-field__control market-form-field__control--text" :aria-labelledby="labelId">
|
||||
{{ projectLabel(record) }}
|
||||
</div>
|
||||
<p class="market-form-field__tip">项目组:{{ record.teamName || '—' }};归属调整请使用 Web 项目归属维护</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-cell title="所属项目" :value="projectLabel(record)" />
|
||||
<van-cell title="项目组" :value="record.teamName || '—'" />
|
||||
<van-cell label="归属调整请使用 Web 项目归属维护" />
|
||||
</template>
|
||||
</template>
|
||||
<template v-else>
|
||||
<PresignSelectField
|
||||
:model-value="modelValue || ''"
|
||||
:options="selectOptions"
|
||||
:disabled="disabled || loading || Boolean(error)"
|
||||
:loading="loading"
|
||||
searchable
|
||||
title="所属项目"
|
||||
:placeholder="filter ? '全部项目' : '请选择所属项目'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
<van-cell v-if="loading" title="正在加载项目…" />
|
||||
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
|
||||
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
|
||||
<div :class="embedded ? 'market-form-field__control' : undefined">
|
||||
<PresignSelectField
|
||||
:model-value="modelValue || ''"
|
||||
:options="selectOptions"
|
||||
:disabled="disabled || loading || Boolean(error)"
|
||||
:loading="loading"
|
||||
searchable
|
||||
title="所属项目"
|
||||
:placeholder="filter ? '全部项目' : '请选择所属项目'"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="embedded">
|
||||
<p v-if="loading" class="market-form-field__tip">正在加载项目…</p>
|
||||
<button v-else-if="error" type="button" class="market-form-field__tip market-form-field__tip--action" @click="load">
|
||||
项目加载失败,点击重试({{ error }})
|
||||
</button>
|
||||
<p v-else-if="!options.length" class="market-form-field__tip">
|
||||
{{ required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目' }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<van-cell v-if="loading" title="正在加载项目…" />
|
||||
<van-cell v-else-if="error" :label="error" title="项目加载失败,点击重试" is-link @click="load" />
|
||||
<van-cell v-else-if="!options.length" :label="required && !filter ? '请联系管理员配置可选项目后再保存' : '当前没有可选项目'" />
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -35,6 +57,7 @@ const props = defineProps({
|
||||
bizType: { type: String, required: true },
|
||||
editing: { type: Boolean, default: false },
|
||||
filter: { type: Boolean, default: false },
|
||||
embedded: { type: Boolean, default: false },
|
||||
context: { type: Object, default: null },
|
||||
disabled: { type: Boolean, default: false },
|
||||
record: { type: Object, default: () => ({}) }
|
||||
@@ -44,7 +67,9 @@ const options = ref([])
|
||||
const required = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const labelId = `project-assignment-${Math.random().toString(36).slice(2, 9)}`
|
||||
let controller
|
||||
const rootClass = computed(() => (props.embedded ? 'market-form-field' : 'market-form-section'))
|
||||
const selectOptions = computed(() => [
|
||||
...(!required.value || props.filter ? [{ text: props.filter ? '全部项目' : '暂不归属', value: '' }] : []),
|
||||
...options.value
|
||||
|
||||
@@ -40,7 +40,8 @@ export function createListFilters(module, source = '') {
|
||||
result.source = SOURCES.has(text(source)) ? text(source) : 'cell_storage'
|
||||
return {
|
||||
source: result.source,
|
||||
protocolStatus: result.protocolStatus
|
||||
protocolStatus: result.protocolStatus,
|
||||
projectId: ''
|
||||
}
|
||||
}
|
||||
return result
|
||||
|
||||
@@ -15,11 +15,13 @@ test('creates isolated defaults for every market module', () => {
|
||||
|
||||
assert.deepEqual(first, {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(second, {
|
||||
source: 'research_donation',
|
||||
protocolStatus: ''
|
||||
protocolStatus: '',
|
||||
projectId: ''
|
||||
})
|
||||
assert.deepEqual(createListFilters('presign'), { orderStatus: '' })
|
||||
assert.deepEqual(createListFilters('payment-chase'), { chaseType: '', payMode: '' })
|
||||
@@ -66,8 +68,14 @@ test('drops incomplete ranges and counts only user-selectable filters', () => {
|
||||
}), { progressStatus: 'yjq' })
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'cell_storage',
|
||||
protocolStatus: 'sxz'
|
||||
protocolStatus: 'sxz',
|
||||
projectId: ''
|
||||
}), 1)
|
||||
assert.equal(activeFilterCount('protocol', {
|
||||
source: 'research_donation',
|
||||
protocolStatus: 'sxz',
|
||||
projectId: 'p1'
|
||||
}), 2)
|
||||
assert.equal(activeFilterCount('freight', {
|
||||
transportMode: '', sampleSource: 'research_donation'
|
||||
}), 1)
|
||||
|
||||
@@ -313,6 +313,7 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
|
||||
assert.match(protocol, /inlineDropdownFilters/)
|
||||
assert.match(protocol, /全部协议状态/)
|
||||
assert.match(protocol, /source:\s*'research_donation'/)
|
||||
assert.match(protocol, /filterFields:\s*\[\]/)
|
||||
assert.doesNotMatch(protocol, /createTimeRange/)
|
||||
assert.doesNotMatch(protocol, /sampleCollectTimeRange/)
|
||||
@@ -320,6 +321,25 @@ test('protocol list uses inline protocol-status dropdown like presign instead of
|
||||
assert.doesNotMatch(filters, /createTimeRange/)
|
||||
assert.match(genericList, /inlineDropdownFilters/)
|
||||
assert.match(genericList, /market-list__filter-row/)
|
||||
assert.match(genericList, /showProtocolProjectFilter/)
|
||||
assert.match(genericList, /全部项目/)
|
||||
assert.doesNotMatch(genericList, /ProjectAssignmentField/)
|
||||
})
|
||||
|
||||
test('protocol form embeds project assignment inside protocol info before status fields', () => {
|
||||
const form = source('../views/market/MarketModuleForm.vue')
|
||||
const formSection = source('./components/MarketFormSection.vue')
|
||||
const projectField = source('./components/ProjectAssignmentField.vue')
|
||||
|
||||
assert.match(form, /insert-before-keys="\['protocolStatus', 'donationStatus'\]"/)
|
||||
assert.match(form, /#insert/)
|
||||
assert.match(form, /embedded/)
|
||||
assert.match(form, /<ProjectAssignmentField[\s\S]*?embedded/)
|
||||
assert.doesNotMatch(form, /ProjectAssignmentField v-if="context\.module === 'protocol'"/)
|
||||
assert.match(formSection, /insertBeforeKeys/)
|
||||
assert.match(formSection, /name="insert"/)
|
||||
assert.match(projectField, /embedded/)
|
||||
assert.match(projectField, /market-form-field/)
|
||||
})
|
||||
|
||||
test('protocol list exposes mobile batch selection and sequential authorized deletion', () => {
|
||||
|
||||
@@ -478,6 +478,12 @@ export default {
|
||||
allText: '全部协议状态',
|
||||
source: 'cell_storage',
|
||||
options: CELL_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
},
|
||||
{
|
||||
key: 'protocolStatus',
|
||||
allText: '全部协议状态',
|
||||
source: 'research_donation',
|
||||
options: DONATION_PROTOCOL_STATUS_OPTIONS.map((item) => ({ text: item.label, value: item.value }))
|
||||
}
|
||||
],
|
||||
statusLabel(detail) {
|
||||
|
||||
@@ -1247,6 +1247,25 @@ body.van-toast--unclickable .presign-order-page * {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.market-form-field > .market-form-field__tip,
|
||||
.market-form-field .market-form-field__control--text .market-form-field__tip {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.market-form-field > .market-form-field__tip--action {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #0a9f6d;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-form-field > .van-field,
|
||||
.market-form-field__control {
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -12,25 +12,52 @@
|
||||
</van-empty>
|
||||
</div>
|
||||
<template v-else>
|
||||
<ProjectAssignmentField v-if="context.module === 'protocol'" ref="projectAssignment"
|
||||
v-model="formData.projectId" :biz-type="formData.source" :editing="mode === 'edit'"
|
||||
:record="formData" :disabled="fieldsLocked" @update:model-value="dirty = true" />
|
||||
<MarketFormSection
|
||||
v-for="section in leadingSections"
|
||||
:key="section.key"
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
/>
|
||||
<template v-for="section in leadingSections" :key="section.key">
|
||||
<MarketFormSection
|
||||
v-if="!(context.module === 'protocol' && section.key === 'protocol')"
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
/>
|
||||
<MarketFormSection
|
||||
v-else
|
||||
:section="section"
|
||||
:model-value="sectionValue(section)"
|
||||
:options="formOptions"
|
||||
:source="formData.source"
|
||||
:mode="mode"
|
||||
:uploading="false"
|
||||
:disabled="fieldsLocked"
|
||||
:search-customers="searchProtocolCustomers"
|
||||
:insert-before-keys="['protocolStatus', 'donationStatus']"
|
||||
@update:model-value="setSectionValue(section, $event)"
|
||||
@upload="uploadField"
|
||||
@field-change="handleFieldChange"
|
||||
@customer-select="handleCustomerSelect"
|
||||
>
|
||||
<template #insert>
|
||||
<ProjectAssignmentField
|
||||
ref="projectAssignment"
|
||||
embedded
|
||||
v-model="formData.projectId"
|
||||
:biz-type="formData.source"
|
||||
:editing="mode === 'edit'"
|
||||
:record="formData"
|
||||
:disabled="fieldsLocked"
|
||||
@update:model-value="dirty = true"
|
||||
/>
|
||||
</template>
|
||||
</MarketFormSection>
|
||||
</template>
|
||||
|
||||
<section
|
||||
v-if="context.module === 'protocol' && formData.source === 'cell_storage' && showDepositSection"
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
:show-header="false"
|
||||
>
|
||||
<div class="market-list__filters">
|
||||
<ProjectAssignmentField v-if="context.module === 'protocol'" v-model="listFilters.projectId"
|
||||
:biz-type="listStatus" filter :context="context" @update:model-value="search" />
|
||||
<div
|
||||
v-if="listFilterTabs.length"
|
||||
class="market-source-tabs"
|
||||
@@ -59,15 +57,24 @@
|
||||
<span v-if="activeFilters" class="market-list__filter-count">{{ activeFilters }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="inlineDropdownFilters.length" class="market-list__filter-row">
|
||||
<div
|
||||
v-if="inlineDropdownFilters.length || showProtocolProjectFilter"
|
||||
class="market-list__filter-row"
|
||||
>
|
||||
<van-dropdown-menu active-color="#0a9f6d">
|
||||
<van-dropdown-item
|
||||
v-for="field in inlineDropdownFilters"
|
||||
:key="field.key"
|
||||
:key="`${field.source || 'all'}:${field.key}`"
|
||||
:model-value="listFilters[field.key]"
|
||||
:options="inlineFilterOptionsByKey[field.key] || []"
|
||||
@change="(value) => onInlineFilterChange(field.key, value)"
|
||||
/>
|
||||
<van-dropdown-item
|
||||
v-if="showProtocolProjectFilter"
|
||||
:model-value="listFilters.projectId || ''"
|
||||
:options="protocolProjectFilterOptions"
|
||||
@change="(value) => onInlineFilterChange('projectId', value)"
|
||||
/>
|
||||
</van-dropdown-menu>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,11 +161,15 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ProjectAssignmentField from '../../market/components/ProjectAssignmentField.vue'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { showConfirmDialog, showSuccessToast, showToast } from 'vant'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { fetchMarketFormOptions, fetchMarketList, performMarketAction } from '../../api/market.js'
|
||||
import {
|
||||
fetchMarketFormOptions,
|
||||
fetchMarketList,
|
||||
fetchProjectOptions,
|
||||
performMarketAction
|
||||
} from '../../api/market.js'
|
||||
import { intersectAllowedActions } from '../../market/action-policy.js'
|
||||
import { deleteMarketItemsSequentially } from '../../market/batch-delete.js'
|
||||
import {
|
||||
@@ -169,6 +180,7 @@ import {
|
||||
import { marketSearchPlaceholder } from '../../market/list-presenter.js'
|
||||
import { marketErrorMessage } from '../../market/market-error.js'
|
||||
import { getMarketModule } from '../../market/module-registry.js'
|
||||
import { projectOptions } from '../../market/project-assignment.js'
|
||||
import {
|
||||
getMarketRouteContext,
|
||||
MARKET_MODULE_TITLES,
|
||||
@@ -247,6 +259,12 @@ const inlineDropdownFilters = computed(() => {
|
||||
const source = listFilters.value.source || listStatus.value
|
||||
return rows.filter((field) => !field.source || field.source === source)
|
||||
})
|
||||
const showProtocolProjectFilter = computed(() => context.value.module === 'protocol')
|
||||
const protocolProjectOptions = ref([])
|
||||
const protocolProjectFilterOptions = computed(() => [
|
||||
{ text: '全部项目', value: '' },
|
||||
...protocolProjectOptions.value
|
||||
])
|
||||
const activeFilters = computed(() =>
|
||||
activeFilterCount(context.value.module, listFilters.value)
|
||||
)
|
||||
@@ -299,6 +317,7 @@ const allDeletableSelected = computed(() =>
|
||||
)
|
||||
let requestSequence = 0
|
||||
let abortController = null
|
||||
let projectOptionsController = null
|
||||
let searchTimer = null
|
||||
|
||||
function normalizeList(result) {
|
||||
@@ -360,7 +379,32 @@ function queueSearch() {
|
||||
searchTimer = window.setTimeout(search, 350)
|
||||
}
|
||||
|
||||
async function loadProtocolProjectOptions() {
|
||||
projectOptionsController?.abort()
|
||||
if (!showProtocolProjectFilter.value) {
|
||||
protocolProjectOptions.value = []
|
||||
return
|
||||
}
|
||||
const current = new AbortController()
|
||||
projectOptionsController = current
|
||||
const bizType = listFilters.value.source || listStatus.value || 'cell_storage'
|
||||
try {
|
||||
const data = await fetchProjectOptions(bizType, {
|
||||
signal: current.signal,
|
||||
filter: true,
|
||||
context: context.value
|
||||
})
|
||||
if (projectOptionsController !== current) return
|
||||
protocolProjectOptions.value = projectOptions(data)
|
||||
} catch (error) {
|
||||
if (projectOptionsController === current && error?.name !== 'AbortError') {
|
||||
protocolProjectOptions.value = []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadListFilterOptions() {
|
||||
await loadProtocolProjectOptions()
|
||||
const optionKeys = [
|
||||
...sheetFilterFields.value.map((field) => field.optionKey),
|
||||
...inlineDropdownFilters.value.map((field) => field.optionKey)
|
||||
@@ -596,6 +640,7 @@ function switchListStatus(value) {
|
||||
tab: next
|
||||
}
|
||||
router.replace({ query: nextQuery }).catch(() => {})
|
||||
loadProtocolProjectOptions()
|
||||
loadPage(true)
|
||||
}
|
||||
|
||||
@@ -644,6 +689,7 @@ onBeforeUnmount(() => {
|
||||
const requestId = ++requestSequence
|
||||
listState.value = invalidateMarketRequestState(listState.value, requestId)
|
||||
abortController?.abort()
|
||||
projectOptionsController?.abort()
|
||||
window.clearTimeout(searchTimer)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,368 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
CELL_STORAGE_HEADERS,
|
||||
CUSTOMER_SERVICE_HEADERS,
|
||||
PRESIGN_HEADERS,
|
||||
RESEARCH_HEADERS,
|
||||
buildCellStorageLedgerRows,
|
||||
buildCustomerServiceLedgerRows,
|
||||
buildFreightMergeRanges,
|
||||
buildPresignLedgerRows,
|
||||
buildResearchLedgerRows,
|
||||
formatLedgerDateTime
|
||||
} from './collection-ledger-export'
|
||||
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
|
||||
|
||||
describe('formatLedgerDateTime', () => {
|
||||
it('uses Chinese text and keeps seconds', () => {
|
||||
assert.equal(formatLedgerDateTime('2026-04-02 16:49:42'), '2026年4月2日16时49分42秒')
|
||||
assert.equal(formatLedgerDateTime('20250907'), '2025年9月7日')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildPresignLedgerRows', () => {
|
||||
it('maps order fields into the presign sheet columns', () => {
|
||||
const [row] = buildPresignLedgerRows([
|
||||
{
|
||||
orderDate: '20260402',
|
||||
protocolNo: 'ZKZX1',
|
||||
motherName: '杨育蕾',
|
||||
sampleTypeNames: ['围产期造血干细胞'],
|
||||
regionName: '亳州区域',
|
||||
hospitalName: '涡阳县人民医院',
|
||||
payMode: 'dj',
|
||||
sampleCollectTime: '2026-04-02 16:49:42',
|
||||
contractAmount: 22880,
|
||||
confirmedDepositAmount: 1000,
|
||||
transferTimes: ['2026-04-02 16:49:42'],
|
||||
expectedDeliveryDate: '20260930',
|
||||
salesUserName: '张慧茹',
|
||||
regionManagerUserName: '张颖',
|
||||
remark: '已存'
|
||||
}
|
||||
])
|
||||
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[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', () => {
|
||||
it('places installment amounts and keeps template header width', () => {
|
||||
const flat = {
|
||||
protocolId: 'p1',
|
||||
protocol: {
|
||||
protocolStatus: 'sxz',
|
||||
remark: '分期备注',
|
||||
voidType: '',
|
||||
voidDesc: ''
|
||||
},
|
||||
sampleIndex: 0,
|
||||
isCoStored: false,
|
||||
sampleCollectTime: '2025-09-15 08:51:00',
|
||||
motherName: '郭梦朵',
|
||||
protocolNo: 'PROTO-SHOULD-NOT-EXPORT',
|
||||
sampleCode: 'ZKZX34000849',
|
||||
sampleTypeName: '围产期造血干细胞',
|
||||
regionName: '亳州区域',
|
||||
hospitalName: '涡阳县人民医院',
|
||||
labStorageCode: 'B0000004',
|
||||
salesUserName: '马丽',
|
||||
regionManagerUserName: '张颖',
|
||||
transportType: 'kdys',
|
||||
freightId: 'fr-cell-1',
|
||||
freightTransportLabel: '专线车',
|
||||
freightAmountLabel: '120',
|
||||
monthlyVerifyNo: '202509012',
|
||||
paymentSummary: {
|
||||
payMode: 'fq',
|
||||
contractAmount: 22880,
|
||||
receivedAmount: 6480,
|
||||
pendingAmount: 16400,
|
||||
firstPayTransferTime: '2025-09-15 11:13:01',
|
||||
paymentDetails: [
|
||||
{
|
||||
installmentNo: 1,
|
||||
dueAmount: 6480,
|
||||
transferAmount: 6480,
|
||||
installmentDueDate: '20250915',
|
||||
transferTime: '2025-09-15 11:13:01'
|
||||
},
|
||||
{
|
||||
installmentNo: 2,
|
||||
dueAmount: 16400,
|
||||
transferAmount: 0,
|
||||
installmentDueDate: '20251215',
|
||||
transferTime: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
const { rows } = buildCellStorageLedgerRows([flat])
|
||||
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')
|
||||
assert.equal(row[11], '否')
|
||||
assert.equal(row[13], '涡阳县人民医院')
|
||||
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')
|
||||
assert.equal(row[26], '2025年12月15日')
|
||||
assert.equal(row[50], '马丽')
|
||||
assert.equal(row[52], '专线车')
|
||||
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: '' },
|
||||
sampleCollectTime: '2025-09-11 18:13:00',
|
||||
motherName: '甲',
|
||||
sampleCode: 'S1',
|
||||
sampleTypeName: 'NK',
|
||||
regionName: '常州',
|
||||
hospitalName: '溧阳市人民医院',
|
||||
labStorageCode: 'UBMC1',
|
||||
freightId: 'batch-cell',
|
||||
freightTransportLabel: '专线车',
|
||||
freightAmountLabel: '100'
|
||||
}
|
||||
const { rows, merges } = buildCellStorageLedgerRows([
|
||||
{ ...base, sampleIndex: 0 } as unknown as ProtocolAuditFlatRow,
|
||||
{
|
||||
...base,
|
||||
sampleIndex: 1,
|
||||
motherName: '乙',
|
||||
sampleCode: 'S2',
|
||||
labStorageCode: 'UBMC2'
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
])
|
||||
assert.equal(rows[0][52], '专线车')
|
||||
assert.equal(rows[0][53], '100')
|
||||
assert.equal(rows[1][52], '')
|
||||
assert.equal(rows[1][53], '')
|
||||
assert.deepEqual(merges, [
|
||||
{ startRow: 2, endRow: 3, startCol: 53, endCol: 53 },
|
||||
{ startRow: 2, endRow: 3, startCol: 54, endCol: 54 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildResearchLedgerRows', () => {
|
||||
it('maps donation fields and consent flag', () => {
|
||||
const flat = {
|
||||
protocol: {
|
||||
donationStatus: 'yzf',
|
||||
voidType: 'sysfq',
|
||||
voidDesc: '母血乙肝阳性',
|
||||
monthlyVerifyNo: '202508001'
|
||||
},
|
||||
sampleIndex: 0,
|
||||
sampleCollectTime: '2025-08-29 12:27:00',
|
||||
motherName: '曹陆双',
|
||||
sampleTypeName: '围产期造血干细胞',
|
||||
regionName: '亳州',
|
||||
hospitalName: '涡阳县人民医院',
|
||||
labStorageCode: 'A0000001',
|
||||
salesUserName: '杨莉莉',
|
||||
regionManagerUserName: '张颖',
|
||||
transportType: '',
|
||||
newbornDeliveryTime: '2025-08-29 12:26:00',
|
||||
newbornSex: '0',
|
||||
newbornWeightGram: 3000,
|
||||
deliveryMode: 'sc',
|
||||
birthOrder: 1,
|
||||
maternalBloodCollectTime: '2025-08-29 13:30:00',
|
||||
consentFormUrls: 'http://example/consent',
|
||||
remark: '',
|
||||
freightId: 'fr1',
|
||||
freightTransportLabel: '专线车',
|
||||
freightAmountLabel: '100'
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
const { rows } = buildResearchLedgerRows([flat])
|
||||
const row = rows[0]
|
||||
assert.equal(row.length, RESEARCH_HEADERS.length)
|
||||
assert.equal(row[1], '2025年')
|
||||
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], '女')
|
||||
assert.equal(row[19], '3000g')
|
||||
assert.equal(row[20], '顺产')
|
||||
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: {
|
||||
donationStatus: 'yzf',
|
||||
voidType: 'sdfq',
|
||||
voidDesc: '',
|
||||
monthlyVerifyNo: ''
|
||||
},
|
||||
sampleIndex: 0,
|
||||
sampleCollectTime: '2025-08-29 12:27:00',
|
||||
motherName: '测试',
|
||||
sampleTypeName: '围产期造血干细胞',
|
||||
regionName: '亳州',
|
||||
hospitalName: '涡阳县人民医院',
|
||||
labStorageCode: 'A0000002',
|
||||
salesUserName: '',
|
||||
regionManagerUserName: '',
|
||||
transportType: '',
|
||||
newbornDeliveryTime: '',
|
||||
newbornSex: '',
|
||||
newbornWeightGram: '',
|
||||
deliveryMode: 'pgc',
|
||||
birthOrder: '',
|
||||
maternalBloodCollectTime: '',
|
||||
consentFormUrls: '',
|
||||
remark: '',
|
||||
freightId: '',
|
||||
freightTransportLabel: '',
|
||||
freightAmountLabel: ''
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
const { rows } = buildResearchLedgerRows([flat])
|
||||
const row = rows[0]
|
||||
assert.equal(row[6], '属地废弃')
|
||||
assert.equal(row[7], '属地废弃')
|
||||
assert.equal(row[9], '属地废弃')
|
||||
assert.equal(row[20], '剖腹产')
|
||||
assert.equal(row[22], '无母血')
|
||||
})
|
||||
|
||||
it('merges transport and freight columns for the same freight batch', () => {
|
||||
const base = {
|
||||
protocol: { donationStatus: 'sxz', voidType: '', voidDesc: '', monthlyVerifyNo: '' },
|
||||
sampleCollectTime: '2025-09-11 18:13:00',
|
||||
motherName: '甲',
|
||||
sampleTypeName: 'NK',
|
||||
regionName: '常州',
|
||||
hospitalName: '溧阳市人民医院',
|
||||
labStorageCode: 'UBMC1',
|
||||
freightId: 'batch-a',
|
||||
freightTransportLabel: '专线车',
|
||||
freightAmountLabel: '100'
|
||||
}
|
||||
const { rows, merges } = buildResearchLedgerRows([
|
||||
{ ...base, sampleIndex: 0 } as unknown as ProtocolAuditFlatRow,
|
||||
{
|
||||
...base,
|
||||
sampleIndex: 1,
|
||||
motherName: '乙',
|
||||
labStorageCode: 'UBMC2'
|
||||
} as unknown as ProtocolAuditFlatRow
|
||||
])
|
||||
assert.equal(rows[0][16], '专线车')
|
||||
assert.equal(rows[0][17], '100')
|
||||
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 }
|
||||
])
|
||||
assert.deepEqual(buildFreightMergeRanges(['', 'x', 'x', 'y']), [
|
||||
{ startRow: 3, endRow: 4, startCol: 17, endCol: 17 },
|
||||
{ startRow: 3, endRow: 4, startCol: 18, endCol: 18 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildCustomerServiceLedgerRows', () => {
|
||||
it('fills protocol-number column with sample code', () => {
|
||||
const [row] = buildCustomerServiceLedgerRows([
|
||||
{
|
||||
labStorageCode: 'B0000002',
|
||||
protocolStatus: 'sxz',
|
||||
smsSendDate: '20250908',
|
||||
smsSendStatus: '已发',
|
||||
sampleCode: 'ZKZX34000852',
|
||||
protocolNo: 'PROTO-SHOULD-NOT-EXPORT',
|
||||
motherName: '高居巧',
|
||||
motherPhone: '17860507206',
|
||||
motherIdCard: '370911199712254424',
|
||||
protocolAttachmentPresent: true,
|
||||
insuranceDocPresent: false,
|
||||
informedConsentPresent: true,
|
||||
healthSurveyPresent: true,
|
||||
payModeLabel: '趸交'
|
||||
}
|
||||
])
|
||||
assert.equal(row.length, CUSTOMER_SERVICE_HEADERS.length)
|
||||
assert.equal(row[0], 'B0000002')
|
||||
assert.equal(row[2], '1')
|
||||
assert.equal(row[5], 'ZKZX34000852')
|
||||
assert.equal(row[8], '370911199712254424')
|
||||
assert.equal(row[29], '1')
|
||||
assert.equal(row[30], '0')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,748 @@
|
||||
import type { ProtocolAuditFlatRow } from './protocol-audit-flatten'
|
||||
|
||||
/** 采集大表导出入口开关;验收未完成前隐藏按钮,改 true 即可恢复。 */
|
||||
export const COLLECTION_LEDGER_EXPORT_ENABLED = false
|
||||
|
||||
type Row = Record<string, unknown>
|
||||
type PayDetail = {
|
||||
installmentNo?: number | null
|
||||
dueAmount?: number | null
|
||||
transferAmount?: number | null
|
||||
installmentDueDate?: string
|
||||
transferTime?: string
|
||||
}
|
||||
|
||||
export const PRESIGN_HEADERS = [
|
||||
'序号',
|
||||
'年',
|
||||
'月',
|
||||
'日',
|
||||
'协议编号',
|
||||
'母亲姓名',
|
||||
'样本类型',
|
||||
'采集地区',
|
||||
'采集医院',
|
||||
'缴费方式',
|
||||
'采集时间',
|
||||
'合同金额',
|
||||
'到账金额',
|
||||
'刷卡时间',
|
||||
'预产期',
|
||||
'市场专员',
|
||||
'地区经理',
|
||||
'备注',
|
||||
'退款金额'
|
||||
]
|
||||
|
||||
/** 表头与《采集大表》自存表第 1 行一致,含原表里的医院名和「分期」。 */
|
||||
export const CELL_STORAGE_HEADERS = [
|
||||
'标准化加工全流程',
|
||||
'年',
|
||||
'月',
|
||||
'日',
|
||||
'当月核量号',
|
||||
'实验室\n流转编码',
|
||||
'储存状态',
|
||||
'废弃\n原因',
|
||||
'母亲姓名',
|
||||
'协议编号',
|
||||
'样本类型',
|
||||
'是否同存',
|
||||
'采集地区',
|
||||
'涡阳县人民医院',
|
||||
'分期',
|
||||
'采集时间',
|
||||
'合同金额\n(元)',
|
||||
'首期应缴费\n金额(元)',
|
||||
'已缴金额\n(元)',
|
||||
'悔存/废弃\n已缴金额',
|
||||
'退款金额',
|
||||
'备注',
|
||||
'首期\n缴费时间',
|
||||
'首期欠缴\n金额(元)',
|
||||
'分期欠缴\n金额(元)',
|
||||
'第二次应缴费\n金额(元)',
|
||||
'第二次应缴费\n时间',
|
||||
'第二次已\n缴金额(元)',
|
||||
'第二次已\n缴费时间',
|
||||
'第二次欠缴\n金额(元)',
|
||||
'第三次应缴费\n金额(元)',
|
||||
'第三次应缴费\n时间',
|
||||
'第三次已\n缴金额(元)',
|
||||
'第三次已\n缴费时间',
|
||||
'第三次欠缴\n金额(元)',
|
||||
'第四次应缴费\n金额(元)',
|
||||
'第四次应缴费\n时间',
|
||||
'第四次已\n缴金额(元)',
|
||||
'第四次已\n缴费时间',
|
||||
'第四次欠缴\n金额(元)',
|
||||
'第五次应缴费\n金额(元)',
|
||||
'第五次应缴费\n时间',
|
||||
'第五次已\n缴金额(元)',
|
||||
'第五次已\n缴费时间',
|
||||
'第五次欠缴\n金额(元)',
|
||||
'第六次应缴费\n金额(元)',
|
||||
'第六次应缴费\n时间',
|
||||
'第六次已\n缴金额(元)',
|
||||
'第六次已\n缴费时间',
|
||||
'第六次欠缴\n金额(元)',
|
||||
'市场专员',
|
||||
'地区经理',
|
||||
'运输方式',
|
||||
'运费',
|
||||
'回单超链接-快捷方式',
|
||||
'发票',
|
||||
'特殊情况说明'
|
||||
]
|
||||
|
||||
export const RESEARCH_HEADERS = [
|
||||
'序号',
|
||||
'年份',
|
||||
'月',
|
||||
'日',
|
||||
'当月核量号',
|
||||
'实验室编码',
|
||||
'储存状态',
|
||||
'废弃原因',
|
||||
'母亲姓名',
|
||||
'围产期造血干细胞',
|
||||
'采集\n地区',
|
||||
'采集医院',
|
||||
'分娩时间',
|
||||
'采集时间',
|
||||
'市场专员@@',
|
||||
'地区经理',
|
||||
'运输方式',
|
||||
'运费',
|
||||
'性别',
|
||||
'新生儿体重',
|
||||
'分娩方式',
|
||||
'胎次',
|
||||
'母血采集时间',
|
||||
'知情同意书',
|
||||
'备注'
|
||||
]
|
||||
|
||||
export const CUSTOMER_SERVICE_HEADERS = [
|
||||
'实验室流转编码',
|
||||
'储存状态',
|
||||
'序号',
|
||||
'发送日期',
|
||||
'短信发送\n情况',
|
||||
'协议编号',
|
||||
'母亲姓名',
|
||||
'母亲电话',
|
||||
'母亲身份证号码',
|
||||
'父亲姓名',
|
||||
'父亲电话',
|
||||
'父亲身份证号码',
|
||||
'回访日期',
|
||||
'微生物\n回访情况',
|
||||
'满月\n回访时间',
|
||||
'回访情况',
|
||||
'是否\n上户口',
|
||||
'宝宝姓名',
|
||||
'宝宝身份证号码',
|
||||
'性别',
|
||||
'分娩时间',
|
||||
'购买保险时间',
|
||||
'保单发送时间',
|
||||
'电子发票\n发送时间',
|
||||
'邮寄储存证时间',
|
||||
'有效地址',
|
||||
'市场专员',
|
||||
'缴费方式',
|
||||
'备注',
|
||||
'协议书',
|
||||
'保险单',
|
||||
'知情同意书',
|
||||
'健康调查表'
|
||||
]
|
||||
|
||||
const PROTOCOL_STATUS: Record<string, string> = {
|
||||
dsx: '待生效',
|
||||
sxz: '生效中',
|
||||
ygq: '已过期',
|
||||
yzf: '已作废',
|
||||
sdfq: '属地废弃',
|
||||
zky: '转科研',
|
||||
zckc: '转细胞存储'
|
||||
}
|
||||
|
||||
/** 作废类型兜底;与 void-type-dict 默认项一致,并覆盖误写入作废类型的状态码。 */
|
||||
const VOID_TYPE: Record<string, string> = {
|
||||
khhc: '客户毁存',
|
||||
jcbhg: '检测不合格',
|
||||
sysfq: '实验室废弃'
|
||||
}
|
||||
|
||||
/** 导出前由字典填充;单元测试环境可为空。 */
|
||||
let runtimeVoidLabels: Record<string, string> = {}
|
||||
|
||||
const PAY_MODE: Record<string, string> = {
|
||||
dj: '趸交',
|
||||
fq: '分期',
|
||||
djfq: '趸交+分期',
|
||||
fgd: '分改趸'
|
||||
}
|
||||
|
||||
const TRANSPORT: Record<string, string> = {
|
||||
kdys: '快递运输',
|
||||
ryxd: '人员携带'
|
||||
}
|
||||
|
||||
const SEX: Record<string, string> = {
|
||||
'1': '男',
|
||||
'0': '女'
|
||||
}
|
||||
|
||||
const DELIVERY: Record<string, string> = {
|
||||
sc: '顺产',
|
||||
pgc: '剖宫产'
|
||||
}
|
||||
|
||||
/** 科研表模板多用「剖腹产」。 */
|
||||
const RESEARCH_DELIVERY: Record<string, string> = {
|
||||
sc: '顺产',
|
||||
pgc: '剖腹产'
|
||||
}
|
||||
|
||||
export type LedgerMergeRange = {
|
||||
startRow: number
|
||||
endRow: number
|
||||
startCol: number
|
||||
endCol: number
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return value == null ? '' : String(value).trim()
|
||||
}
|
||||
|
||||
function label(map: Record<string, string>, value: unknown): string {
|
||||
const code = text(value)
|
||||
if (!code) return ''
|
||||
return map[code] || code
|
||||
}
|
||||
|
||||
export function formatLedgerMoney(value: unknown): string {
|
||||
if (value == null || value === '') return ''
|
||||
const amount = Number(value)
|
||||
if (!Number.isFinite(amount)) return text(value)
|
||||
if (Math.abs(amount - Math.round(amount)) < 1e-9) return String(Math.round(amount))
|
||||
return amount.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
|
||||
}
|
||||
|
||||
export function formatLedgerDate(value: unknown): string {
|
||||
const parts = dateParts(value)
|
||||
if (!parts) return text(value)
|
||||
return `${parts.year}年${parts.month}月${parts.day}日`
|
||||
}
|
||||
|
||||
function dateParts(value: unknown): {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
hour?: number
|
||||
minute?: number
|
||||
second?: number
|
||||
} | null {
|
||||
const raw = text(value)
|
||||
if (!raw) return null
|
||||
if (/^\d{8}$/.test(raw)) {
|
||||
return {
|
||||
year: Number(raw.slice(0, 4)),
|
||||
month: Number(raw.slice(4, 6)),
|
||||
day: Number(raw.slice(6, 8))
|
||||
}
|
||||
}
|
||||
const matched = raw.match(
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?/
|
||||
)
|
||||
if (!matched) return null
|
||||
return {
|
||||
year: Number(matched[1]),
|
||||
month: Number(matched[2]),
|
||||
day: Number(matched[3]),
|
||||
hour: matched[4] == null ? undefined : Number(matched[4]),
|
||||
minute: matched[5] == null ? undefined : Number(matched[5]),
|
||||
second: matched[6] == null ? undefined : Number(matched[6])
|
||||
}
|
||||
}
|
||||
|
||||
export function formatLedgerDateTime(value: unknown): string {
|
||||
const parts = dateParts(value)
|
||||
if (!parts) return text(value)
|
||||
let result = `${parts.year}年${parts.month}月${parts.day}日`
|
||||
if (parts.hour == null) return result
|
||||
result += `${parts.hour}时${String(parts.minute ?? 0).padStart(2, '0')}分`
|
||||
if (parts.second) result += `${String(parts.second).padStart(2, '0')}秒`
|
||||
return result
|
||||
}
|
||||
|
||||
function yearText(value: unknown, suffix: string): string {
|
||||
const parts = dateParts(value)
|
||||
if (!parts) return ''
|
||||
return `${parts.year}${suffix}`
|
||||
}
|
||||
|
||||
function monthText(value: unknown): string {
|
||||
const parts = dateParts(value)
|
||||
return parts ? String(parts.month) : ''
|
||||
}
|
||||
|
||||
function dayText(value: unknown): string {
|
||||
const parts = dateParts(value)
|
||||
return parts ? String(parts.day) : ''
|
||||
}
|
||||
|
||||
/** 作废类型:字典 → 本地兜底 → 协议状态码文案(如 sdfq→属地废弃)。 */
|
||||
function voidTypeText(value: unknown): string {
|
||||
const code = text(value)
|
||||
if (!code) return ''
|
||||
return (
|
||||
runtimeVoidLabels[code] ||
|
||||
VOID_TYPE[code] ||
|
||||
PROTOCOL_STATUS[code] ||
|
||||
code
|
||||
)
|
||||
}
|
||||
|
||||
function statusText(status: unknown, voidType: unknown): string {
|
||||
const code = text(status)
|
||||
const base = PROTOCOL_STATUS[code] || code
|
||||
if (code !== 'yzf') return base
|
||||
const reason = voidTypeText(voidType)
|
||||
return reason ? `${base}(${reason})` : base
|
||||
}
|
||||
|
||||
/** 科研表储存状态:废弃写类别文案,正常样本留空,对齐线下大表。 */
|
||||
function researchStorageStatus(status: unknown, voidType: unknown): string {
|
||||
const code = text(status)
|
||||
if (code === 'yzf') return voidTypeText(voidType)
|
||||
if (code === 'sdfq') return PROTOCOL_STATUS.sdfq
|
||||
return ''
|
||||
}
|
||||
|
||||
function voidReason(row: Row): string {
|
||||
return text(row.voidDesc) || voidTypeText(row.voidType)
|
||||
}
|
||||
|
||||
function researchTransportText(row: ProtocolAuditFlatRow): string {
|
||||
return text(row.freightTransportLabel) || transportText(row)
|
||||
}
|
||||
|
||||
function researchFreightText(row: ProtocolAuditFlatRow): string {
|
||||
return text(row.freightAmountLabel)
|
||||
}
|
||||
|
||||
function researchMaternalBloodText(value: unknown): string {
|
||||
const formatted = formatLedgerDateTime(value)
|
||||
if (formatted) return formatted
|
||||
return text(value) ? text(value) : '无母血'
|
||||
}
|
||||
|
||||
/**
|
||||
* 相邻且 freightId 相同的导出行,合并运输方式/运费列(Excel 行号含表头)。
|
||||
* @param transportCol 运输方式列(1-based)
|
||||
* @param freightCol 运费列(1-based)
|
||||
*/
|
||||
export function buildFreightMergeRanges(
|
||||
freightIds: string[],
|
||||
transportCol = 17,
|
||||
freightCol = 18
|
||||
): LedgerMergeRange[] {
|
||||
const merges: LedgerMergeRange[] = []
|
||||
let index = 0
|
||||
while (index < freightIds.length) {
|
||||
const freightId = text(freightIds[index])
|
||||
if (!freightId) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
let end = index + 1
|
||||
while (end < freightIds.length && text(freightIds[end]) === freightId) end += 1
|
||||
if (end - index >= 2) {
|
||||
const startRow = index + 2
|
||||
const endRow = end + 1
|
||||
merges.push({ startRow, endRow, startCol: transportCol, endCol: transportCol })
|
||||
merges.push({ startRow, endRow, startCol: freightCol, endCol: freightCol })
|
||||
}
|
||||
index = end
|
||||
}
|
||||
return merges
|
||||
}
|
||||
|
||||
function applyFreightMerges(
|
||||
built: string[][],
|
||||
freightIds: string[],
|
||||
transportCol: number,
|
||||
freightCol: number
|
||||
): LedgerMergeRange[] {
|
||||
const merges = buildFreightMergeRanges(freightIds, transportCol, freightCol)
|
||||
for (const merge of merges) {
|
||||
const colIndex = merge.startCol - 1
|
||||
for (let excelRow = merge.startRow + 1; excelRow <= merge.endRow; excelRow += 1) {
|
||||
const dataIndex = excelRow - 2
|
||||
if (dataIndex >= 0 && dataIndex < built.length) built[dataIndex][colIndex] = ''
|
||||
}
|
||||
}
|
||||
return merges
|
||||
}
|
||||
|
||||
/** 导出前加载作废类型字典,避免列表外的编码原样写入 Excel。 */
|
||||
export async function prepareCollectionLedgerExport(): Promise<void> {
|
||||
const { loadVoidTypeDict, getVoidTypeOptions } = await import('./void-type-dict')
|
||||
await loadVoidTypeDict()
|
||||
runtimeVoidLabels = Object.fromEntries(
|
||||
getVoidTypeOptions().map((item) => [String(item.value), String(item.label)])
|
||||
)
|
||||
}
|
||||
|
||||
function payModeText(value: unknown, fallback = ''): string {
|
||||
return label(PAY_MODE, value) || text(fallback)
|
||||
}
|
||||
|
||||
function joined(value: unknown): string {
|
||||
if (Array.isArray(value)) return value.map((item) => text(item)).filter(Boolean).join('、')
|
||||
return text(value)
|
||||
}
|
||||
|
||||
function flag01(value: unknown): string {
|
||||
if (value === true || value === 1 || value === '1') return '1'
|
||||
if (value === false || value === 0 || value === '0') return '0'
|
||||
return ''
|
||||
}
|
||||
|
||||
function weightText(value: unknown): string {
|
||||
const raw = text(value)
|
||||
if (!raw) return ''
|
||||
if (/g$/i.test(raw)) return raw
|
||||
return `${formatLedgerMoney(raw) || raw}g`
|
||||
}
|
||||
|
||||
function paymentDetails(summary: unknown): PayDetail[] {
|
||||
if (!summary || typeof summary !== 'object') return []
|
||||
const details = (summary as Row).paymentDetails
|
||||
return Array.isArray(details) ? (details as PayDetail[]) : []
|
||||
}
|
||||
|
||||
function installmentGroup(details: PayDetail[], installmentNo: number) {
|
||||
let rows = details.filter((item) => Number(item.installmentNo) === installmentNo)
|
||||
const numbered = details.some(
|
||||
(item) => item.installmentNo != null && text(item.installmentNo) !== ''
|
||||
)
|
||||
if (!rows.length && installmentNo === 1 && !numbered) rows = details.slice(0, 1)
|
||||
const dueValues = rows
|
||||
.map((item) => item.dueAmount)
|
||||
.filter((item) => item != null && item !== ('' as unknown))
|
||||
const paidValues = rows
|
||||
.map((item) => item.transferAmount)
|
||||
.filter((item) => item != null && item !== ('' as unknown))
|
||||
const due = dueValues.length
|
||||
? dueValues.reduce<number>((sum, item) => sum + Number(item || 0), 0)
|
||||
: null
|
||||
const paid = paidValues.length
|
||||
? paidValues.reduce<number>((sum, item) => sum + Number(item || 0), 0)
|
||||
: null
|
||||
const dueDate = rows.map((item) => text(item.installmentDueDate)).find(Boolean) || ''
|
||||
const paidTime = rows
|
||||
.map((item) => text(item.transferTime))
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
return { due, paid, dueDate, paidTime }
|
||||
}
|
||||
|
||||
function gapText(due: number | null, paid: number | null): string {
|
||||
if (due == null || paid == null) return ''
|
||||
return formatLedgerMoney(due - paid)
|
||||
}
|
||||
|
||||
function sampleTransport(row: ProtocolAuditFlatRow): Row {
|
||||
const items = row.protocol.sampleItems
|
||||
if (!Array.isArray(items)) return {}
|
||||
const item = items[row.sampleIndex]
|
||||
if (!item || typeof item !== 'object') return {}
|
||||
const transport = (item as Row).transport
|
||||
return transport && typeof transport === 'object' ? (transport as Row) : {}
|
||||
}
|
||||
|
||||
function transportText(row: ProtocolAuditFlatRow): string {
|
||||
const transport = sampleTransport(row)
|
||||
const company = text(transport.expressCompanyName)
|
||||
if (company) return company
|
||||
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 : []
|
||||
return [
|
||||
String(index + 1),
|
||||
yearText(orderDate, ''),
|
||||
monthText(orderDate),
|
||||
dayText(orderDate),
|
||||
text(row.protocolNo),
|
||||
text(row.motherName),
|
||||
joined(row.sampleTypeNames),
|
||||
text(row.regionName),
|
||||
text(row.hospitalName),
|
||||
payModeText(row.payMode),
|
||||
formatLedgerDateTime(row.sampleCollectTime),
|
||||
formatLedgerMoney(row.contractAmount),
|
||||
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
|
||||
const details = paymentDetails(summary)
|
||||
const first = installmentGroup(details, 1)
|
||||
const collectTime = row.sampleCollectTime
|
||||
const status = research ? protocol.donationStatus || protocol.status : protocol.protocolStatus
|
||||
const received = summary ? formatLedgerMoney(summary.receivedAmount) : ''
|
||||
const contract = formatLedgerMoney(
|
||||
summary?.contractAmount ?? protocol.protocolTotalAmount
|
||||
)
|
||||
const payMode = text(summary?.payMode)
|
||||
const firstPaidTime = text(summary?.firstPayTransferTime) || first.paidTime
|
||||
const voided = text(status) === 'yzf' || text(status) === 'sdfq'
|
||||
const cells = research ? new Array<string>(RESEARCH_HEADERS.length) : new Array<string>(CELL_STORAGE_HEADERS.length)
|
||||
cells.fill('')
|
||||
if (research) {
|
||||
cells[0] = String(index + 1)
|
||||
cells[1] = yearText(collectTime, '年')
|
||||
cells[2] = monthText(collectTime)
|
||||
cells[3] = dayText(collectTime)
|
||||
cells[4] = text(protocol.monthlyVerifyNo)
|
||||
cells[5] = text(row.labStorageCode)
|
||||
cells[6] = researchStorageStatus(status, protocol.voidType)
|
||||
cells[7] = voidReason(protocol)
|
||||
cells[8] = text(row.motherName)
|
||||
// 标准大表废弃行第 10 列复用储存状态文案;正常行写样本类型
|
||||
cells[9] = cells[6] || text(row.sampleTypeName)
|
||||
cells[10] = text(row.regionName)
|
||||
cells[11] = text(row.hospitalName)
|
||||
cells[12] = formatLedgerDateTime(row.newbornDeliveryTime)
|
||||
cells[13] = formatLedgerDateTime(collectTime)
|
||||
cells[14] = text(row.salesUserName)
|
||||
cells[15] = text(row.regionManagerUserName)
|
||||
cells[16] = researchTransportText(row)
|
||||
cells[17] = researchFreightText(row)
|
||||
cells[18] = label(SEX, row.newbornSex)
|
||||
cells[19] = weightText(row.newbornWeightGram)
|
||||
cells[20] = label(RESEARCH_DELIVERY, row.deliveryMode) || label(DELIVERY, row.deliveryMode)
|
||||
cells[21] = text(row.birthOrder)
|
||||
cells[22] = researchMaternalBloodText(row.maternalBloodCollectTime)
|
||||
cells[23] = text(row.consentFormUrls) ? '1' : ''
|
||||
cells[24] = text(row.remark)
|
||||
return cells
|
||||
}
|
||||
cells[0] = String(index + 1)
|
||||
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)
|
||||
cells[8] = text(row.motherName)
|
||||
cells[9] = text(row.sampleCode)
|
||||
cells[10] = text(row.sampleTypeName)
|
||||
cells[11] = row.isCoStored ? '是' : '否'
|
||||
cells[12] = text(row.regionName)
|
||||
cells[13] = text(row.hospitalName)
|
||||
cells[14] = payModeText(payMode, text(summary?.payModeLabel))
|
||||
cells[15] = formatLedgerDateTime(collectTime)
|
||||
cells[16] = contract
|
||||
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)
|
||||
cells[24] =
|
||||
payMode === 'fq' || payMode === 'djfq'
|
||||
? formatLedgerMoney(summary?.pendingAmount)
|
||||
: contract
|
||||
? '0'
|
||||
: ''
|
||||
let cursor = 25
|
||||
for (const installmentNo of [2, 3, 4, 5, 6]) {
|
||||
const group = installmentGroup(details, installmentNo)
|
||||
cells[cursor++] = formatLedgerMoney(group.due)
|
||||
cells[cursor++] = formatLedgerDate(group.dueDate)
|
||||
cells[cursor++] = formatLedgerMoney(group.paid)
|
||||
cells[cursor++] = formatLedgerDateTime(group.paidTime)
|
||||
cells[cursor++] = gapText(group.due, group.paid)
|
||||
}
|
||||
cells[50] = text(row.salesUserName)
|
||||
cells[51] = text(row.regionManagerUserName)
|
||||
cells[52] = researchTransportText(row)
|
||||
cells[53] = researchFreightText(row)
|
||||
return cells
|
||||
}
|
||||
|
||||
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
|
||||
return { rows: built, merges: applyFreightMerges(built, freightIds, 53, 54) }
|
||||
}
|
||||
|
||||
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
|
||||
return { rows: built, merges: applyFreightMerges(built, freightIds, 17, 18) }
|
||||
}
|
||||
|
||||
export function buildCustomerServiceLedgerRows(rows: Row[]): string[][] {
|
||||
return rows.map((row, index) => [
|
||||
text(row.labStorageCode),
|
||||
statusText(row.protocolStatus, row.voidType),
|
||||
String(index + 1),
|
||||
formatLedgerDate(row.smsSendDate),
|
||||
text(row.smsSendStatus),
|
||||
text(row.sampleCode),
|
||||
text(row.motherName),
|
||||
text(row.motherPhone),
|
||||
text(row.motherIdCard),
|
||||
text(row.fatherName),
|
||||
text(row.fatherPhone),
|
||||
text(row.fatherIdCard),
|
||||
formatLedgerDate(row.microbeFollowupDate),
|
||||
text(row.microbeFollowupStatus),
|
||||
formatLedgerDate(row.fullmoonFollowupDate),
|
||||
text(row.fullmoonFollowupStatus),
|
||||
text(row.householdRegistered),
|
||||
text(row.newbornName),
|
||||
text(row.newbornIdCard),
|
||||
label(SEX, row.newbornSex),
|
||||
formatLedgerDateTime(row.newbornDeliveryTime),
|
||||
formatLedgerDateTime(row.purchaseInsuranceTime),
|
||||
formatLedgerDateTime(row.policySendTime),
|
||||
formatLedgerDateTime(row.einvoiceSendTime),
|
||||
formatLedgerDateTime(row.mailCertTime),
|
||||
text(row.effectiveAddress),
|
||||
text(row.marketSpecialistName),
|
||||
text(row.payModeLabel) || payModeText(row.payMode),
|
||||
text(row.protocolRemark),
|
||||
flag01(row.protocolAttachmentPresent),
|
||||
flag01(row.insuranceDocPresent),
|
||||
flag01(row.informedConsentPresent),
|
||||
flag01(row.healthSurveyPresent)
|
||||
])
|
||||
}
|
||||
|
||||
export async function fetchAllPages<T>(
|
||||
load: (page: number, pageSize: number) => Promise<{ list?: T[]; total?: number }>
|
||||
): Promise<T[]> {
|
||||
const pageSize = 100
|
||||
const all: T[] = []
|
||||
let page = 1
|
||||
let total = Number.POSITIVE_INFINITY
|
||||
while (all.length < total && page <= 200) {
|
||||
const result = await load(page, pageSize)
|
||||
const list = result.list || []
|
||||
total = result.total ?? list.length
|
||||
all.push(...list)
|
||||
if (!list.length || list.length < pageSize) break
|
||||
page += 1
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
function fileStamp(): string {
|
||||
const stamp = new Date()
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}${pad(stamp.getHours())}${pad(stamp.getMinutes())}${pad(stamp.getSeconds())}`
|
||||
}
|
||||
|
||||
export async function saveCollectionLedgerWorkbook(
|
||||
sheetName: string,
|
||||
headers: string[],
|
||||
rows: string[][],
|
||||
filenamePrefix: string,
|
||||
merges: LedgerMergeRange[] = []
|
||||
) {
|
||||
const ExcelJS = (await import('exceljs')).default
|
||||
const FileSaver = (await import('file-saver')).default
|
||||
const workbook = new ExcelJS.Workbook()
|
||||
const sheet = workbook.addWorksheet(sheetName)
|
||||
const headerRow = sheet.addRow(headers)
|
||||
headerRow.eachCell((cell) => {
|
||||
cell.numFmt = '@'
|
||||
cell.alignment = { wrapText: true, vertical: 'middle' }
|
||||
})
|
||||
rows.forEach((line) => {
|
||||
const added = sheet.addRow(line)
|
||||
line.forEach((value, index) => {
|
||||
const cell = added.getCell(index + 1)
|
||||
cell.value = value
|
||||
cell.numFmt = '@'
|
||||
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }
|
||||
})
|
||||
})
|
||||
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 }
|
||||
})
|
||||
headers.forEach((header, index) => {
|
||||
sheet.getColumn(index + 1).width = Math.min(28, Math.max(12, header.replace(/\n/g, '').length + 2))
|
||||
})
|
||||
const buffer = await workbook.xlsx.writeBuffer()
|
||||
FileSaver.saveAs(
|
||||
new Blob([buffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
}),
|
||||
`${filenamePrefix}-${fileStamp()}.xlsx`
|
||||
)
|
||||
}
|
||||
@@ -110,7 +110,18 @@ describe('flattenProtocolAuditRows', () => {
|
||||
infectiousDiseaseReportUrls: '',
|
||||
attachmentUrls: 'https://x/b.pdf',
|
||||
remark: '备注一',
|
||||
sampleItems: [{ sampleTypeName: 'NK', sampleCode: 'D2', labStorageCode: 'A1' }]
|
||||
sampleItems: [
|
||||
{
|
||||
sampleTypeName: 'NK',
|
||||
sampleCode: 'D2',
|
||||
labStorageCode: 'A1',
|
||||
freight: {
|
||||
id: 'fr-1',
|
||||
transportModeLabel: '专线车',
|
||||
freightAmountLabel: '100'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
])
|
||||
assert.equal(rows[0].monthlyVerifyNo, '202508001')
|
||||
@@ -122,6 +133,9 @@ describe('flattenProtocolAuditRows', () => {
|
||||
assert.equal(rows[0].infectiousDiseaseReportUrls, '')
|
||||
assert.equal(rows[0].attachmentUrls, 'https://x/b.pdf')
|
||||
assert.equal(rows[0].remark, '备注一')
|
||||
assert.equal(rows[0].freightId, 'fr-1')
|
||||
assert.equal(rows[0].freightTransportLabel, '专线车')
|
||||
assert.equal(rows[0].freightAmountLabel, '100')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -37,6 +37,10 @@ export type ProtocolAuditFlatRow = {
|
||||
labStorageCode: string
|
||||
transportType: string
|
||||
transportLabel: string
|
||||
/** 样本运费批次;多样本共用时 id 相同 */
|
||||
freightId: string
|
||||
freightTransportLabel: string
|
||||
freightAmountLabel: string
|
||||
paymentSummary: QcProtocolPaymentSummary | null
|
||||
monthlyVerifyNo: string
|
||||
donationStatus: string
|
||||
@@ -90,7 +94,13 @@ export function flattenProtocolAuditRows(
|
||||
slots.forEach((item, sampleIndex) => {
|
||||
const rec = item || {}
|
||||
const transport = asRecord(rec.transport)
|
||||
const freight = asRecord(rec.freight)
|
||||
const transportType = str(transport?.transportType)
|
||||
const freightAmountLabel = str(freight?.freightAmountLabel)
|
||||
const freightAmount =
|
||||
freight?.freightAmount == null || freight?.freightAmount === ''
|
||||
? ''
|
||||
: String(freight.freightAmount)
|
||||
out.push({
|
||||
rowKey: `${protocolId}|${str(rec.sampleCode) || sampleIndex}`,
|
||||
protocolId,
|
||||
@@ -113,6 +123,9 @@ export function flattenProtocolAuditRows(
|
||||
labStorageCode: str(rec.labStorageCode),
|
||||
transportType,
|
||||
transportLabel: '',
|
||||
freightId: str(freight?.id),
|
||||
freightTransportLabel: str(freight?.transportModeLabel) || str(freight?.transportMode),
|
||||
freightAmountLabel: freightAmountLabel || freightAmount,
|
||||
paymentSummary:
|
||||
protocol.paymentSummary && typeof protocol.paymentSummary === 'object'
|
||||
? (protocol.paymentSummary as QcProtocolPaymentSummary)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
getConsentProtocolAttachment,
|
||||
getSourceProtocolAttachment,
|
||||
getSourceProtocolAttachmentGroups
|
||||
} from './protocol-source-attachment'
|
||||
|
||||
describe('protocol-source-attachment', () => {
|
||||
it('returns health survey as primary for cell storage', () => {
|
||||
const meta = getSourceProtocolAttachment({
|
||||
sourceType: 'cell_storage',
|
||||
healthSurveyAttachmentFiles: [{ id: '1', fileUrl: '/a.png' }]
|
||||
})
|
||||
assert.equal(meta.label, '孕妇健康调查表')
|
||||
assert.equal(meta.files.length, 1)
|
||||
})
|
||||
|
||||
it('returns infectious disease report as primary for research donation', () => {
|
||||
const meta = getSourceProtocolAttachment({
|
||||
sourceType: 'research_donation',
|
||||
infectiousDiseaseReportAttachmentFiles: [{ id: '2', fileUrl: '/b.png' }]
|
||||
})
|
||||
assert.equal(meta.label, '传染病检测报告')
|
||||
assert.equal(meta.files.length, 1)
|
||||
})
|
||||
|
||||
it('returns consent attachment from shared field', () => {
|
||||
const meta = getConsentProtocolAttachment({
|
||||
consentAttachmentFiles: [{ id: '3', fileUrl: '/c.png' }]
|
||||
})
|
||||
assert.equal(meta.label, '知情同意书')
|
||||
assert.equal(meta.files.length, 1)
|
||||
})
|
||||
|
||||
it('groups cell storage attachments for reception dialog', () => {
|
||||
const groups = getSourceProtocolAttachmentGroups({
|
||||
sourceType: 'cell_storage',
|
||||
protocolAttachmentFiles: [{ id: 'p' }],
|
||||
consentAttachmentFiles: [{ id: 'c' }],
|
||||
healthSurveyAttachmentFiles: [{ id: 'h' }]
|
||||
})
|
||||
assert.deepEqual(
|
||||
groups.map((g) => g.label),
|
||||
['知情同意书', '孕妇健康调查表', '协议附件']
|
||||
)
|
||||
assert.ok(groups.every((g) => g.files.length === 1))
|
||||
})
|
||||
|
||||
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', () => {
|
||||
const groups = getSourceProtocolAttachmentGroups({
|
||||
sourceType: 'research_donation',
|
||||
consentAttachmentFiles: [{ id: 'c' }],
|
||||
infectiousDiseaseReportAttachmentFiles: [{ id: 'i' }],
|
||||
attachmentFiles: [{ id: 'o' }]
|
||||
})
|
||||
assert.deepEqual(
|
||||
groups.map((g) => g.label),
|
||||
['知情同意书', '传染病检测报告', '其他附件']
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ function normalizeAttachmentFiles(raw: unknown): Array<Record<string, unknown>>
|
||||
return raw.filter((item) => item && typeof item === 'object') as Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** 按来源返回协议级附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
|
||||
/** 按来源返回协议级主附件标签与文件列表(细胞存储=孕妇健康调查表,科研捐赠=传染病检测报告) */
|
||||
export function getSourceProtocolAttachment(
|
||||
row: Record<string, unknown>
|
||||
): ProtocolSourceAttachmentMeta {
|
||||
@@ -24,3 +24,58 @@ export function getSourceProtocolAttachment(
|
||||
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
|
||||
}
|
||||
}
|
||||
|
||||
/** 知情同意书(细胞存储与科研捐赠共用字段) */
|
||||
export function getConsentProtocolAttachment(
|
||||
row: Record<string, unknown>
|
||||
): ProtocolSourceAttachmentMeta {
|
||||
return {
|
||||
label: '知情同意书',
|
||||
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 样本接收弹窗:按来源返回质检需查看的协议附件分组。
|
||||
* 细胞存储:知情同意书 / 孕妇健康调查表 / 协议附件
|
||||
* 科研捐赠:知情同意书 / 传染病检测报告 / 其他附件
|
||||
* 兼容 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 [
|
||||
{
|
||||
label: '知情同意书',
|
||||
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
|
||||
},
|
||||
{
|
||||
label: '传染病检测报告',
|
||||
files: normalizeAttachmentFiles(row.infectiousDiseaseReportAttachmentFiles)
|
||||
},
|
||||
{
|
||||
label: '其他附件',
|
||||
files: normalizeAttachmentFiles(row.attachmentFiles ?? row.otherAttachmentFiles)
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: '知情同意书',
|
||||
files: normalizeAttachmentFiles(row.consentAttachmentFiles)
|
||||
},
|
||||
{
|
||||
label: '孕妇健康调查表',
|
||||
files: normalizeAttachmentFiles(row.healthSurveyAttachmentFiles)
|
||||
},
|
||||
{
|
||||
label: '协议附件',
|
||||
files: protocolFiles
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -56,6 +56,15 @@
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增预签约
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="COLLECTION_LEDGER_EXPORT_ENABLED"
|
||||
class="btn-ghost"
|
||||
:loading="exporting"
|
||||
@click="handleExport"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +205,16 @@
|
||||
const projectFilter = ref('')
|
||||
const teamFilter = ref('')
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { Plus, Search, RefreshLeft, Edit, Delete, Check } from '@element-plus/icons-vue'
|
||||
import { Plus, Search, RefreshLeft, Edit, Delete, Check, Download } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
COLLECTION_LEDGER_EXPORT_ENABLED,
|
||||
buildPresignLedgerRows,
|
||||
fetchAllPages,
|
||||
PRESIGN_HEADERS,
|
||||
saveCollectionLedgerWorkbook,
|
||||
prepareCollectionLedgerExport
|
||||
} from '@/utils/collection-ledger-export'
|
||||
import { formatDisplayOrDash } from '@/utils/datetime-display'
|
||||
import { formatMoneyDisplay } from '@/utils/money-format'
|
||||
import { presignOrderStatusLabel, presignOrderStatusTagType } from '@/utils/status-tag-registry'
|
||||
@@ -213,6 +231,7 @@
|
||||
const tableData = ref<Record<string, any>[]>([])
|
||||
const selectedRows = ref<Record<string, any>[]>([])
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<string | undefined>(undefined)
|
||||
@@ -246,6 +265,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await prepareCollectionLedgerExport()
|
||||
const rows = selectedRows.value.length
|
||||
? selectedRows.value
|
||||
: await fetchAllPages((page, pageSize) =>
|
||||
fetchPresignOrderList({
|
||||
page,
|
||||
pageSize,
|
||||
projectId: projectFilter.value || undefined,
|
||||
teamId: teamFilter.value || undefined,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
orderStatus: searchForm.orderStatus || undefined
|
||||
})
|
||||
)
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
await saveCollectionLedgerWorkbook(
|
||||
'预签约',
|
||||
PRESIGN_HEADERS,
|
||||
buildPresignLedgerRows(rows),
|
||||
'预签约'
|
||||
)
|
||||
ElMessage.success('导出成功')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
|
||||
@@ -75,6 +75,15 @@
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="COLLECTION_LEDGER_EXPORT_ENABLED"
|
||||
class="btn-ghost"
|
||||
:loading="exporting"
|
||||
@click="handleExport"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
<el-button class="btn-ghost" :disabled="!selectedProtocolCount" @click="openAuditDialog()">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
审核
|
||||
@@ -422,6 +431,7 @@
|
||||
Delete,
|
||||
Search,
|
||||
RefreshLeft,
|
||||
Download,
|
||||
CircleCheck,
|
||||
CircleClose,
|
||||
Select,
|
||||
@@ -456,6 +466,14 @@
|
||||
formatProtocolDocBatchDeleteConfirm
|
||||
} from '@/utils/protocol-doc-label'
|
||||
import { loadVoidTypeDict } from '@/utils/void-type-dict'
|
||||
import {
|
||||
COLLECTION_LEDGER_EXPORT_ENABLED,
|
||||
RESEARCH_HEADERS,
|
||||
buildResearchLedgerRows,
|
||||
fetchAllPages,
|
||||
saveCollectionLedgerWorkbook,
|
||||
prepareCollectionLedgerExport
|
||||
} from '@/utils/collection-ledger-export'
|
||||
import {
|
||||
flattenProtocolAuditRows,
|
||||
uniqueProtocolsFromFlatRows,
|
||||
@@ -476,6 +494,7 @@
|
||||
const sampleCollectTimeRange = ref<[string, string] | null>(null)
|
||||
const tableData = ref<ResearchDonationRow[]>([])
|
||||
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
|
||||
const exporting = ref(false)
|
||||
const loading = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
@@ -529,6 +548,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await prepareCollectionLedgerExport()
|
||||
const rows = selectedFlatRows.value.length
|
||||
? selectedFlatRows.value
|
||||
: flattenProtocolAuditRows(
|
||||
await fetchAllPages((page, pageSize) =>
|
||||
fetchResearchDonationList({
|
||||
page,
|
||||
pageSize,
|
||||
projectId: projectFilter.value || undefined,
|
||||
teamId: teamFilter.value || undefined,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
donationStatus: searchForm.donationStatus || undefined,
|
||||
createTimeStart: createTimeRange.value?.[0] || undefined,
|
||||
createTimeEnd: createTimeRange.value?.[1] || undefined,
|
||||
sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined,
|
||||
sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined
|
||||
})
|
||||
)
|
||||
)
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
const ledger = buildResearchLedgerRows(rows)
|
||||
await saveCollectionLedgerWorkbook(
|
||||
'科研表',
|
||||
RESEARCH_HEADERS,
|
||||
ledger.rows,
|
||||
'科研表',
|
||||
ledger.merges
|
||||
)
|
||||
ElMessage.success('导出成功')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
|
||||
@@ -79,6 +79,15 @@
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="COLLECTION_LEDGER_EXPORT_ENABLED"
|
||||
class="btn-ghost"
|
||||
:loading="exporting"
|
||||
@click="handleExport"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
<el-button
|
||||
class="btn-ghost"
|
||||
:disabled="selectedProtocolCount !== 1"
|
||||
@@ -396,7 +405,8 @@
|
||||
Switch,
|
||||
Timer,
|
||||
Coin,
|
||||
More
|
||||
More,
|
||||
Download
|
||||
} from '@element-plus/icons-vue'
|
||||
import ProtocolFormDialog from '../protocol/ProtocolFormDialog.vue'
|
||||
import ProtocolViewDialog from '../protocol/ProtocolViewDialog.vue'
|
||||
@@ -424,6 +434,14 @@
|
||||
transportTypeLabel
|
||||
} from '@/utils/status-tag-registry'
|
||||
import { loadVoidTypeDict } from '@/utils/void-type-dict'
|
||||
import {
|
||||
COLLECTION_LEDGER_EXPORT_ENABLED,
|
||||
CELL_STORAGE_HEADERS,
|
||||
buildCellStorageLedgerRows,
|
||||
fetchAllPages,
|
||||
saveCollectionLedgerWorkbook,
|
||||
prepareCollectionLedgerExport
|
||||
} from '@/utils/collection-ledger-export'
|
||||
import {
|
||||
flattenProtocolAuditRows,
|
||||
uniqueProtocolsFromFlatRows,
|
||||
@@ -442,6 +460,7 @@
|
||||
const tableData = ref<CollectionProtocolRow[]>([])
|
||||
const selectedFlatRows = ref<ProtocolAuditFlatRow[]>([])
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
|
||||
const flatRows = computed(() => flattenProtocolAuditRows(tableData.value))
|
||||
@@ -494,6 +513,49 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await prepareCollectionLedgerExport()
|
||||
const rows = selectedFlatRows.value.length
|
||||
? selectedFlatRows.value
|
||||
: flattenProtocolAuditRows(
|
||||
await fetchAllPages((page, pageSize) =>
|
||||
fetchCollectionProtocolList({
|
||||
page,
|
||||
pageSize,
|
||||
projectId: projectFilter.value || undefined,
|
||||
teamId: teamFilter.value || undefined,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
protocolStatus: searchForm.protocolStatus || undefined,
|
||||
createTimeStart: createTimeRange.value?.[0] || undefined,
|
||||
createTimeEnd: createTimeRange.value?.[1] || undefined,
|
||||
sampleCollectTimeStart: sampleCollectTimeRange.value?.[0] || undefined,
|
||||
sampleCollectTimeEnd: sampleCollectTimeRange.value?.[1] || undefined
|
||||
})
|
||||
)
|
||||
)
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
const ledger = buildCellStorageLedgerRows(rows)
|
||||
await saveCollectionLedgerWorkbook(
|
||||
'自存表',
|
||||
CELL_STORAGE_HEADERS,
|
||||
ledger.rows,
|
||||
'自存表',
|
||||
ledger.merges
|
||||
)
|
||||
ElMessage.success('导出成功')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Document, Download, VideoPlay } from '@element-plus/icons-vue'
|
||||
import { attachmentFullUrl } from '@/composables/useAttachmentActions'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -58,14 +59,8 @@
|
||||
(e: 'download', file: Record<string, any>): void
|
||||
}>()
|
||||
|
||||
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '').replace(/\/$/, '')
|
||||
|
||||
function resolveFileUrl(suffix: string) {
|
||||
if (!suffix) return ''
|
||||
if (suffix.startsWith('http://') || suffix.startsWith('https://') || suffix.startsWith('/')) {
|
||||
return suffix
|
||||
}
|
||||
return `${minioBaseUrl}/${suffix}`
|
||||
return attachmentFullUrl(suffix)
|
||||
}
|
||||
|
||||
function isImageFile(file: { fileType?: string; fileName?: string; fileUrl?: string }) {
|
||||
|
||||
@@ -92,6 +92,15 @@
|
||||
<el-icon><Box /></el-icon>
|
||||
登记入库
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isCellStorage && COLLECTION_LEDGER_EXPORT_ENABLED"
|
||||
class="btn-ghost"
|
||||
:loading="exporting"
|
||||
@click="handleExport"
|
||||
>
|
||||
<el-icon><Download /></el-icon>
|
||||
导出 Excel
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isCellStorage"
|
||||
class="btn-ghost"
|
||||
@@ -473,6 +482,7 @@
|
||||
ChatDotRound,
|
||||
Collection,
|
||||
Document,
|
||||
Download,
|
||||
EditPen,
|
||||
Files,
|
||||
Location,
|
||||
@@ -511,6 +521,14 @@
|
||||
sampleStorageFlagLabel,
|
||||
sampleStorageFlagTagType
|
||||
} from '@/utils/status-tag-registry'
|
||||
import {
|
||||
COLLECTION_LEDGER_EXPORT_ENABLED,
|
||||
CUSTOMER_SERVICE_HEADERS,
|
||||
buildCustomerServiceLedgerRows,
|
||||
fetchAllPages,
|
||||
saveCollectionLedgerWorkbook,
|
||||
prepareCollectionLedgerExport
|
||||
} from '@/utils/collection-ledger-export'
|
||||
|
||||
defineOptions({ name: 'UnifiedSampleInfoPage' })
|
||||
|
||||
@@ -533,6 +551,7 @@
|
||||
})
|
||||
const tableData = ref<Record<string, unknown>[]>([])
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
const pagination = reactive({ page: 1, pageSize: 10, total: 0 })
|
||||
const selectedRows = ref<Record<string, unknown>[]>([])
|
||||
const tableRef = ref<{ clearSelection?: () => void } | null>(null)
|
||||
@@ -632,6 +651,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (exporting.value || !isCellStorage.value) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await prepareCollectionLedgerExport()
|
||||
const rows = selectedRows.value.length
|
||||
? selectedRows.value
|
||||
: await fetchAllPages((page, pageSize) =>
|
||||
fetchProtocolSampleCollectionList({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: searchForm.keyword || undefined,
|
||||
collectionFlag: searchForm.collectionFlag || undefined,
|
||||
receptionFlag: searchForm.receptionFlag || undefined,
|
||||
qcFlag: searchForm.qcFlag || undefined,
|
||||
storageFlag: searchForm.storageFlag || undefined,
|
||||
storageCertHandled: searchForm.storageCertHandled || undefined,
|
||||
insuranceHandled: searchForm.insuranceHandled || undefined
|
||||
})
|
||||
)
|
||||
if (!rows.length) {
|
||||
ElMessage.warning('没有可导出的数据')
|
||||
return
|
||||
}
|
||||
await saveCollectionLedgerWorkbook(
|
||||
'客服表',
|
||||
CUSTOMER_SERVICE_HEADERS,
|
||||
buildCustomerServiceLedgerRows(rows),
|
||||
'客服表'
|
||||
)
|
||||
ElMessage.success('导出成功')
|
||||
} catch {
|
||||
ElMessage.error('导出失败,请重试')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1
|
||||
loadData()
|
||||
|
||||
+18
-1
@@ -80,6 +80,16 @@
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="sample-reception-card__meta-item sample-reception-card__meta-item--attachment">
|
||||
<dt>{{ consentAttachmentMeta(row).label }}</dt>
|
||||
<dd>
|
||||
<ProtocolAttachmentMiniReadonly
|
||||
:files="consentAttachmentMeta(row).files"
|
||||
@preview="handleAttachmentPreview"
|
||||
@download="handleAttachmentDownload"
|
||||
/>
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="activeTab === 'received'" class="sample-reception-card__meta-item">
|
||||
<dt>实验室入库编码</dt>
|
||||
<dd>
|
||||
@@ -242,7 +252,10 @@
|
||||
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
|
||||
import QcWorkOrderProtocolPaymentPanel from '@/views/quality-inspection/qc-work-order/QcWorkOrderProtocolPaymentPanel.vue'
|
||||
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
|
||||
import { getSourceProtocolAttachment } from '@/utils/protocol-source-attachment'
|
||||
import {
|
||||
getConsentProtocolAttachment,
|
||||
getSourceProtocolAttachment
|
||||
} from '@/utils/protocol-source-attachment'
|
||||
import { useAttachmentActions } from '@/composables/useAttachmentActions'
|
||||
|
||||
defineOptions({ name: 'SampleReceptionCardList' })
|
||||
@@ -284,6 +297,10 @@
|
||||
return getSourceProtocolAttachment(row)
|
||||
}
|
||||
|
||||
function consentAttachmentMeta(row: Record<string, unknown>) {
|
||||
return getConsentProtocolAttachment(row)
|
||||
}
|
||||
|
||||
function protocolStatusLabelBySource(status: string, source: string, voidType?: string): string {
|
||||
return source === 'research_donation'
|
||||
? donationStatusDisplayLabel(status, voidType)
|
||||
|
||||
@@ -204,41 +204,63 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="form-section-transport" class="sample-transport-col">
|
||||
<div class="section-title section-title--compact">
|
||||
<div class="section-title-left"><h3>运输信息</h3></div>
|
||||
<div id="form-section-transport" class="sample-transport-col sample-transport-col--stack">
|
||||
<div class="sample-transport-stack-block">
|
||||
<div class="section-title section-title--compact">
|
||||
<div class="section-title-left"><h3>运输信息</h3></div>
|
||||
</div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div class="data-item">
|
||||
<div class="data-label">运输方式</div>
|
||||
<div class="data-value">
|
||||
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">预计到达</div>
|
||||
<div class="data-value">{{ formatDisplayOrDash(currentRow.etaTime) }}</div>
|
||||
</div>
|
||||
<template v-if="currentRow.transportType === 'kdys'">
|
||||
<div class="data-item">
|
||||
<div class="data-label">快递公司</div>
|
||||
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">快递单号</div>
|
||||
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="currentRow.transportType === 'ryxd'">
|
||||
<div class="data-item">
|
||||
<div class="data-label">携带人</div>
|
||||
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">携带人电话</div>
|
||||
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-grid data-grid--2">
|
||||
<div class="data-item">
|
||||
<div class="data-label">运输方式</div>
|
||||
<div class="data-value">
|
||||
{{ transportTypeLabel(String(currentRow.transportType || '')) }}
|
||||
<div class="sample-transport-stack-block sample-protocol-attachments">
|
||||
<div class="section-title section-title--compact">
|
||||
<div class="section-title-left"><h3>协议附件</h3></div>
|
||||
</div>
|
||||
<div class="sample-protocol-attachments__list">
|
||||
<div
|
||||
v-for="group in receiveProtocolAttachmentGroups"
|
||||
:key="group.label"
|
||||
class="sample-protocol-attachments__item"
|
||||
>
|
||||
<div class="data-label">{{ group.label }}</div>
|
||||
<ProtocolAttachmentMiniReadonly
|
||||
:files="group.files"
|
||||
empty-text="暂无附件"
|
||||
@preview="handleAttachmentPreview"
|
||||
@download="handleAttachmentDownload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">预计到达</div>
|
||||
<div class="data-value">{{ formatDisplayOrDash(currentRow.etaTime) }}</div>
|
||||
</div>
|
||||
<template v-if="currentRow.transportType === 'kdys'">
|
||||
<div class="data-item">
|
||||
<div class="data-label">快递公司</div>
|
||||
<div class="data-value">{{ currentRow.expressCompanyName || '-' }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">快递单号</div>
|
||||
<div class="data-value">{{ currentRow.waybillNo || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="currentRow.transportType === 'ryxd'">
|
||||
<div class="data-item">
|
||||
<div class="data-label">携带人</div>
|
||||
<div class="data-value">{{ currentRow.carrierName || '-' }}</div>
|
||||
</div>
|
||||
<div class="data-item">
|
||||
<div class="data-label">携带人电话</div>
|
||||
<div class="data-value">{{ currentRow.carrierPhone || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -722,6 +744,7 @@
|
||||
import SampleCodeWithSpecialDisplay from '@/components/business/sample-code-with-special-display/index.vue'
|
||||
import ProtocolFinanceConfirmFields from '@/components/business/protocol-finance-confirm-fields/index.vue'
|
||||
import ProtocolAttachmentReadonly from '@/views/cell-storage/protocol/ProtocolAttachmentReadonly.vue'
|
||||
import ProtocolAttachmentMiniReadonly from '@/components/business/protocol-attachment-mini-readonly/index.vue'
|
||||
import SampleReceptionCardList from './SampleReceptionCardList.vue'
|
||||
import SampleReceptionDualReadonlyPanel from '@/components/business/sample-reception-dual-readonly-panel/index.vue'
|
||||
import {
|
||||
@@ -745,6 +768,7 @@
|
||||
buildPrefilledLabStorageCode,
|
||||
parseLabStorageReferenceDate
|
||||
} from '@/utils/sample-reception-lab-storage-code'
|
||||
import { getSourceProtocolAttachmentGroups } from '@/utils/protocol-source-attachment'
|
||||
import type { ProductionReceptionRecord } from '@/utils/sample-production-reception-fields'
|
||||
import {
|
||||
formatProductionFieldValue,
|
||||
@@ -955,6 +979,12 @@
|
||||
.filter(Boolean) as Array<Record<string, unknown>>
|
||||
})
|
||||
|
||||
const receiveProtocolAttachmentGroups = computed(() => {
|
||||
const row = currentRow.value
|
||||
if (!row) return []
|
||||
return getSourceProtocolAttachmentGroups(row as Record<string, unknown>)
|
||||
})
|
||||
|
||||
function formatMetric(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '-'
|
||||
const n = Number(v)
|
||||
@@ -1485,6 +1515,8 @@
|
||||
let detail: Record<string, unknown> = row
|
||||
try {
|
||||
detail = (await fetchSampleReceptionDetail(receptionId)) as Record<string, unknown>
|
||||
// 详情可能带更完整的协议附件字段,合并后供弹窗右侧展示
|
||||
currentRow.value = { ...row, ...detail }
|
||||
} catch {
|
||||
/* 列表行已含主要字段,详情失败时回退 */
|
||||
}
|
||||
@@ -1653,6 +1685,44 @@
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
&--stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.sample-transport-stack-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sample-protocol-attachments {
|
||||
background: #ecfdf5;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #a7f3d0;
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.sample-protocol-attachments__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sample-protocol-attachments__item {
|
||||
min-width: 0;
|
||||
|
||||
.data-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--mint-text-muted, #64748b);
|
||||
}
|
||||
|
||||
:deep(.protocol-attachment-mini) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
|
||||
Reference in new issue
Block a user