package jnpf.elnService.impl; import cn.hutool.core.util.IdUtil; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import jnpf.audit.model.AuditEventDTO; import jnpf.audit.sdk.AuditClient; import jnpf.base.UserInfo; import jnpf.database.util.TenantDataSourceUtil; import jnpf.elnEntity.field.ElnFieldForms; import jnpf.elnEntity.field.ElnFieldVersionEntity; import jnpf.elnEntity.runtime.ElnRecordEntity; import jnpf.elnEntity.runtime.ElnRecordFieldValueEntity; import jnpf.elnEntity.runtime.ElnRecordForms; import jnpf.elnEntity.runtime.ElnRecordRevisionEntity; import jnpf.elnEntity.runtime.ElnRecordVos; import jnpf.elnEntity.template.ElnTemplateBindingEntity; import jnpf.elnMapper.ElnFieldVersionMapper; import jnpf.elnMapper.ElnRecordFieldValueMapper; import jnpf.elnMapper.ElnRecordMapper; import jnpf.elnMapper.ElnRecordRevisionMapper; import jnpf.elnMapper.ElnTemplateBindingMapper; import jnpf.elnService.ElnTestWorkItemSnapshotProvider; import jnpf.elnService.ElnCrossTemplateSnapshotResolver; import jnpf.elnService.ElnRecordFormulaCalculationService; import jnpf.elnService.ElnRecordSourceSyncService; import jnpf.elnService.ElnRecordSourceSyncOperationCoordinator; import jnpf.elnService.ElnRecordSourceSyncDocumentConfirmation; import jnpf.elnService.ElnRecordFillTicketService; import jnpf.exception.DataException; import jnpf.lims.api.model.TestWorkItemSnapshotBatch; import jnpf.lims.api.model.TestWorkItemSnapshot; import jnpf.lims.api.model.TestWorkItemSnapshotProperty; import jnpf.lims.api.model.ElnSourcePropertyRef; import jnpf.lims.api.model.ElnSourceSnapshot; import jnpf.model.tenant.TenantVO; import jnpf.util.JsonUtil; import jnpf.util.UserProvider; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.util.StringUtils; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.math.BigDecimal; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.function.Consumer; @Service public class ElnRecordSourceSyncServiceImpl implements ElnRecordSourceSyncService, ElnRecordSourceSyncDocumentConfirmation { private static final String DRAFT = "DRAFT"; private static final String LIMS_TASK = "LIMS_TASK"; private static final String CROSS_TEMPLATE = "CROSS_TEMPLATE"; private static final String PENDING_TEST = "pending_test"; private final ElnRecordMapper recordMapper; private final ElnRecordRevisionMapper revisionMapper; private final ElnRecordFieldValueMapper valueMapper; private final ElnTemplateBindingMapper bindingMapper; private final ElnFieldVersionMapper fieldVersionMapper; private final ElnTestWorkItemSnapshotProvider taskProvider; private final ElnCrossTemplateSnapshotResolver crossTemplateSnapshotResolver; private final ElnRecordFormulaCalculationService calculationService; private final Consumer auditRecorder; private final CurrentUserProvider currentUserProvider; private final TenantRoute tenantRoute; private final TransactionTemplate ticketReadTransaction; private final TransactionTemplate ticketWriteTransaction; private final ElnRecordSourceSyncOperationCoordinator operationCoordinator; @Autowired public ElnRecordSourceSyncServiceImpl( ElnRecordMapper recordMapper, ElnRecordRevisionMapper revisionMapper, ElnRecordFieldValueMapper valueMapper, ElnTemplateBindingMapper bindingMapper, ElnFieldVersionMapper fieldVersionMapper, ElnTestWorkItemSnapshotProvider taskProvider, ElnCrossTemplateSnapshotResolver crossTemplateSnapshotResolver, ElnRecordFormulaCalculationService calculationService, ElnRecordSourceSyncOperationCoordinator operationCoordinator, PlatformTransactionManager transactionManager, ObjectProvider auditClientProvider) { this(recordMapper, revisionMapper, valueMapper, bindingMapper, fieldVersionMapper, taskProvider, calculationService, event -> { AuditClient client = auditClientProvider.getIfAvailable(); if (client != null) client.record(event); }, ElnRecordSourceSyncServiceImpl::currentUser, new TenantRoute() { @Override public void switchTo(String tenantId) { TenantDataSourceUtil.switchTenant(tenantId); } @Override public void clear() { TenantDataSourceUtil.clearLocalTenantInfo(); } }, readTransaction(transactionManager), new TransactionTemplate(transactionManager), crossTemplateSnapshotResolver, operationCoordinator); } ElnRecordSourceSyncServiceImpl( ElnRecordMapper recordMapper, ElnRecordRevisionMapper revisionMapper, ElnRecordFieldValueMapper valueMapper, ElnFieldVersionMapper fieldVersionMapper, ElnTestWorkItemSnapshotProvider taskProvider, ElnRecordFormulaCalculationService calculationService, Consumer auditRecorder, CurrentUserProvider currentUserProvider) { this(recordMapper, revisionMapper, valueMapper, null, fieldVersionMapper, taskProvider, calculationService, auditRecorder, currentUserProvider, new TenantRoute() { @Override public void switchTo(String tenantId) { } @Override public void clear() { } }, null, null, ElnCrossTemplateSnapshotResolver.none(), ElnRecordSourceSyncOperationCoordinator.none()); } ElnRecordSourceSyncServiceImpl( ElnRecordMapper recordMapper, ElnRecordRevisionMapper revisionMapper, ElnRecordFieldValueMapper valueMapper, ElnFieldVersionMapper fieldVersionMapper, ElnTestWorkItemSnapshotProvider taskProvider, ElnRecordFormulaCalculationService calculationService, Consumer auditRecorder, CurrentUserProvider currentUserProvider, ElnCrossTemplateSnapshotResolver crossTemplateSnapshotResolver) { this(recordMapper, revisionMapper, valueMapper, null, fieldVersionMapper, taskProvider, calculationService, auditRecorder, currentUserProvider, crossTemplateSnapshotResolver, ElnRecordSourceSyncOperationCoordinator.none()); } ElnRecordSourceSyncServiceImpl( ElnRecordMapper recordMapper, ElnRecordRevisionMapper revisionMapper, ElnRecordFieldValueMapper valueMapper, ElnTemplateBindingMapper bindingMapper, ElnFieldVersionMapper fieldVersionMapper, ElnTestWorkItemSnapshotProvider taskProvider, ElnRecordFormulaCalculationService calculationService, Consumer auditRecorder, CurrentUserProvider currentUserProvider, ElnCrossTemplateSnapshotResolver crossTemplateSnapshotResolver, ElnRecordSourceSyncOperationCoordinator operationCoordinator) { this(recordMapper, revisionMapper, valueMapper, bindingMapper, fieldVersionMapper, taskProvider, calculationService, auditRecorder, currentUserProvider, new TenantRoute() { @Override public void switchTo(String tenantId) { } @Override public void clear() { } }, null, null, crossTemplateSnapshotResolver, operationCoordinator); } ElnRecordSourceSyncServiceImpl( ElnRecordMapper recordMapper, ElnRecordRevisionMapper revisionMapper, ElnRecordFieldValueMapper valueMapper, ElnFieldVersionMapper fieldVersionMapper, ElnTestWorkItemSnapshotProvider taskProvider, ElnRecordFormulaCalculationService calculationService, Consumer auditRecorder, CurrentUserProvider currentUserProvider, TenantRoute tenantRoute, TransactionTemplate ticketReadTransaction, TransactionTemplate ticketWriteTransaction) { this(recordMapper, revisionMapper, valueMapper, null, fieldVersionMapper, taskProvider, calculationService, auditRecorder, currentUserProvider, tenantRoute, ticketReadTransaction, ticketWriteTransaction, ElnCrossTemplateSnapshotResolver.none(), ElnRecordSourceSyncOperationCoordinator.none()); } ElnRecordSourceSyncServiceImpl( ElnRecordMapper recordMapper, ElnRecordRevisionMapper revisionMapper, ElnRecordFieldValueMapper valueMapper, ElnFieldVersionMapper fieldVersionMapper, ElnTestWorkItemSnapshotProvider taskProvider, ElnRecordFormulaCalculationService calculationService, Consumer auditRecorder, CurrentUserProvider currentUserProvider, TenantRoute tenantRoute, TransactionTemplate ticketReadTransaction, TransactionTemplate ticketWriteTransaction, ElnCrossTemplateSnapshotResolver crossTemplateSnapshotResolver) { this(recordMapper, revisionMapper, valueMapper, null, fieldVersionMapper, taskProvider, calculationService, auditRecorder, currentUserProvider, tenantRoute, ticketReadTransaction, ticketWriteTransaction, crossTemplateSnapshotResolver, ElnRecordSourceSyncOperationCoordinator.none()); } ElnRecordSourceSyncServiceImpl( ElnRecordMapper recordMapper, ElnRecordRevisionMapper revisionMapper, ElnRecordFieldValueMapper valueMapper, ElnTemplateBindingMapper bindingMapper, ElnFieldVersionMapper fieldVersionMapper, ElnTestWorkItemSnapshotProvider taskProvider, ElnRecordFormulaCalculationService calculationService, Consumer auditRecorder, CurrentUserProvider currentUserProvider, TenantRoute tenantRoute, TransactionTemplate ticketReadTransaction, TransactionTemplate ticketWriteTransaction, ElnCrossTemplateSnapshotResolver crossTemplateSnapshotResolver, ElnRecordSourceSyncOperationCoordinator operationCoordinator) { this.recordMapper = recordMapper; this.revisionMapper = revisionMapper; this.valueMapper = valueMapper; this.bindingMapper = bindingMapper; this.fieldVersionMapper = fieldVersionMapper; this.taskProvider = taskProvider; this.crossTemplateSnapshotResolver = crossTemplateSnapshotResolver; this.calculationService = calculationService; this.auditRecorder = auditRecorder; this.currentUserProvider = currentUserProvider; this.tenantRoute = tenantRoute; this.ticketReadTransaction = ticketReadTransaction; this.ticketWriteTransaction = ticketWriteTransaction; this.operationCoordinator = operationCoordinator; } @Override @Transactional(readOnly = true) public ElnRecordVos.SourceSyncPreview preview(String recordId) { PreparedSync prepared = prepare(required(recordId, "电子记录不能为空"), currentUserProvider.get(), null); return preview(prepared); } @Override public ElnRecordVos.SourceSyncPreview preview( ElnRecordFillTicketService.FillTicketContext ticket) { requireRecordTicket(ticket); String tenantId = required(ticket.getTenantId(), "填写凭证租户不能为空"); return inTenant(tenantId, ticketReadTransaction, () -> preview(prepare( required(ticket.getBizDataId(), "填写凭证电子记录不能为空"), new UserContext(tenantId, required(ticket.getUserId(), "填写凭证用户不能为空")), required(ticket.getFileId(), "填写凭证文档不能为空")))); } private static ElnRecordVos.SourceSyncPreview preview(PreparedSync prepared) { ElnRecordVos.SourceSyncPreview result = new ElnRecordVos.SourceSyncPreview(); result.setRecordId(prepared.record.getId()); result.setRevisionId(prepared.revision.getId()); result.setRecordVersion(prepared.record.getFVersion()); result.setPreviewToken(previewToken(prepared)); result.setDifferences(prepared.differences()); result.setChangedCount(prepared.changed().size()); return result; } @Override @Transactional(rollbackFor = Exception.class) public ElnRecordVos.SourceSynchronized synchronize( String recordId, ElnRecordForms.SourceSync command) { return synchronize(required(recordId, "电子记录不能为空"), command, currentUserProvider.get(), null); } @Override public ElnRecordVos.SourceSynchronized synchronize( ElnRecordFillTicketService.FillTicketContext ticket, ElnRecordForms.SourceSync command) { requireRecordTicket(ticket); String tenantId = required(ticket.getTenantId(), "填写凭证租户不能为空"); return inTenant(tenantId, ticketWriteTransaction, () -> synchronize( required(ticket.getBizDataId(), "填写凭证电子记录不能为空"), command, new UserContext(tenantId, required(ticket.getUserId(), "填写凭证用户不能为空")), required(ticket.getFileId(), "填写凭证文档不能为空"))); } @Override public ElnRecordVos.SourceSynchronized pending( ElnRecordFillTicketService.FillTicketContext ticket) { requireRecordTicket(ticket); String tenantId = required(ticket.getTenantId(), "填写凭证租户不能为空"); return inTenant(tenantId, ticketReadTransaction, () -> operationCoordinator.pending(tenantId, required(ticket.getUserId(), "填写凭证用户不能为空"), required(ticket.getBizDataId(), "填写凭证电子记录不能为空"))); } @Override @Transactional(rollbackFor = Exception.class) public void confirmSavedDocument(String tenantId, String userId, String recordId, String revisionId) { ElnRecordVos.SourceSynchronized result = operationCoordinator.pending( tenantId, userId, recordId); if (result == null) return; if (!Objects.equals(revisionId, result.getRevisionId())) { throw new DataException("来源同步文档确认修订不匹配"); } ElnRecordEntity record = recordMapper.selectOne( Wrappers.lambdaQuery() .eq(ElnRecordEntity::getFTenantId, tenantId) .eq(ElnRecordEntity::getId, recordId) .and(q -> q.isNull(ElnRecordEntity::getFDeleteMark) .or().eq(ElnRecordEntity::getFDeleteMark, 0))); if (record == null || !Objects.equals(record.getDangqianXiudingId(), result.getRevisionId())) { throw new DataException("来源同步文档确认修订不匹配"); } boolean completed = operationCoordinator.confirm( tenantId, userId, result.getOperationCode()); if (completed) audit(record, result, new UserContext(tenantId, userId)); } private ElnRecordVos.SourceSynchronized synchronize( String actualRecordId, ElnRecordForms.SourceSync command, UserContext user, String expectedDocumentId) { String operationCode = required(command == null ? null : command.getOperationCode(), "重新同步操作码不能为空"); Integer expectedVersion = command == null ? null : command.getExpectedRecordVersion(); if (expectedVersion == null || expectedVersion < 0) { throw new DataException("电子记录版本不能为空"); } String expectedPreviewToken = required( command == null ? null : command.getPreviewToken(), "重新同步预览凭证不能为空"); Map clearReasons = clearReasons(command); String requestDigest = operationRequestDigest( actualRecordId, expectedVersion, expectedPreviewToken, clearReasons); ElnRecordVos.SourceSynchronized replay = operationCoordinator.begin( user.tenantId, user.userId, operationCode, requestDigest); if (replay != null) { return replay; } if (expectedDocumentId != null && operationCoordinator.hasPendingDocumentConfirmation( user.tenantId, actualRecordId)) { throw new DataException("电子记录已有来源同步等待文档保存"); } PreparedSync prepared = prepare(actualRecordId, user, expectedDocumentId); if (!expectedVersion.equals(prepared.record.getFVersion())) { throw new DataException("电子记录已被其他操作更新"); } if (!MessageDigest.isEqual( expectedPreviewToken.getBytes(StandardCharsets.UTF_8), previewToken(prepared).getBytes(StandardCharsets.UTF_8))) { throw new DataException("LIMS 任务快照已变化,请重新预览后再确认"); } selectClears(prepared, clearReasons); List changed = prepared.changed(); if (changed.isEmpty()) { ElnRecordVos.SourceSynchronized result = synchronizedResult( prepared, operationCode, expectedVersion, Collections.emptyList()); audit(prepared.record, result, user); return operationCoordinator.complete(user.tenantId, user.userId, operationCode, requestDigest, result); } if (recordMapper.advanceVersion(user.tenantId, prepared.record.getId(), expectedVersion, user.userId) != 1) { throw new DataException("电子记录已被其他操作更新"); } Set valueChangedBindingIds = new LinkedHashSet<>(); for (SyncCandidate candidate : changed) { if (candidate.newValue || !candidate.currentValue.matches(candidate.value)) { valueChangedBindingIds.add(candidate.value.getMubanBangdingId()); } apply(candidate, user.userId, prepared.fetchedTime); int saved = candidate.newValue ? valueMapper.insert(candidate.value) : candidate.configuredSnapshot == null ? valueMapper.updateById(candidate.value) : valueMapper.updateConfiguredSnapshot(candidate.value); if (saved != 1) { throw new DataException("电子记录任务快照字段保存冲突"); } } List calculated = valueChangedBindingIds.isEmpty() ? Collections.emptyList() : calculationService.recalculateAffected( user.tenantId, prepared.record, prepared.revision, prepared.values, valueChangedBindingIds, operationCode, user.userId); for (ElnRecordFieldValueEntity value : calculated) { if (valueMapper.updateById(value) != 1) { throw new DataException("重新同步后的公式结果保存冲突"); } } ElnRecordVos.SourceSynchronized result = synchronizedResult( prepared, operationCode, expectedVersion + 1, calculated); if (expectedDocumentId != null) { return operationCoordinator.prepare(user.tenantId, user.userId, operationCode, requestDigest, result); } audit(prepared.record, result, user); return operationCoordinator.complete(user.tenantId, user.userId, operationCode, requestDigest, result); } private PreparedSync prepare(String recordId, UserContext user, String expectedDocumentId) { ElnRecordEntity record = recordMapper.selectOne( Wrappers.lambdaQuery() .eq(ElnRecordEntity::getFTenantId, user.tenantId) .eq(ElnRecordEntity::getId, recordId) .and(q -> q.isNull(ElnRecordEntity::getFDeleteMark) .or().eq(ElnRecordEntity::getFDeleteMark, 0)).last("limit 1")); if (record == null || !StringUtils.hasText(record.getDangqianXiudingId())) { throw new DataException("电子记录不存在或当前修订无效"); } ElnRecordRevisionEntity revision = revisionMapper.selectOne( Wrappers.lambdaQuery() .eq(ElnRecordRevisionEntity::getFTenantId, user.tenantId) .eq(ElnRecordRevisionEntity::getId, record.getDangqianXiudingId()) .eq(ElnRecordRevisionEntity::getDianziJiluId, record.getId()) .and(q -> q.isNull(ElnRecordRevisionEntity::getFDeleteMark) .or().eq(ElnRecordRevisionEntity::getFDeleteMark, 0)).last("limit 1")); if (revision != null && StringUtils.hasText(expectedDocumentId) && !Objects.equals(expectedDocumentId, revision.getJiluWendang())) { throw new DataException("电子记录文档与当前修订不匹配"); } if (revision == null || !DRAFT.equals(revision.getBizStatus())) { throw new DataException("电子记录当前修订不允许重新同步"); } List values = valueMapper.selectList( Wrappers.lambdaQuery() .eq(ElnRecordFieldValueEntity::getFTenantId, user.tenantId) .eq(ElnRecordFieldValueEntity::getDianziJiluId, record.getId()) .eq(ElnRecordFieldValueEntity::getXiudingId, revision.getId()) .and(q -> q.isNull(ElnRecordFieldValueEntity::getFDeleteMark) .or().eq(ElnRecordFieldValueEntity::getFDeleteMark, 0))); List sourceValues = new ArrayList<>(); Set crossTemplateBindingIds = new LinkedHashSet<>(); Map crossTemplateVersions = new LinkedHashMap<>(); Set properties = new LinkedHashSet<>(); Set configuredProperties = new LinkedHashSet<>(); Set bindingIds = new LinkedHashSet<>(); for (ElnRecordFieldValueEntity value : values) { if (!Objects.equals(record.getMubanId(), value.getMubanId()) || !bindingIds.add(value.getMubanBangdingId())) { throw new DataException("电子记录字段值与固定模板不一致"); } ElnFieldVersionEntity version = fixedVersion(user.tenantId, value); ElnFieldForms.Binding binding = parseBinding(version.getBangdingPeizhi()); String source = binding == null || binding.getRead() == null ? null : binding.getRead().getSource(); if (CROSS_TEMPLATE.equals(value.getZhiLaiyuan())) { crossTemplateBindingIds.add(value.getMubanBangdingId()); crossTemplateVersions.put(value.getMubanBangdingId(), version); continue; } if (!LIMS_TASK.equals(source)) continue; String property = required(binding.getRead().getProperty(), "LIMS 任务快照字段缺少 property"); if (!LIMS_TASK.equals(value.getZhiLaiyuan()) || !property.equals(value.getYuanShuxing())) { throw new DataException("电子记录任务快照字段来源与固定版本不一致"); } ElnSourcePropertyRef reference = binding.getRead().getPropertyRevision() == null ? null : ElnConfiguredSourceValues.reference(binding.getRead()); boolean initializationIncomplete = reference != null && value.getYuanShuxingBanben() == null && value.getYuanQuzhiZhuangtai() == null && !StringUtils.hasText(value.getYuanXueyuan()) && value.getYuanHuoquShijian() == null; if (reference != null && !initializationIncomplete && !reference.getRevision().equals(value.getYuanShuxingBanben())) { throw new DataException("电子记录来源快照版本与固定字段版本不一致"); } sourceValues.add(new VersionedValue( value, version, property, reference, initializationIncomplete, false)); if (reference == null) properties.add(property); else configuredProperties.add(reference); } addMissingConfiguredSourceValues(user, record, revision, values, bindingIds, sourceValues, configuredProperties); TestWorkItemSnapshot snapshot = snapshot(user, record, new ArrayList<>(properties)); Map byProperty = properties(snapshot); List references = new ArrayList<>(configuredProperties); Map configured = references.isEmpty() ? Collections.emptyMap() : ElnConfiguredSourceValues.index(record.getJianyanXiangId(), references, taskProvider.resolveConfiguredAsActor(user.tenantId, user.userId, Collections.singletonList(record.getJianyanXiangId()), references)); Date fetchedTime = new Date(); List candidates = new ArrayList<>(); for (VersionedValue sourceValue : sourceValues) { if (sourceValue.reference != null) { candidates.add(SyncCandidate.configured(sourceValue.value, sourceValue.version, configured.get(sourceValue.reference), sourceValue.initializationIncomplete, sourceValue.newValue)); continue; } TestWorkItemSnapshotProperty property = byProperty.get(sourceValue.property); if (property == null || !"SUCCESS".equals(property.getOutcomeCode())) { throw new DataException(property != null && StringUtils.hasText(property.getReason()) ? property.getReason() : "LIMS 任务快照缺少字段:" + sourceValue.property); } if (!Objects.equals(sourceValue.version.getShujuLeixing(), property.getDataType())) { throw new DataException("LIMS 任务快照字段类型与固定字段版本不一致"); } candidates.add(SyncCandidate.lims(sourceValue.value, sourceValue.version, property, fetchedTime)); } if (!crossTemplateBindingIds.isEmpty()) { Map sources = crossTemplateSnapshotResolver.resolveByBindingIds(user.tenantId, record.getJianyanXiangId(), crossTemplateBindingIds); for (ElnRecordFieldValueEntity targetValue : values) { if (!CROSS_TEMPLATE.equals(targetValue.getZhiLaiyuan())) continue; ElnRecordFieldValueEntity source = sources.get(targetValue.getMubanBangdingId()); if (source == null) { throw new DataException("跨模板字段来源规则无效或源记录不可用"); } candidates.add(SyncCandidate.crossTemplate(targetValue, crossTemplateVersions.get(targetValue.getMubanBangdingId()), source, fetchedTime)); } } return new PreparedSync(record, revision, values, candidates, fetchedTime); } private void addMissingConfiguredSourceValues(UserContext user, ElnRecordEntity record, ElnRecordRevisionEntity revision, List values, Set bindingIds, List sourceValues, Set configuredProperties) { if (bindingMapper == null) return; List bindings = bindingMapper.selectList( Wrappers.lambdaQuery() .eq(ElnTemplateBindingEntity::getFTenantId, user.tenantId) .eq(ElnTemplateBindingEntity::getMubanId, record.getMubanId()) .eq(ElnTemplateBindingEntity::getBangdingZhuangtai, "ACTIVE") .eq(ElnTemplateBindingEntity::getKongjianJuese, "PRIMARY") .and(q -> q.isNull(ElnTemplateBindingEntity::getFDeleteMark) .or().eq(ElnTemplateBindingEntity::getFDeleteMark, 0)) .orderByAsc(ElnTemplateBindingEntity::getId)); if (bindings == null) return; for (ElnTemplateBindingEntity binding : bindings) { if (binding == null || bindingIds.contains(binding.getId())) continue; ElnRecordFieldValueEntity value = missingValue(user, record, revision, binding); ElnFieldVersionEntity version = fixedVersion(user.tenantId, value); ElnFieldForms.Binding config = parseBinding(version.getBangdingPeizhi()); if (config == null || config.getRead() == null || !LIMS_TASK.equals(config.getRead().getSource()) || config.getRead().getPropertyRevision() == null) { continue; } ElnSourcePropertyRef reference = ElnConfiguredSourceValues.reference(config.getRead()); values.add(value); bindingIds.add(binding.getId()); sourceValues.add(new VersionedValue(value, version, reference.getPropertyCode(), reference, true, true)); configuredProperties.add(reference); } } private static ElnRecordFieldValueEntity missingValue(UserContext user, ElnRecordEntity record, ElnRecordRevisionEntity revision, ElnTemplateBindingEntity binding) { ElnRecordFieldValueEntity value = new ElnRecordFieldValueEntity(); value.setId(IdUtil.getSnowflakeNextIdStr()); value.setDianziJiluId(record.getId()); value.setXiudingId(revision.getId()); value.setMubanId(record.getMubanId()); value.setMubanBangdingId(binding.getId()); value.setZiduanDingyiId(binding.getZiduanDingyiId()); value.setZiduanBianma(binding.getZiduanBianma()); value.setZiduanBanbenId(binding.getZiduanBanbenId()); value.setBangdingJuese("PRIMARY"); value.setBangdingZhuangtai("ACTIVE"); value.setFTenantId(user.tenantId); value.setFVersion(0); value.setFDeleteMark(0); value.setFCreatorUserId(user.userId); value.setFLastModifyUserId(user.userId); return value; } private static Map clearReasons(ElnRecordForms.SourceSync command) { Map result = new java.util.TreeMap<>(); if (command.getClearReasons() == null) return result; command.getClearReasons().forEach((bindingId, reason) -> { String actualReason = required(reason, "清空来源快照必须填写原因"); if (actualReason.length() > 500) throw new DataException("清空原因不能超过 500 字"); result.put(required(bindingId, "清空字段绑定不能为空"), actualReason); }); if (result.size() != command.getClearReasons().size()) throw new DataException("清空字段绑定重复"); return result; } private static void selectClears(PreparedSync prepared, Map reasons) { for (Map.Entry entry : reasons.entrySet()) { SyncCandidate candidate = prepared.candidates.stream() .filter(item -> entry.getKey().equals(item.value.getMubanBangdingId())).findFirst() .orElseThrow(() -> new DataException("清空字段不属于本次来源同步")); if (!candidate.difference.isCanClear()) throw new DataException("本次来源状态不允许清空该字段"); candidate.currentValue = ValueSlots.text(null); candidate.difference.setCurrentValue(null); candidate.difference.setCurrentFetchedTime(candidate.difference.getSourceFetchedAt()); candidate.difference.setClearReason(entry.getValue()); candidate.difference.setChanged(true); } } private ElnFieldVersionEntity fixedVersion( String tenantId, ElnRecordFieldValueEntity value) { if (!"PRIMARY".equals(value.getBangdingJuese()) || !"ACTIVE".equals(value.getBangdingZhuangtai())) { throw new DataException("电子记录字段值不是活动主绑定"); } ElnFieldVersionEntity version = fieldVersionMapper.selectOne( Wrappers.lambdaQuery() .eq(ElnFieldVersionEntity::getFTenantId, tenantId) .eq(ElnFieldVersionEntity::getId, value.getZiduanBanbenId()) .and(q -> q.isNull(ElnFieldVersionEntity::getFDeleteMark) .or().eq(ElnFieldVersionEntity::getFDeleteMark, 0)).last("limit 1")); if (version == null || !Objects.equals(value.getZiduanDingyiId(), version.getZiduanDingyiId())) { throw new DataException("电子记录固定字段版本无效"); } return version; } private TestWorkItemSnapshot snapshot( UserContext user, ElnRecordEntity record, List properties) { TestWorkItemSnapshotBatch batch = taskProvider.resolveAsActor( user.tenantId, user.userId, Collections.singletonList(record.getJianyanXiangId()), properties); if (batch == null || batch.getItems() == null || batch.getItems().size() != 1) { throw new DataException("LIMS 检验任务快照返回无效"); } TestWorkItemSnapshot item = batch.getItems().get(0); if (item == null || !record.getJianyanXiangId().equals(item.getTestWorkItemId()) || !"SUCCESS".equals(item.getOutcomeCode()) || !PENDING_TEST.equals(item.getTaskStatus())) { throw new DataException(item != null && StringUtils.hasText(item.getReason()) ? item.getReason() : "当前用户或检验项状态不允许重新同步电子记录"); } return item; } private static Map properties( TestWorkItemSnapshot item) { Map result = new LinkedHashMap<>(); if (item.getProperties() == null) return result; for (TestWorkItemSnapshotProperty property : item.getProperties()) { if (property == null || !StringUtils.hasText(property.getProperty())) continue; if (result.put(property.getProperty(), property) != null) { throw new DataException("LIMS 任务快照返回重复字段"); } } return result; } private static void apply(SyncCandidate candidate, String userId, Date fetchedTime) { candidate.currentValue.apply(candidate.value); candidate.value.setZhiLaiyuan(candidate.sourceType); candidate.value.setYuanShuxing(candidate.sourceProperty); candidate.value.setYuanYinyongLeixing(candidate.sourceType); candidate.value.setYuanYinyongId(candidate.sourceRef); candidate.value.setYuanHuoquShijian(fetchedTime); if (candidate.configuredSnapshot != null) { ElnConfiguredSourceValues.apply(candidate.value, candidate.version, candidate.configuredSnapshot); if (candidate.difference.getClearReason() != null) candidate.value.setYuanQuzhiZhuangtai("CLEARED"); } if (CROSS_TEMPLATE.equals(candidate.sourceType)) { candidate.value.setKuamoLaiyuanJiluId(candidate.source.getDianziJiluId()); candidate.value.setKuamoLaiyuanXiudingId(candidate.source.getXiudingId()); candidate.value.setKuamoLaiyuanZhiId(candidate.source.getId()); candidate.value.setKuamoLaiyuanZiduanBanbenId(candidate.source.getZiduanBanbenId()); candidate.value.setKuamoHuoquShijian(fetchedTime); } candidate.value.setFLastModifyUserId(userId); candidate.value.setFLastModifyTime(fetchedTime); } private void audit(ElnRecordEntity record, ElnRecordVos.SourceSynchronized result, UserContext user) { auditRecorder.accept(AuditEventDTO.builder() .clientEventId(result.getOperationCode()) .operationId(result.getOperationCode()) .tenantId(user.tenantId) .operatorId(user.userId) .eventTime(auditEventTime(result)) .eventType(result.getChangedCount() == 0 ? "BIZ_ACTION" : "DATA_CHANGE") .actionCode("ELN_RECORD_SOURCE_SYNC") .actionLabel("重新同步电子记录任务快照") .bizModule("eln") .bizType("eln_record") .bizCode(record.getJiluBianhao()) .targetTable("eln_record") .targetId(record.getId()) .fieldDiffs(auditFieldDiffs(result.getDifferences())) .dataSnapshot(JsonUtil.getObjectToString(result.getDifferences())) .build()); } private static Date auditEventTime(ElnRecordVos.SourceSynchronized result) { for (ElnRecordVos.SourceSyncDifference difference : result.getDifferences()) { if (difference.getCurrentFetchedTime() != null) { return difference.getCurrentFetchedTime(); } } return new Date(); } private static String auditFieldDiffs( List differences) { List> diffs = new ArrayList<>(); for (ElnRecordVos.SourceSyncDifference source : differences) { if (!source.isChanged()) continue; Map diff = new LinkedHashMap<>(); String valueColumn = "zhi_wenben"; if ("DECIMAL".equalsIgnoreCase(source.getDataType())) valueColumn = "zhi_shuzhi"; else if ("DATE".equalsIgnoreCase(source.getDataType()) || "DATETIME".equalsIgnoreCase(source.getDataType())) valueColumn = "zhi_riqi"; diff.put("field", valueColumn); diff.put("fieldName", source.getFieldName()); diff.put("oldData", source.getPreviousValue() == null ? "" : source.getPreviousValue()); diff.put("newData", source.getCurrentValue() == null ? "" : source.getCurrentValue()); diff.put("type", source.getPreviousValue() == null || source.getPreviousValue().isEmpty() ? 0 : 1); diff.put("targetTable", "eln_record_field_value"); diff.put("targetId", source.getFieldValueId()); diff.put("elnFieldCode", source.getFieldCode()); diff.put("sourceType", source.getSourceType()); diff.put("sourceProperty", source.getSourceProperty()); if (source.getSourcePropertyRevision() != null) { diff.put("sourcePropertyRevision", source.getSourcePropertyRevision()); diff.put("sourceOutcome", source.getSourceOutcome()); diff.put("sourceReason", source.getSourceReason()); diff.put("sourceLineage", source.getSourceLineage()); diff.put("clearReason", source.getClearReason()); } diff.put("sourceRecordId", source.getSourceRecordId()); diff.put("sourceRevisionId", source.getSourceRevisionId()); diff.put("sourceValueId", source.getSourceValueId()); diff.put("sourceFieldVersionId", source.getSourceFieldVersionId()); diffs.add(diff); } return JsonUtil.getObjectToString(diffs); } private static ElnRecordVos.SourceSynchronized synchronizedResult( PreparedSync prepared, String operationCode, int recordVersion, List calculated) { ElnRecordVos.SourceSynchronized result = new ElnRecordVos.SourceSynchronized(); result.setRecordId(prepared.record.getId()); result.setRevisionId(prepared.revision.getId()); result.setOperationCode(operationCode); result.setRecordVersion(recordVersion); result.setDifferences(prepared.differences()); result.setChangedCount(prepared.changed().size()); result.setRecalculatedCount(calculated.size()); Set changedFieldCodes = new LinkedHashSet<>(); for (SyncCandidate candidate : prepared.changed()) { changedFieldCodes.add(candidate.difference.getFieldCode()); } for (ElnRecordFieldValueEntity value : calculated) { if (StringUtils.hasText(value.getZiduanBianma())) { changedFieldCodes.add(value.getZiduanBianma()); } } result.setChangedFieldCodes(new ArrayList<>(changedFieldCodes)); return result; } private static ElnFieldForms.Binding parseBinding(String json) { try { return StringUtils.hasText(json) ? JsonUtil.getJsonToBean(json, ElnFieldForms.Binding.class) : null; } catch (RuntimeException e) { throw new DataException("字段绑定配置无效"); } } private static String previewToken(PreparedSync prepared) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); updateDigest(digest, prepared.record.getId()); updateDigest(digest, prepared.revision.getId()); updateDigest(digest, String.valueOf(prepared.record.getFVersion())); List candidates = new ArrayList<>(prepared.candidates); candidates.sort(Comparator .comparing((SyncCandidate candidate) -> candidate.value.getMubanBangdingId()) .thenComparing(candidate -> candidate.sourceType)); for (SyncCandidate candidate : candidates) { ElnRecordVos.SourceSyncDifference difference = candidate.difference; updateDigest(digest, difference.getBindingId()); updateDigest(digest, difference.getSourceProperty()); updateDigest(digest, difference.getDataType()); updateDigest(digest, difference.getPreviousValue()); updateDigest(digest, difference.getCurrentValue()); updateDigest(digest, difference.getPreviousSourceType()); updateDigest(digest, difference.getCurrentSourceType()); updateDigest(digest, difference.getPreviousSourceRef()); updateDigest(digest, difference.getCurrentSourceRef()); updateDigest(digest, difference.getSourceType()); updateDigest(digest, difference.getSourceRecordId()); updateDigest(digest, difference.getSourceRevisionId()); updateDigest(digest, difference.getSourceValueId()); updateDigest(digest, difference.getSourceFieldVersionId()); if (candidate.configuredSnapshot != null) { updateDigest(digest, String.valueOf(difference.getSourcePropertyRevision())); updateDigest(digest, difference.getSourceOutcome()); updateDigest(digest, difference.getSourceReason()); updateDigest(digest, difference.getSourceLineage()); updateDigest(digest, candidate.value.getYuanQuzhiZhuangtai()); updateDigest(digest, candidate.value.getYuanXueyuan()); updateDigest(digest, String.valueOf(candidate.value.getYuanHuoquShijian() == null ? null : candidate.value.getYuanHuoquShijian().getTime())); } } return toHex(digest.digest()); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("JVM 不支持 SHA-256", e); } } private static String operationRequestDigest( String recordId, Integer expectedVersion, String previewToken, Map clearReasons) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); updateDigest(digest, "ELN_RECORD_SOURCE_SYNC"); updateDigest(digest, recordId); updateDigest(digest, String.valueOf(expectedVersion)); updateDigest(digest, previewToken); for (Map.Entry entry : clearReasons.entrySet()) { updateDigest(digest, entry.getKey()); updateDigest(digest, entry.getValue()); } return toHex(digest.digest()); } catch (NoSuchAlgorithmException e) { throw new DataException("来源同步请求摘要生成失败"); } } private static void updateDigest(MessageDigest digest, String value) { byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8); digest.update((byte) (bytes.length >>> 24)); digest.update((byte) (bytes.length >>> 16)); digest.update((byte) (bytes.length >>> 8)); digest.update((byte) bytes.length); digest.update(bytes); } private static String toHex(byte[] bytes) { StringBuilder result = new StringBuilder(bytes.length * 2); for (byte value : bytes) { result.append(Character.forDigit((value >>> 4) & 0x0f, 16)); result.append(Character.forDigit(value & 0x0f, 16)); } return result.toString(); } private static String required(String value, String message) { if (!StringUtils.hasText(value)) throw new DataException(message); return value.trim(); } private T inTenant(String tenantId, TransactionTemplate transaction, TenantAction action) { try { tenantRoute.switchTo(tenantId); return transaction == null ? action.get() : transaction.execute(status -> action.get()); } finally { tenantRoute.clear(); } } private static TransactionTemplate readTransaction( PlatformTransactionManager transactionManager) { TransactionTemplate transaction = new TransactionTemplate(transactionManager); transaction.setReadOnly(true); return transaction; } private static void requireRecordTicket( ElnRecordFillTicketService.FillTicketContext ticket) { if (ticket == null || !ElnRecordFillTicketService.BIZ_MODULE.equals(ticket.getBizModule()) || !ElnRecordFillTicketService.BIZ_SCENE.equals(ticket.getBizScene())) { throw new DataException("电子记录填写凭证无效"); } } private static UserContext currentUser() { UserInfo user = UserProvider.getUser(); if (user == null || !StringUtils.hasText(user.getUserId())) { throw new DataException("当前登录用户无效"); } TenantVO routed = TenantDataSourceUtil.getTenantInfo(); String tenantId = routed != null && StringUtils.hasText(routed.getEnCode()) ? routed.getEnCode().trim() : user.getTenantId(); return new UserContext(StringUtils.hasText(tenantId) ? tenantId.trim() : "0", user.getUserId().trim()); } @FunctionalInterface interface CurrentUserProvider { UserContext get(); } @FunctionalInterface interface TenantAction { T get(); } interface TenantRoute { void switchTo(String tenantId); void clear(); } static final class UserContext { private final String tenantId; private final String userId; UserContext(String tenantId, String userId) { this.tenantId = tenantId; this.userId = userId; } } private static final class VersionedValue { private final ElnRecordFieldValueEntity value; private final ElnFieldVersionEntity version; private final String property; private final ElnSourcePropertyRef reference; private final boolean initializationIncomplete; private final boolean newValue; private VersionedValue(ElnRecordFieldValueEntity value, ElnFieldVersionEntity version, String property, ElnSourcePropertyRef reference, boolean initializationIncomplete, boolean newValue) { this.value = value; this.version = version; this.property = property; this.reference = reference; this.initializationIncomplete = initializationIncomplete; this.newValue = newValue; } } private static final class SyncCandidate { private final ElnRecordFieldValueEntity value; private final ElnFieldVersionEntity version; private final String sourceType; private final String sourceProperty; private final String sourceRef; private ValueSlots currentValue; private final ElnRecordFieldValueEntity source; private final ElnRecordVos.SourceSyncDifference difference; private ElnSourceSnapshot configuredSnapshot; private boolean newValue; private SyncCandidate(ElnRecordFieldValueEntity value, ElnFieldVersionEntity version, String sourceType, String sourceProperty, String sourceRef, ValueSlots currentValue, ElnRecordFieldValueEntity source, Date fetchedTime) { this.value = value; this.version = version; this.sourceType = sourceType; this.sourceProperty = sourceProperty; this.sourceRef = sourceRef; this.currentValue = currentValue; this.source = source; this.difference = difference(value, version, sourceType, sourceProperty, sourceRef, currentValue, source, fetchedTime); } private static SyncCandidate lims(ElnRecordFieldValueEntity value, ElnFieldVersionEntity version, TestWorkItemSnapshotProperty property, Date fetchedTime) { return new SyncCandidate(value, version, LIMS_TASK, property.getProperty(), property.getSourceRef(), ValueSlots.text(property.getValue()), null, fetchedTime); } private static SyncCandidate configured(ElnRecordFieldValueEntity value, ElnFieldVersionEntity version, ElnSourceSnapshot snapshot, boolean initializationIncomplete, boolean newValue) { ElnRecordFieldValueEntity resolved = new ElnRecordFieldValueEntity(); ElnConfiguredSourceValues.apply(resolved, version, snapshot); boolean missing = !"SUCCESS".equals(snapshot.getOutcomeCode()); SyncCandidate candidate = new SyncCandidate(value, version, LIMS_TASK, snapshot.getPropertyCode(), snapshot.getSourceRef(), ValueSlots.of( missing && !initializationIncomplete ? value : resolved, version), null, missing && !initializationIncomplete ? value.getYuanHuoquShijian() : resolved.getYuanHuoquShijian()); candidate.configuredSnapshot = snapshot; candidate.newValue = newValue; candidate.difference.setSourcePropertyRevision(snapshot.getPropertyRevision()); candidate.difference.setSourceOutcome(snapshot.getOutcomeCode()); candidate.difference.setSourceReason(snapshot.getReasonCode()); candidate.difference.setSourceLineage(snapshot.getLineage()); candidate.difference.setSourceFetchedAt(resolved.getYuanHuoquShijian()); candidate.difference.setCanClear(missing); if (missing && !initializationIncomplete) candidate.difference.setChanged(false); if (initializationIncomplete) candidate.difference.setChanged(true); return candidate; } private static SyncCandidate crossTemplate(ElnRecordFieldValueEntity value, ElnFieldVersionEntity version, ElnRecordFieldValueEntity source, Date fetchedTime) { return new SyncCandidate(value, version, CROSS_TEMPLATE, null, source.getId(), ValueSlots.of(source, version), source, fetchedTime); } private static ElnRecordVos.SourceSyncDifference difference( ElnRecordFieldValueEntity value, ElnFieldVersionEntity version, String sourceType, String sourceProperty, String sourceRef, ValueSlots currentValue, ElnRecordFieldValueEntity source, Date fetchedTime) { ElnRecordVos.SourceSyncDifference result = new ElnRecordVos.SourceSyncDifference(); result.setBindingId(value.getMubanBangdingId()); result.setFieldValueId(value.getId()); result.setFieldCode(value.getZiduanBianma()); result.setFieldName(version.getZiduanMingcheng()); result.setDataType(version.getShujuLeixing()); result.setSourceType(sourceType); result.setSourceProperty(sourceProperty); result.setPreviousValue(ValueSlots.of(value, version).display(version)); result.setCurrentValue(currentValue.display(version)); result.setPreviousSourceType(value.getYuanYinyongLeixing()); result.setCurrentSourceType(sourceType); result.setPreviousSourceRef(value.getYuanYinyongId()); result.setCurrentSourceRef(sourceRef); result.setPreviousFetchedTime(value.getYuanHuoquShijian()); result.setCurrentFetchedTime(fetchedTime); if (source != null) { result.setSourceRecordId(source.getDianziJiluId()); result.setSourceRevisionId(source.getXiudingId()); result.setSourceValueId(source.getId()); result.setSourceFieldVersionId(source.getZiduanBanbenId()); result.setSourceFetchedAt(fetchedTime); } result.setChanged(!currentValue.matches(value) || !Objects.equals(result.getPreviousSourceType(), result.getCurrentSourceType()) || !Objects.equals(result.getPreviousSourceRef(), result.getCurrentSourceRef()) || (source != null && (!Objects.equals(value.getKuamoLaiyuanJiluId(), source.getDianziJiluId()) || !Objects.equals(value.getKuamoLaiyuanXiudingId(), source.getXiudingId()) || !Objects.equals(value.getKuamoLaiyuanZhiId(), source.getId()) || !Objects.equals(value.getKuamoLaiyuanZiduanBanbenId(), source.getZiduanBanbenId())))); return result; } } private static final class ValueSlots { private final String text; private final BigDecimal number; private final Date date; private ValueSlots(String text, BigDecimal number, Date date) { this.text = text; this.number = number; this.date = date; } private static ValueSlots text(String value) { return new ValueSlots(value, null, null); } private static ValueSlots of(ElnRecordFieldValueEntity value, ElnFieldVersionEntity version) { String type = version == null || version.getShujuLeixing() == null ? "TEXT" : version.getShujuLeixing().trim().toUpperCase(); if ("DECIMAL".equals(type)) return new ValueSlots(null, value.getZhiShuzhi(), null); if ("DATE".equals(type) || "DATETIME".equals(type)) { return new ValueSlots(null, null, value.getZhiRiqi()); } return text(value.getZhiWenben()); } private boolean matches(ElnRecordFieldValueEntity value) { return Objects.equals(text, value.getZhiWenben()) && Objects.equals(number, value.getZhiShuzhi()) && Objects.equals(date, value.getZhiRiqi()); } private void apply(ElnRecordFieldValueEntity value) { value.setZhiWenben(text); value.setZhiShuzhi(number); value.setZhiRiqi(date); } private String display(ElnFieldVersionEntity version) { if (number != null) return number.toPlainString(); if (date != null) { return new SimpleDateFormat("DATETIME".equalsIgnoreCase( version == null ? null : version.getShujuLeixing()) ? "yyyy-MM-dd HH:mm:ss" : "yyyy-MM-dd").format(date); } return text; } } private static final class PreparedSync { private final ElnRecordEntity record; private final ElnRecordRevisionEntity revision; private final List values; private final List candidates; private final Date fetchedTime; private PreparedSync(ElnRecordEntity record, ElnRecordRevisionEntity revision, List values, List candidates, Date fetchedTime) { this.record = record; this.revision = revision; this.values = values; this.candidates = candidates; this.fetchedTime = fetchedTime; } private List changed() { List result = new ArrayList<>(); for (SyncCandidate candidate : candidates) { if (candidate.difference.isChanged()) result.add(candidate); } return result; } private List differences() { List result = new ArrayList<>(); for (SyncCandidate candidate : candidates) result.add(candidate.difference); return result; } } }