Compare commits

...
Author SHA1 Message Date
zlwuandCursor a64dd563bd fix(sql): 储存证 ACL 配置按钮授权给全部角色
continuous-integration/drone/tag Build is passing
迁移脚本为各租户有效角色补齐 storage-cert-qc-acl 菜单,使质检/生产权限入口对全部角色可见。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 17:03:49 +08:00
zlwuandCursor 377e0981ab fix(web-admin): 储存证 ACL 配置人员支持姓名模糊检索
远程下拉改为用检索结果替换选项并保留已选,修复只追加导致输入姓名后列表不收缩的问题。质检/生产权限共用同一弹窗。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 17:01:28 +08:00
zlwuandCursor 4654678231 fix(cell-storage): 储存证 ACL 保存避免 collation 混用 503
ACL 表与 sys_user JOIN/条件比较统一 utf8mb4_unicode_ci,并补建表 COLLATE 与 CONVERT 修复脚本(#97)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:56:58 +08:00
zlwu a6d3f6f610 merge: #96 储存证 ACL 字段勾选全选/取消全选 2026-11-11 16:42:25 +08:00
zlwu 6cd5ced688 feat(web-admin): 储存证 ACL 字段勾选支持全选/取消全选
质检/生产权限弹窗「勾选可编辑字段」Tab 增加一键全选与取消全选。关联 #96。
2026-11-11 16:42:10 +08:00
zlwuandCursor 843f44929c fix(web-admin): 储存证 ACL 字段勾选改为两列布局
质检/生产权限弹窗「勾选可编辑字段」Tab 使用两列网格,窄屏回退单列。关联 #95。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:37:30 +08:00
zlwu 87a22880e8 merge: #93 储存证质检字段级质检/生产权限 2026-11-11 16:15:07 +08:00
zlwuandCursor 529f36eac6 feat(cell-storage): 储存证质检字段级质检/生产权限
按方案 A 增加租户级 ACL(字段+人员),详情返回可编辑字段,保存改为服务端 merge;弹窗 footer 提供权限配置入口。关联 #93。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 16:13:20 +08:00
zlwu 9dc6812393 merge: #94 客户服务管理列表性别显示修复 2026-11-11 15:49:36 +08:00
zlwuandCursor e482980c7d fix(web-admin): 客户服务管理列表性别按新生儿约定显示
本地 sexLabel 误将女映射为 2,导致 0 原样展示;改为复用 newbornSexLabel(1男/0女)。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:48:37 +08:00
zlwu 900e957f25 merge: #91 脐带间充质储存证隐藏母血三项核酸 2026-11-11 15:15:43 +08:00
zlwuandCursor 15a20db725 fix(cell-storage): 脐带间充质储存证隐藏母血三项核酸
脐带间充质干细胞填写/查看储存证质检时不展示、不预填、不落库
HBV DNA / HCV RNA / HIV-1RNA;围产期造血干细胞保持原字段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 15:15:01 +08:00
zlwuandCursor 3ac30a6c2c fix(web-admin): 恢复采集大表导出并按运费批次归拢合并
去掉临时隐藏开关,导出自存/科研表时先按运费批次聚拢再合并单元格。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 14:56:45 +08:00
zlwuandCursor 95d6b6025f fix(web-admin): 样本接收列表卡片协议附件与弹窗对齐
卡片改用完整附件分组,展示知情同意书/来源主附件及协议附件或其他附件。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 14:54:16 +08:00
cykj d0eb97b167 fix(backend): 协议列表从 V2 样本缴费补齐已收金额 (#88)
fix(backend): 协议列表从 V2 样本缴费补齐已收金额 (#88)

Merged from PR #89.
2026-11-11 06:17:00 +00:00
zlwuandCursor 476f744ee9 fix(backend): 协议列表从 V2 样本缴费补齐已收金额
自存表导出依赖 paymentSummary.receivedAmount,原先仅汇总 V1
biz_protocol_payment;现对 include_sample_payment_modes 路径补齐
合同额、已收、分期明细与退费,避免已缴/退款列为空。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 14:15:02 +08:00
zlwuandCursor cec96ce480 fix(web-admin): 批量调整归属按钮文案更明确
将历史归属维护列表工具栏按钮由「调整归属」改为「批量调整归属」,与行内单条操作区分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 12:07:04 +08:00
zlwuandCursor 77473a1738 fix(web-admin): 恢复预签约导出并校准刷卡时间与表头样式
预签约列表重新显示导出按钮;刷卡时间仅统计已确认到账;Excel 标题行黄底居中并加边框。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 12:02:30 +08:00
zlwuandCursor db74cd64fe docs(agents): 完善 Cursor 确认问题与提示音可见性约定
明确工具先于正文时 notify-question 须为本轮最后一次调用,最终回复以「待你确认」打头,并区分 Queued 与确认入口,减少听音找不到题的情况。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 10:38:20 +08:00
cykj e1f0030403 fix(backend): 计划开始时间筛选不再内置工单状态限制 (#85)
fix(backend): 计划开始时间筛选不再内置工单状态限制

Refs #82
2026-11-11 02:23:12 +00:00
zlwuandCursor 5109d8b3e5 fix(backend): 计划开始时间筛选不再内置工单状态限制
去掉 EXISTS 中对工单/任务/步骤活跃态的硬编码,日期条件与前端勾选的状态等检索项组合生效,修复区间内有步骤日期却查空的问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 10:20:59 +08:00
zlwuandCursor 2a0a2ef72a feat(web-admin): 历史归属维护支持批量调整归属
在历史归属维护列表增加多选与工具栏批量「调整归属」,复用现有单条预览/保存接口逐条提交,便于一次将多条业务记录归属到同一项目。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-11 10:02:24 +08:00
zlwu 3a396d79a2 chore(mobile-h5): 切换本地联调环境与测试 AppID
continuous-integration/drone/tag Build is passing
按用户本地验收要求,将 APP_ENV 切换为 local,使用本机 API 和移动 H5 地址,并将微信 AppID 切换为测试号。
2026-11-10 09:37:04 +08:00
zlwu 9c4944702b fix(db): 补充协议操作日志旧表纠偏脚本
为同名旧表与现行日志模型不兼容的情况提供迁移脚本,并在原建表脚本注明纠偏入口。保留有数据的旧表为备份;本次仅提交现有脚本,未执行数据库迁移。
2026-11-10 09:37:04 +08:00
cykj e06f89dcf6 Merge pull request 'fix(mobile): 修复协议单保存前项目校验异常' (#79) from codex/issue-78-protocol-save into V2.0 2026-11-10 01:24:10 +00:00
zlwu f343f12645 fix(mobile): 修复协议单项目校验引用导致保存中断
循环模板内字符串 ref 被 Vue 收集为数组,调用 validate 时在请求前抛错。改用函数 ref 保持单组件实例,并覆盖两角色新增、编辑及项目校验提示。关联工单 #78。
2026-11-10 09:21:29 +08:00
zlwuandCursor 1032631318 chore(mobile-h5): 微信小程序 AppID 切换为生产
将 mp-weixin.appid 改为 wx2b139581f4c855e4,与 APP_ENV=prod 对齐。

Refs: #77
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:32:24 +08:00
zlwuandCursor f9f79479e8 chore(mobile-h5): 小程序切换为生产环境配置
将 APP_ENV 设为 prod,API/OA H5/MinIO 指向 xwyd.zkxw.cn,便于发生产版。

Refs: #76
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 16:27:28 +08:00
zlwuandCursor e488b86c35 fix(mobile): 小程序 WebView 附件下载改走宿主原生预览页
continuous-integration/drone/tag Build is passing
鸿蒙微信 ArkWeb 下 H5 window.open 常静默失败,仅出现「开始下载…」。
OA/市场附件在小程序 WebView 内改为跳转 pages/oa/file-preview,
用 uni.downloadFile + openDocument 打开,浏览器环境仍用预签名直链。

Refs: #74
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 15:04:14 +08:00
zlwuandCursor 6a78c28121 chore(mobile-h5): 切换小程序到测试环境配置
将微信 AppID 与 APP_ENV 改为测试环境,便于对接测试后端与业务域名。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-09 14:38:13 +08:00
cykj c7905b5da0 Merge pull request 'fix(web-admin): 样本接收弹窗恢复展示协议附件' (#73) into V2.0
fix(web-admin): 样本接收弹窗恢复展示协议附件 (#73)

Refs #43
2026-11-09 06:00:19 +00:00
53 changed files with 3156 additions and 156 deletions

No files matched your search

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