package jnpf.limsService.impl; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import jnpf.audit.model.AuditEventDTO; import jnpf.audit.sdk.AuditClient; import jnpf.audit.AuditConsts; import jnpf.audit.sdk.annotation.AuditLog; import jnpf.base.UserInfo; import jnpf.limsEntity.FenjianZhuangtai; import jnpf.limsEntity.JianyanStatus; import jnpf.limsEntity.LimsFenjianItem; import jnpf.limsEntity.LimsJianceTaskAcceptParam; import jnpf.limsEntity.LimsJianyanAssignItem; import jnpf.limsEntity.LimsJianyanAssignParam; import jnpf.limsEntity.LimsJianyanAuditFailParam; import jnpf.limsEntity.LimsJianyanAuditItem; import jnpf.limsEntity.LimsJianyanAuditOutcome; import jnpf.limsEntity.LimsJianyanAuditParam; import jnpf.limsEntity.LimsJianyanResultItem; import jnpf.limsEntity.LimsJianyanResultParam; import jnpf.limsEntity.LimsQingyandanEntity; import jnpf.limsEntity.LimsSignUsageTarget; import jnpf.limsEntity.LimsSubmitResultOutcome; import jnpf.limsEntity.LimsJianyanXiangEntity; import jnpf.limsEntity.bizlog.BizLogAction; import jnpf.limsEntity.bizlog.BizLogType; import jnpf.limsMapper.LimsJianyanXiangMapper; import jnpf.limsMapper.LimsQingyandanMapper; import jnpf.limsService.LimsJianceTaskService; import jnpf.limsService.LimsQingyandanReportService; import jnpf.limsService.LimsYichangDiaochaService; import jnpf.permission.UserRelationApi; import jnpf.permission.entity.UserRelationEntity; import jnpf.constant.PermissionConst; import jnpf.exception.DataException; import jnpf.util.JsonUtil; import jnpf.util.RandomUtil; import jnpf.util.UserProvider; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.LinkedHashMap; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @Slf4j @Service public class LimsJianceTaskServiceImpl implements LimsJianceTaskService { @Autowired private LimsJianyanXiangMapper limsJianyanXiangMapper; @Autowired private LimsQingyandanMapper limsQingyandanMapper; @Autowired private UserRelationApi userRelationApi; @Autowired private LimsQingyandanReportService limsQingyandanReportService; @Autowired private LimsYichangDiaochaService limsYichangDiaochaService; @Autowired private LimsOperationalMessagePublisher operationalMessagePublisher; /** * 层 3 显式审计入口。**必须用 {@link ObjectProvider} 注入**:{@code audit.enabled=false} 时 * 整个自动配置类连同 AuditClient Bean 一起缺席,普通 @Autowired 会让本服务起不来。 */ @Autowired private ObjectProvider auditClientProvider; @Override @AuditLog( action = "ACCEPT", label = "样品分检", bizType = "JIANCE_TASK", bizCodeExpr = "#result != null && !#result.isEmpty() ? #param.jianyanLiushuihao : ''", targetTable = "lims_jianyan_xiang", targetIdExpr = "#param.data[0].id", targetIdsExpr = "#param.data.![id]", diff = true, entityClass = LimsJianyanXiangEntity.class ) public List fenjianSamples(LimsJianceTaskAcceptParam param) { if (param == null || param.getData() == null || param.getData().isEmpty()) { return Collections.emptyList(); } if (param.getBizSign() == null || param.getBizSign().isEmpty()) { return Collections.emptyList(); } // 分检人 = 当前登录用户,分检日期 = 当前时间(写入被推进的行) String fenjianRen = UserProvider.getUser().getUserId(); Date fenjianRiqi = new Date(); // 组合id:整次请求生成一个,仅写入 shifou_zuhe_fenjian=yes 的行(非组合行不会用到) String zuheId = RandomUtil.uuId(); // 组合分检量 / 单位:请求级共享值(前端在 param 顶层提交一次),前端传了(非 null)就写入本次分检的检验项行 String zuheFenjianLiang = param.getZuheFenjianLiang(); String zuheFenjianDanwei = param.getZuheFenjianDanwei(); // 预加载目标行,读各行样品级 shifou_zuhe_fenjian(入参不带该字段,需查 DB 判断是否组合行) List validIds = param.getData().stream() .filter(i -> i != null && i.getId() != null && !i.getId().isEmpty()) .map(LimsFenjianItem::getId) .collect(Collectors.toList()); Map rowMap = new HashMap<>(); if (!validIds.isEmpty()) { for (LimsJianyanXiangEntity r : loadRowsByIds(validIds)) { if (r != null && r.getId() != null) { rowMap.put(r.getId(), r); } } } // 必须整批校验完再开始 UPDATE。Controller 会把 DataException 转成 R.error;若边校验边更新, // 后一行非法时前面已更新的行会提交,但 SIGN_USED 尚未生成,形成无签名证据的半批写入。 validateFenjianItems(param.getData()); List affectedTargets = new ArrayList<>(); BigDecimal fenjianShuliang = BigDecimal.ZERO; if (zuheFenjianLiang != null && !zuheFenjianLiang.trim().isEmpty()) { fenjianShuliang = parseFenjianQuantity(zuheFenjianLiang, "组合分检量"); } for (LimsFenjianItem item : param.getData()) { if (item == null || item.getId() == null || item.getId().isEmpty()) { continue; } LimsJianyanXiangEntity row = rowMap.get(item.getId()); if (row == null) { continue; } if (zuheFenjianLiang == null || zuheFenjianLiang.trim().isEmpty()) { fenjianShuliang = fenjianShuliang.add( parseFenjianQuantity(item.getFenjianLiang(), "分检量")); } // 仅推进 submit 时创建的 created 行;其余状态 / 已软删 / 不存在 自动跳过(affected=0) UpdateWrapper wrapper = new UpdateWrapper<>(); wrapper.eq("f_id", item.getId()); wrapper.eq("jianyan_zhuangtai", JianyanStatus.CREATED.getCode()); wrapper.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); wrapper.set("jianyan_zhuangtai", JianyanStatus.PENDING_ASSIGN.getCode()); wrapper.set("fenjian_zhuangtai", FenjianZhuangtai.FENJIAN.getCode()); wrapper.set("fenjian_liang", item.getFenjianLiang()); wrapper.set("fenjian_danwei", item.getFenjianDanwei()); wrapper.set("fenjian_fenzhuang_rongqi", item.getFenjianFenzhuangRongqi()); wrapper.set("fenjian_baocun_tiaojian", item.getFenjianBaocunTiaojian()); wrapper.set("fenjian_ren", fenjianRen); wrapper.set("fenjian_riqi", fenjianRiqi); // 组合三列仅写该行 DB 上 shifou_zuhe_fenjian=yes 的组合行: // zuhe_id 用本次请求共享的 zuheId;组合分检量 / 单位取请求级共享值,前端传了(非 null)才写、不校验空 if ("yes".equalsIgnoreCase(row.getShifouZuheFenjian())) { wrapper.set("zuhe_id", zuheId); if (zuheFenjianLiang != null) { wrapper.set("zuhe_fenjian_liang", zuheFenjianLiang); } if (zuheFenjianDanwei != null) { wrapper.set("zuhe_fenjian_danwei", zuheFenjianDanwei); } } int rows = limsJianyanXiangMapper.update(null, wrapper); if (rows > 0) { affectedTargets.add(signUsageTarget(row)); } } // 更新请验单库存数量 updateQingyandanKucun(param.getJianyanLiushuihao(), fenjianShuliang); return affectedTargets; } @Override @AuditLog( action = "ASSIGN", label = "检验任务分配", bizType = "JIANCE_TASK", bizCodeExpr = "#result[0].bizCode", targetTable = "lims_jianyan_xiang", targetIdExpr = "#param.data[0].id", targetIdsExpr = "#param.data.![id]", diff = true, entityClass = LimsJianyanXiangEntity.class ) public List assignJianyanXiang(LimsJianyanAssignParam param) { if (param == null || param.getData() == null || param.getData().isEmpty()) { return Collections.emptyList(); } if (param.getBizSign() == null || param.getBizSign().isEmpty()) { return Collections.emptyList(); } UserInfo userInfo = UserProvider.getUser(); List affectedTargets = new ArrayList<>(); // 预加载所有目标行,按 shifou_waijian 决定每行的必填分支: // "yes" → waijian_danwei + waijian_lururen 必填(外检分配) // 其他 → jiancha_ren + wancheng_riqi 必填(普通分配) List validIds = param.getData().stream() .filter(i -> i != null && i.getId() != null && !i.getId().isEmpty()) .map(LimsJianyanAssignItem::getId) .collect(Collectors.toList()); Map rowMap = new HashMap<>(); if (!validIds.isEmpty()) { for (LimsJianyanXiangEntity r : loadRowsByIds(validIds)) { if (r != null && r.getId() != null) { rowMap.put(r.getId(), r); } } } validateAssignItems(param.getData(), rowMap); for (LimsJianyanAssignItem item : param.getData()) { if (item == null || item.getId() == null || item.getId().isEmpty()) { continue; } LimsJianyanXiangEntity existing = rowMap.get(item.getId()); // 没有预加载到的行不执行 UPDATE,保证成功行必然能映射为实际签名目标。 if (existing == null) { continue; } boolean isWaijian = "yes".equalsIgnoreCase(existing.getShifouWaijian()); UpdateWrapper wrapper = new UpdateWrapper<>(); Date assignedAt = new Date(); wrapper.eq("f_id", item.getId()); // 允许初次分配 + 改派:pending_assign / pending_test 均可,审核中 / 已审核禁止 wrapper.in("jianyan_zhuangtai", JianyanStatus.PENDING_ASSIGN.getCode(), JianyanStatus.PENDING_TEST.getCode()); wrapper.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); wrapper.set("renwu_fenpeiren", userInfo.getUserId()); wrapper.set("renwu_fenpei_shijian", assignedAt); wrapper.set("jianyan_zhuangtai", JianyanStatus.PENDING_TEST.getCode()); if (item.getHetonghao() != null && !item.getHetonghao().isEmpty()) { wrapper.set("hetonghao", item.getHetonghao()); } if (item.getBeizhu() != null && !item.getBeizhu().isEmpty()) { wrapper.set("beizhu", item.getBeizhu()); } if (isWaijian) { wrapper.set("waijian_danwei", item.getWaijianDanwei()); wrapper.set("waijian_lururen", item.getWaijianLururen()); // 同时清空检验员 / 完成日期,避免改派切换为外检后残留旧值 wrapper.set("jiancha_ren", null); wrapper.set("wancheng_riqi", null); } else { // wancheng_riqi 改为非必填(业务允许"先分配人,后续再补完成日期")。 // null / 非 null 都按入参原样写入,由 daily 提醒任务在 IS NOT NULL 时才命中。 wrapper.set("jiancha_ren", item.getJianchaRen()); wrapper.set("wancheng_riqi", item.getWanchengRiqi()); } // 任何分配 / 改派都清空 remind_flags:等同重新计时,下一轮 D-N 重新计算。 wrapper.set("remind_flags", null); int rows = limsJianyanXiangMapper.update(null, wrapper); if (rows > 0) { affectedTargets.add(signUsageTarget(existing)); operationalMessagePublisher.taskAssigned(existing, item, userInfo, assignedAt); } } return affectedTargets; } @Override @AuditLog( action = "RESULT_SUBMIT", label = "检验结果录入", bizType = "JIANCE_TASK", bizCodeExpr = "#result.affected > 0 ? #result.affectedTargets[0].bizCode : ''", targetTable = "lims_jianyan_xiang", targetIdExpr = "#param.data[0].id", targetIdsExpr = "#param.data.![id]", diff = true, entityClass = LimsJianyanXiangEntity.class ) public LimsSubmitResultOutcome submitJianyanResult(LimsJianyanResultParam param) { if (param == null || param.getData() == null || param.getData().isEmpty()) { return new LimsSubmitResultOutcome(); } if (param.getBizSign() == null || param.getBizSign().isEmpty()) { return new LimsSubmitResultOutcome(); } UserInfo userInfo = UserProvider.getUser(); String currentUserId = userInfo.getUserId(); Date now = new Date(); int totalAffected = 0; LimsSubmitResultOutcome outcome = new LimsSubmitResultOutcome(); List incomingIds = param.getData().stream() .filter(i -> i != null) .map(LimsJianyanResultItem::getId) .collect(Collectors.toList()); // 预加载行:外检行没有检验员(jiancha_ren=NULL),不能用 jiancha_ren=currentUserId 过滤; // 其他列(qingyandan_id)也会用来后续兜底触发 generateReportData List validIds = incomingIds.stream() .filter(id -> id != null && !id.isEmpty()) .collect(Collectors.toList()); Map rowMap = new HashMap<>(); if (!validIds.isEmpty()) { for (LimsJianyanXiangEntity r : loadRowsByIds(validIds)) { if (r != null && r.getId() != null) { rowMap.put(r.getId(), r); } } } Set affectedQingyandanIds = new HashSet<>(); List messageRows = new ArrayList<>(); for (LimsJianyanResultItem item : param.getData()) { if (item == null) { continue; } if (item.getId() == null || item.getId().isEmpty()) { continue; } if (item.getJianyanJieguo() == null || item.getJianyanJieguo().isEmpty()) { continue; } if (item.getJianyanJielun() == null || item.getJianyanJielun().isEmpty()) { continue; } LimsJianyanXiangEntity existing = rowMap.get(item.getId()); // 没有预加载到的行不执行 UPDATE,避免并发插入后写成功却缺少 bizCode/target 元数据。 if (existing == null) { continue; } boolean isWaijian = "yes".equalsIgnoreCase(existing.getShifouWaijian()); UpdateWrapper wrapper = new UpdateWrapper<>(); wrapper.eq("f_id", item.getId()); // 允许初次录入(pending_test)或重新录入(audit_rejected 审核退回后)或已撤回的重新录入 wrapper.in("jianyan_zhuangtai", JianyanStatus.PENDING_TEST.getCode(), JianyanStatus.CANCELLED.getCode(), JianyanStatus.AUDIT_REJECTED.getCode()); // 权限过滤:仅非外检项校验"分配给自己的任务"。外检行 jiancha_ren=NULL, // 不做该校验,由 waijian_lururen / 上下游业务约束兜底。 if (!isWaijian) { wrapper.eq("jiancha_ren", currentUserId); } wrapper.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); wrapper.set("jianyan_jieguo", item.getJianyanJieguo()); wrapper.set("jianyan_jielun", item.getJianyanJielun()); wrapper.set("biz_files", item.getBizFiles()); wrapper.set("jieguo_luru_riqi", now); wrapper.set("jianyan_zhuangtai", JianyanStatus.PENDING_AUDIT.getCode()); // 写回本行选择的异常调查类型(na/ad/oos/oot),供台账与后续联动使用 wrapper.set("yichang_diaocha_leixing", item.getYichangDiaochaLeixing()); int rows = limsJianyanXiangMapper.update(null, wrapper); if (rows > 0 && existing.getQingyandanId() != null) { affectedQingyandanIds.add(existing.getQingyandanId()); } if (rows > 0) { outcome.getAffectedTargets().add(signUsageTarget(existing)); messageRows.add(existing); } // 结果登记成功后,按本行异常调查类型发起 AD/OOS/OOT 调查工作流。 // best-effort:失败只记日志 + 收集流水号供响应提示,不上抛(否则会触发 Controller 层事务回滚,连带回滚结果登记)。 if (rows > 0) { try { limsYichangDiaochaService.createIfNeeded(existing, item); } catch (Exception e) { log.error("[submit-result] 发起异常调查流程失败 id={} type={}: {}", item.getId(), item.getYichangDiaochaLeixing(), e.getMessage(), e); if (existing.getJianyanLiushuihao() != null && !existing.getJianyanLiushuihao().isEmpty()) { outcome.getFlowLaunchFailed().add(existing.getJianyanLiushuihao()); } } } totalAffected += rows; } // 兜底触发报告生成。 // // 历史背景:早期外检项允许"录入完即就绪",不走复核 —— 当一个 liushuihao // 下全是外检项时,永远不会调到 passJianyanAudit,需要这里在录入后兜底 // 尝试生成报告。 // // 现状(2026-05-20 起):外检也必须 audited 才视为就绪,见 // LimsQingyandanReportServiceImpl.hasUnauditedXiang 的历史变更说明。 // 因此本兜底实际等同于 no-op —— 录入完只到 pending_audit, // generateReportData 会被守门拒绝。保留这段调用是"无害的防御网": // 若未来再次出现"无需复核"的快速通道(如紧急放行),不必再加触发点。 if (!affectedQingyandanIds.isEmpty()) { for (String qingyandanId : affectedQingyandanIds) { limsQingyandanReportService.generateReportData(qingyandanId); } } outcome.setAffected(totalAffected); if (!messageRows.isEmpty()) { operationalMessagePublisher.inspectionResultsPendingReview(messageRows, userInfo, now); } return outcome; } @Override @Transactional(rollbackFor = Exception.class) @AuditLog( action = "AUDIT_PASS", label = "检验结果审核通过", bizType = "JIANCE_TASK", bizCodeExpr = "#result.affected > 0 ? #result.affectedTargets[0].bizCode : ''", targetTable = "lims_jianyan_xiang", targetIdExpr = "#param.data[0].id", targetIdsExpr = "#param.data.![id]", diff = true, entityClass = LimsJianyanXiangEntity.class ) public LimsJianyanAuditOutcome passJianyanAudit(LimsJianyanAuditParam param) { LimsJianyanAuditOutcome outcome = new LimsJianyanAuditOutcome(); if (param == null || param.getData() == null || param.getData().isEmpty()) { return outcome; } outcome.setTotal(param.getData().size()); if (param.getBizSign() == null || param.getBizSign().isEmpty()) { return outcome; } List validIds = param.getData().stream() .filter(i -> i != null && i.getId() != null && !i.getId().isEmpty()) .map(LimsJianyanAuditItem::getId) .collect(Collectors.toList()); if (validIds.isEmpty()) { return outcome; } UserInfo auditor = UserProvider.getUser(); String auditorId = auditor.getUserId(); // 一次查回所有未软删的行,缺失即 notFound;行存在但状态不符即 wrongStatus List allRows = loadRowsByIds(validIds); Map rowMap = allRows.stream() .filter(r -> r != null && r.getId() != null) .collect(Collectors.toMap(LimsJianyanXiangEntity::getId, r -> r, (a, b) -> a)); List pendingAuditRows = new ArrayList<>(); for (String id : validIds) { LimsJianyanXiangEntity r = rowMap.get(id); if (r == null) { outcome.getNotFoundIds().add(id); } else if (!JianyanStatus.PENDING_AUDIT.getCode().equals(r.getJianyanZhuangtai())) { outcome.getWrongStatusIds().add(id); } else { pendingAuditRows.add(r); } } if (pendingAuditRows.isEmpty()) { return outcome; } Set auditorOrganizes = fetchAuditorOrganizes(auditorId); List passedIds = classifyAuditableRows(pendingAuditRows, auditorId, auditorOrganizes, outcome); if (passedIds.isEmpty()) { return outcome; } List affectedIds = new ArrayList<>(); for (String id : passedIds) { if (markAudited(id, auditorId) > 0) { affectedIds.add(id); outcome.getAffectedTargets().add(signUsageTarget(rowMap.get(id))); } } outcome.setAffected(affectedIds.size()); if (!affectedIds.isEmpty()) { tryGenerateReports(affectedIds); } return outcome; } /** * 审核通过后,对本批 audited 涉及的请验单逐个尝试生成报告。 * 与 audit-pass 在同一事务内:报告生成失败会回滚整个动作。 */ private void tryGenerateReports(List auditedIds) { // 不限定 select 列,避免 JNPF 拦截器对部分列结果置 null(见 loadRowsByStatus 注释)。 QueryWrapper sq = new QueryWrapper<>(); sq.in("f_id", auditedIds); List rows = limsJianyanXiangMapper.selectList(sq); Set qingyandanIds = rows.stream() .filter(r -> r != null) .map(LimsJianyanXiangEntity::getQingyandanId) .filter(s -> s != null && !s.isEmpty()) .collect(Collectors.toSet()); for (String qingyandanId : qingyandanIds) { limsQingyandanReportService.generateReportData(qingyandanId); } } /** 查选中 id 列表中处于指定状态且未软删的行。 * 注意:不要用 sq.select(...) 限定列,否则 JNPF 多租户/数据权限拦截器 * 在结果映射后置处理阶段会把缺少 f_tenant_id 等系统列的 entity 整个置为 null。 */ private List loadRowsByStatus(List ids, JianyanStatus status) { QueryWrapper sq = new QueryWrapper<>(); sq.in("f_id", ids); sq.eq("jianyan_zhuangtai", status.getCode()); sq.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); return limsJianyanXiangMapper.selectList(sq); } /** * 查审核人所属的部门 id 集合(base_user_relation.f_object_type='organize')。 * 超级管理员(UserInfo.isAdministrator)返回 null 作为"全班组通行证", * 由 {@link #classifyAuditableRows} 识别后跳过班组校验。 */ private Set fetchAuditorOrganizes(String auditorId) { UserInfo userInfo = UserProvider.getUser(); if (Boolean.TRUE.equals(userInfo.getIsAdministrator())) { return null; } List relations = userRelationApi.getList(auditorId, PermissionConst.ORGANIZE); if (relations == null || relations.isEmpty()) { return new HashSet<>(); } return relations.stream() .map(UserRelationEntity::getObjectId) .filter(s -> s != null && !s.isEmpty()) .collect(Collectors.toSet()); } /** 逐行写入审核人,使返回值能精确对应真正命中的 ID。 */ private int markAudited(String id, String auditorId) { UpdateWrapper uw = new UpdateWrapper<>(); uw.eq("f_id", id); uw.eq("jianyan_zhuangtai", JianyanStatus.PENDING_AUDIT.getCode()); uw.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); uw.set("renwu_shenheren", auditorId); uw.set("jianyan_zhuangtai", JianyanStatus.AUDITED.getCode()); uw.set("renwu_shenheren_shijian", new Date()); return limsJianyanXiangMapper.update(null, uw); } @Override public LimsJianyanAuditOutcome failJianyanAudit(LimsJianyanAuditFailParam param) { LimsJianyanAuditOutcome outcome = new LimsJianyanAuditOutcome(); if (param == null || param.getData() == null || param.getData().isEmpty()) { return outcome; } outcome.setTotal(param.getData().size()); if (param.getBizSign() == null || param.getBizSign().isEmpty()) { return outcome; } if (param.getNote() == null || param.getNote().isEmpty()) { return outcome; } List validIds = param.getData().stream() .filter(i -> i != null && i.getId() != null && !i.getId().isEmpty()) .map(LimsJianyanAuditItem::getId) .collect(Collectors.toList()); if (validIds.isEmpty()) { return outcome; } UserInfo auditor = UserProvider.getUser(); String auditorId = auditor.getUserId(); // 一次查回所有未软删的行,缺失即 notFound;行存在但状态不符即 wrongStatus List allRows = loadRowsByIds(validIds); Map rowMap = allRows.stream() .filter(r -> r != null && r.getId() != null) .collect(Collectors.toMap(LimsJianyanXiangEntity::getId, r -> r, (a, b) -> a)); List pendingAuditRows = new ArrayList<>(); for (String id : validIds) { LimsJianyanXiangEntity r = rowMap.get(id); if (r == null) { outcome.getNotFoundIds().add(id); } else if (!JianyanStatus.PENDING_AUDIT.getCode().equals(r.getJianyanZhuangtai())) { outcome.getWrongStatusIds().add(id); } else { pendingAuditRows.add(r); } } if (pendingAuditRows.isEmpty()) { return outcome; } Set auditorOrganizes = fetchAuditorOrganizes(auditorId); List passedIds = classifyAuditableRows(pendingAuditRows, auditorId, auditorOrganizes, outcome); if (passedIds.isEmpty()) { return outcome; } List affectedIds = new ArrayList<>(); for (String id : passedIds) { if (markAuditRejected(id, param.getNote(), auditorId) > 0) { affectedIds.add(id); outcome.getAffectedTargets().add(signUsageTarget(rowMap.get(id))); } } outcome.setAffected(affectedIds.size()); String auditExtraJson = JsonUtil.getObjectToString( Collections.singletonMap("reason", param.getNote())); for (String id : affectedIds) { LimsJianyanXiangEntity row = rowMap.get(id); if (row == null) continue; recordAuditFailEvent(row.getJianyanLiushuihao(), id, auditExtraJson); } if (!affectedIds.isEmpty()) { List rejectedRows = affectedIds.stream() .map(rowMap::get).filter(java.util.Objects::nonNull).collect(Collectors.toList()); operationalMessagePublisher.inspectionResultsRejected( rejectedRows, param.getNote(), auditor, new Date()); } return outcome; } /** * 层 3 显式写一条「检验结果项目退回」审计事件。 * *

为什么走层 3 而不是方法级 {@code @AuditLog}:本动作在 {@code for} 循环里**逐项**写 N 条 * (每个被退回的检验项一条、bizCode 各不相同),方法级注解只会产出 1 条。 * *

{@code operationId} **本方法不传**,交 {@code AuditTxHolder} 单源补齐——spec 契约规定 * 它由 holder 生成、调用方与切面都不得自行生成。注意「不传」不等于「落库为 NULL」: * {@code submit} 的三条路径(事件自带 → 外层 MDC → 新生成)都不留空,事务分支还会把 * 缓冲内所有事件统一覆盖成同一个 {@code buffer.operationId}。controller 上有 @Transactional, * 故本方法在 for 循环里产出的 N 条事件**同属一个操作作用域**,查询侧能折叠成一次操作。 * *

(勘误:dddce53 原注释写「刻意留空、代价是折叠不到一起」,那是未执行就下的结论; * 2026-07-27 端到端实测 3 条事件共享同一个非空 operation_id,见 tasks/audit-layer3-e2e.sh A6。) */ private void recordAuditFailEvent(String jianyanLiushuihao, String rowId, String extraJson) { auditClientProvider.ifAvailable(client -> client.record(AuditEventDTO.builder() .eventType(AuditConsts.TYPE_BIZ_ACTION) .actionCode(BizLogAction.AUDIT_FAIL.getCode()) .actionLabel("检验结果项目退回") .bizType(BizLogType.JIANCE_TASK.getCode()) .bizCode(jianyanLiushuihao) .targetTable("lims_jianyan_xiang") .targetId(rowId) .sourceLayer(3) .extra(extraJson) .build())); } /** 跟 loadRowsByStatus 同一查询结构但不限定状态,用于做 notFound / wrongStatus 区分。 */ private List loadRowsByIds(List ids) { QueryWrapper sq = new QueryWrapper<>(); sq.in("f_id", ids); sq.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); return limsJianyanXiangMapper.selectList(sq); } /** * 过滤可审核 / 退回的行:审核人不是录入人、且审核人在该行班组内。 * auditorOrganizes 为 null 表示超管通行,跳过班组校验(仍保留不能审自己的约束)。 * 把"自审自""非本班组"两种失败原因分别记入 outcome,便于 controller 给出针对性错误。 */ private List classifyAuditableRows(List rows, String auditorId, Set auditorOrganizes, LimsJianyanAuditOutcome outcome) { List passedIds = new ArrayList<>(); for (LimsJianyanXiangEntity r : rows) { if (r == null || r.getId() == null || r.getId().isEmpty()) { continue; } if (auditorId.equals(r.getJianchaRen())) { outcome.getSelfRejectIds().add(r.getId()); continue; } if (auditorOrganizes != null) { if (r.getJianchaBanzu() == null || !auditorOrganizes.contains(r.getJianchaBanzu())) { outcome.getNotInBanzuIds().add(r.getId()); continue; } } passedIds.add(r.getId()); } return passedIds; } /** 整批分检入参的纯校验阶段;返回后业务循环才允许执行第一条 UPDATE。 */ static void validateFenjianItems(List items) { if (items == null) { return; } int idx = 0; for (LimsFenjianItem item : items) { idx++; if (item == null || item.getId() == null || item.getId().isEmpty()) { continue; } String label = "第 " + idx + " 条"; if (item.getFenjianLiang() == null || item.getFenjianLiang().isEmpty()) { throw new DataException(label + " 缺少分检量"); } if (item.getFenjianDanwei() == null || item.getFenjianDanwei().isEmpty()) { throw new DataException(label + " 缺少分检单位"); } if (item.getFenjianFenzhuangRongqi() == null || item.getFenjianFenzhuangRongqi().isEmpty()) { throw new DataException(label + " 缺少分装容器"); } if (item.getFenjianBaocunTiaojian() == null || item.getFenjianBaocunTiaojian().isEmpty()) { throw new DataException(label + " 缺少分检后待检样品保存条件"); } } } private void updateQingyandanKucun(String jianyanLiushuihao, BigDecimal fenjianShuliang) { if (jianyanLiushuihao == null || jianyanLiushuihao.trim().isEmpty()) { throw new DataException("缺少检验流水号"); } QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("jianyan_liushuihao", jianyanLiushuihao); LimsQingyandanEntity qingyandan = limsQingyandanMapper.selectOne(queryWrapper); if (qingyandan == null) { throw new DataException("未找到检验流水号对应的请验单"); } BigDecimal kucunShuliang = parseFenjianQuantity(qingyandan.getKucunShuliang(), "库存数量"); BigDecimal shengyuShuliang = kucunShuliang.subtract(fenjianShuliang).max(BigDecimal.ZERO); UpdateWrapper updateWrapper = new UpdateWrapper<>(); updateWrapper.eq("f_id", qingyandan.getId()); updateWrapper.set("kucun_shuliang", shengyuShuliang.stripTrailingZeros().toPlainString()); limsQingyandanMapper.update(null, updateWrapper); } private BigDecimal parseFenjianQuantity(String value, String fieldName) { if (value == null || value.trim().isEmpty()) { throw new DataException(fieldName + "不能为空"); } try { return new BigDecimal(value.trim()); } catch (NumberFormatException e) { throw new DataException(fieldName + "格式不正确"); } } /** 整批分配入参的纯校验阶段;缺失业务行仍按原有部分成功语义跳过。 */ static void validateAssignItems(List items, Map rowMap) { if (items == null) { return; } int idx = 0; for (LimsJianyanAssignItem item : items) { idx++; if (item == null || item.getId() == null || item.getId().isEmpty()) { continue; } LimsJianyanXiangEntity existing = rowMap == null ? null : rowMap.get(item.getId()); if (existing == null) { continue; } String label = existing.getJianyanXiangmuMingcheng() != null ? existing.getJianyanXiangmuMingcheng() : "第 " + idx + " 条"; if ("yes".equalsIgnoreCase(existing.getShifouWaijian())) { if (item.getWaijianDanwei() == null || item.getWaijianDanwei().isEmpty()) { throw new DataException("外检项「" + label + "」必须填写外检检测单位"); } if (item.getWaijianLururen() == null || item.getWaijianLururen().isEmpty()) { throw new DataException("外检项「" + label + "」必须填写外检结果录入人"); } } else if (item.getJianchaRen() == null || item.getJianchaRen().isEmpty()) { throw new DataException(label + " 未选择检验员"); } } } /** 逐行退回,使部分成功时可精确记录实际 ID。 */ private int markAuditRejected(String id, String note, String auditorId) { UpdateWrapper uw = new UpdateWrapper<>(); uw.eq("f_id", id); uw.eq("jianyan_zhuangtai", JianyanStatus.PENDING_AUDIT.getCode()); uw.and(w -> w.isNull("f_delete_mark").or().ne("f_delete_mark", 1)); uw.set("renwu_tuihuiren", auditorId); uw.set("tuihui_yuanyin", note); uw.set("jianyan_zhuangtai", JianyanStatus.AUDIT_REJECTED.getCode()); return limsJianyanXiangMapper.update(null, uw); } private LimsSignUsageTarget signUsageTarget(LimsJianyanXiangEntity row) { return LimsSignUsageTarget.builder() .targetTable("lims_jianyan_xiang") .targetId(row == null ? null : row.getId()) .bizType(BizLogType.JIANCE_TASK.getCode()) .bizModule("LIMS-检测任务") .bizCode(row == null ? null : row.getJianyanLiushuihao()) .build(); } }