package jnpf.elnService.impl; import jnpf.audit.model.AuditEventDTO; 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.ElnRecordFormulaCalculationService; import jnpf.elnService.ElnCrossTemplateSnapshotResolver; import jnpf.elnService.ElnRecordFillTicketService; import jnpf.elnService.ElnRecordSourceSyncService; import jnpf.elnService.ElnRecordSourceSyncOperationCoordinator; 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.ElnSourceSnapshot; import jnpf.lims.api.model.ElnSourcePropertyRef; import org.junit.Test; import jnpf.util.JsonUtil; import java.lang.reflect.Proxy; import java.math.BigDecimal; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.List; import java.util.LinkedHashMap; import java.util.Map; import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; public class ElnRecordSourceSyncServiceImplTest { @Test public void explicitlyClearsAnOptionalMissingSnapshotAndAuditsTheReason() { Fixture fixture = configuredFixture(); fixture.configuredSnapshot.setOutcomeCode("MISSING"); fixture.configuredSnapshot.setReasonCode("NAME_MISSING"); fixture.configuredSnapshot.setValue(null); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); Map request = new LinkedHashMap<>(); request.put("expectedRecordVersion", preview.getRecordVersion()); request.put("previewToken", preview.getPreviewToken()); request.put("operationCode", "sync-1"); request.put("clearReasons", Collections.singletonMap("binding-lims", "Confirmed source no longer has a name")); ElnRecordVos.SourceSynchronized result = fixture.service.synchronize("record-1", JsonUtil.getJsonToBean(request, ElnRecordForms.SourceSync.class)); assertEquals(null, fixture.limsValue.getZhiWenben()); assertEquals("CLEARED", fixture.limsValue.getYuanQuzhiZhuangtai()); assertEquals("NAME_MISSING", fixture.limsValue.getYuanQuzhiYuanyin()); assertEquals(Date.from(java.time.Instant.parse("2026-09-06T02:00:00Z")), fixture.limsValue.getYuanHuoquShijian()); assertEquals(1, result.getChangedCount()); assertTrue(fixture.auditEvents.get(0).getDataSnapshot().contains("Confirmed source no longer has a name")); assertTrue(fixture.auditEvents.get(0).getFieldDiffs().contains("old basis")); } @Test public void rejectsConfirmationWhenConfiguredOutcomeOrLineageChangedAfterPreview() { List> changes = Arrays.asList( snapshot -> { snapshot.setOutcomeCode("MISSING"); snapshot.setValue(null); }, snapshot -> snapshot.setLineage("task.details@2/basis")); for (java.util.function.Consumer change : changes) { Fixture fixture = configuredFixture(); fixture.configuredSnapshot.setValue("old basis"); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); change.accept(fixture.configuredSnapshot); try { fixture.service.synchronize("record-1", syncCommand(preview)); fail("Changed source outcome or lineage must invalidate the preview"); } catch (DataException expected) { } assertEquals(0, fixture.casCalls.get()); assertEquals(0, fixture.updateCalls.get()); assertEquals(new Date(1000), fixture.limsValue.getYuanHuoquShijian()); } } @Test public void optionalMissingSourcesKeepTheOldValueAndAcquisitionTimeDuringSync() { for (String outcome : Arrays.asList("MISSING", "NOT_APPLICABLE")) { Fixture fixture = configuredFixture(); fixture.configuredSnapshot.setOutcomeCode(outcome); fixture.configuredSnapshot.setReasonCode("NAME_MISSING"); fixture.configuredSnapshot.setValue(null); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); assertEquals(0, preview.getChangedCount()); assertEquals("old basis", preview.getDifferences().get(0).getCurrentValue()); assertEquals(new Date(1000), preview.getDifferences().get(0).getCurrentFetchedTime()); com.fasterxml.jackson.databind.JsonNode difference = new com.fasterxml.jackson.databind.ObjectMapper() .valueToTree(preview.getDifferences().get(0)); assertEquals(outcome, difference.path("sourceOutcome").asText()); assertEquals("NAME_MISSING", difference.path("sourceReason").asText()); fixture.service.synchronize("record-1", syncCommand(preview)); assertEquals("old basis", fixture.limsValue.getZhiWenben()); assertEquals(new Date(1000), fixture.limsValue.getYuanHuoquShijian()); assertEquals("SUCCESS", fixture.limsValue.getYuanQuzhiZhuangtai()); assertEquals(0, fixture.casCalls.get()); assertEquals(0, fixture.updateCalls.get()); } } @Test public void explicitlySynchronizesThePinnedRevisionAndActualAcquisitionMetadata() { Fixture fixture = configuredFixture(); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); assertEquals("configured V1", preview.getDifferences().get(0).getCurrentValue()); assertEquals(Collections.emptyList(), fixture.requestedProperties.get()); fixture.configuredSnapshot.setAcquiredAt("2026-09-06T03:00:00Z"); fixture.service.synchronize("record-1", syncCommand(preview)); assertEquals("configured V1", fixture.limsValue.getZhiWenben()); assertEquals(Integer.valueOf(1), fixture.limsValue.getYuanShuxingBanben()); assertEquals("task.details@1/basis", fixture.limsValue.getYuanXueyuan()); assertEquals(Date.from(java.time.Instant.parse("2026-09-06T03:00:00Z")), fixture.limsValue.getYuanHuoquShijian()); assertEquals("SUCCESS", fixture.limsValue.getYuanQuzhiZhuangtai()); } @Test public void retriesAnUninitializedConfiguredSnapshotThroughTheControlledSyncFlow() { Fixture fixture = configuredFixture(); fixture.limsValue.setYuanShuxingBanben(null); fixture.limsValue.setYuanQuzhiZhuangtai(null); fixture.limsValue.setYuanXueyuan(null); fixture.limsValue.setYuanHuoquShijian(null); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); assertEquals(1, preview.getChangedCount()); assertEquals("old basis", preview.getDifferences().get(0).getPreviousValue()); assertEquals("configured V1", preview.getDifferences().get(0).getCurrentValue()); fixture.service.synchronize("record-1", syncCommand(preview)); assertEquals("configured V1", fixture.limsValue.getZhiWenben()); assertEquals(Integer.valueOf(1), fixture.limsValue.getYuanShuxingBanben()); assertEquals("SUCCESS", fixture.limsValue.getYuanQuzhiZhuangtai()); assertEquals("task.details@1/basis", fixture.limsValue.getYuanXueyuan()); assertEquals(Date.from(java.time.Instant.parse("2026-09-06T02:00:00Z")), fixture.limsValue.getYuanHuoquShijian()); assertEquals("system user", fixture.systemValue.getZhiWenben()); assertEquals(2, fixture.updateCalls.get()); assertEquals(1, fixture.formulaCalls.get()); } @Test public void initializesAnOptionalMissingSourceAsAnEmptySnapshotDuringRecovery() { Fixture fixture = configuredFixture(); fixture.limsValue.setYuanShuxingBanben(null); fixture.limsValue.setYuanQuzhiZhuangtai(null); fixture.limsValue.setYuanXueyuan(null); fixture.limsValue.setYuanHuoquShijian(null); fixture.configuredSnapshot.setOutcomeCode("MISSING"); fixture.configuredSnapshot.setReasonCode("NAME_MISSING"); fixture.configuredSnapshot.setValue(null); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); assertEquals(1, preview.getChangedCount()); assertEquals(null, preview.getDifferences().get(0).getCurrentValue()); fixture.service.synchronize("record-1", syncCommand(preview)); assertEquals(null, fixture.limsValue.getZhiWenben()); assertEquals(Integer.valueOf(1), fixture.limsValue.getYuanShuxingBanben()); assertEquals("MISSING", fixture.limsValue.getYuanQuzhiZhuangtai()); assertEquals("NAME_MISSING", fixture.limsValue.getYuanQuzhiYuanyin()); assertEquals("task.details@1/basis", fixture.limsValue.getYuanXueyuan()); assertEquals(Date.from(java.time.Instant.parse("2026-09-06T02:00:00Z")), fixture.limsValue.getYuanHuoquShijian()); } @Test public void failedRecoveryLeavesTheUninitializedRecordUntouched() { Fixture fixture = configuredFixture(); fixture.limsValue.setYuanShuxingBanben(null); fixture.limsValue.setYuanQuzhiZhuangtai(null); fixture.limsValue.setYuanXueyuan(null); fixture.limsValue.setYuanHuoquShijian(null); fixture.configuredSnapshot.setOutcomeCode("NOT_AVAILABLE"); fixture.configuredSnapshot.setReasonCode("DEPENDENCY_UNAVAILABLE"); fixture.configuredSnapshot.setValue(null); try { fixture.service.preview("record-1"); fail("Unavailable source must keep recovery incomplete"); } catch (DataException expected) { assertTrue(expected.getMessage().contains("LIMS 来源取数失败")); } assertEquals("old basis", fixture.limsValue.getZhiWenben()); assertEquals(null, fixture.limsValue.getYuanShuxingBanben()); assertEquals(null, fixture.limsValue.getYuanQuzhiZhuangtai()); assertEquals(null, fixture.limsValue.getYuanXueyuan()); assertEquals(null, fixture.limsValue.getYuanHuoquShijian()); assertEquals(0, fixture.casCalls.get()); assertEquals(0, fixture.updateCalls.get()); assertEquals(0, fixture.formulaCalls.get()); } @Test public void restoresAMissingConfiguredSourceValueThroughTheControlledSyncFlow() { Fixture fixture = configuredFixture(); fixture.includeLimsValue.set(false); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); assertEquals(1, preview.getChangedCount()); assertEquals(1, preview.getDifferences().size()); assertEquals("binding-lims", preview.getDifferences().get(0).getBindingId()); assertEquals(null, preview.getDifferences().get(0).getPreviousValue()); assertEquals("configured V1", preview.getDifferences().get(0).getCurrentValue()); assertEquals(0, fixture.insertCalls.get()); assertEquals(0, fixture.updateCalls.get()); assertEquals(0, fixture.casCalls.get()); assertEquals("system user", fixture.systemValue.getZhiWenben()); ElnRecordVos.SourceSynchronized result = fixture.service.synchronize( "record-1", syncCommand(preview)); ElnRecordFieldValueEntity restored = fixture.insertedValue.get(); assertNotNull(restored); assertEquals("record-1", restored.getDianziJiluId()); assertEquals("revision-1", restored.getXiudingId()); assertEquals("template-1", restored.getMubanId()); assertEquals("binding-lims", restored.getMubanBangdingId()); assertEquals("version-lims", restored.getZiduanBanbenId()); assertEquals("definition-lims", restored.getZiduanDingyiId()); assertEquals("test_basis", restored.getZiduanBianma()); assertEquals("tenant-a", restored.getFTenantId()); assertEquals("configured V1", restored.getZhiWenben()); assertEquals(Integer.valueOf(1), restored.getYuanShuxingBanben()); assertEquals("SUCCESS", restored.getYuanQuzhiZhuangtai()); assertEquals("task.details@1/basis", restored.getYuanXueyuan()); assertEquals(Date.from(java.time.Instant.parse("2026-09-06T02:00:00Z")), restored.getYuanHuoquShijian()); assertEquals(1, fixture.insertCalls.get()); assertEquals(1, fixture.casCalls.get()); assertEquals(1, fixture.formulaCalls.get()); assertEquals(Collections.singleton("binding-lims"), fixture.changedBindings.get()); assertEquals(1, result.getChangedCount()); assertEquals(Integer.valueOf(5), result.getRecordVersion()); assertEquals("system user", fixture.systemValue.getZhiWenben()); } private static Fixture configuredFixture() { Fixture fixture = new Fixture("old basis", "legacy basis"); fixture.limsVersion.setBangdingPeizhi("{\"read\":{\"source\":\"LIMS_TASK\",\"property\":\"test.basis\",\"propertyRevision\":1}}"); fixture.limsValue.setYuanShuxingBanben(1); fixture.limsValue.setYuanQuzhiZhuangtai("SUCCESS"); fixture.limsValue.setYuanHuoquShijian(new Date(1000)); ElnSourceSnapshot snapshot = new ElnSourceSnapshot(); snapshot.setSourceRef("item-1"); snapshot.setPropertyCode("test.basis"); snapshot.setPropertyRevision(1); snapshot.setDataType("TEXT"); snapshot.setValue("configured V1"); snapshot.setOutcomeCode("SUCCESS"); snapshot.setAcquiredAt("2026-09-06T02:00:00Z"); snapshot.setLineage("task.details@1/basis"); fixture.configuredSnapshot = snapshot; return fixture; } @Test public void previewsWithTheRecordFillTicketActorAndCurrentDocument() { Fixture fixture = new Fixture("old basis", "new basis", () -> { throw new AssertionError("login user must not be read"); }); ElnRecordVos.SourceSyncPreview result = fixture.service.preview( recordTicket("revision-1.docx")); assertEquals("record-1", result.getRecordId()); assertEquals(1, result.getChangedCount()); assertEquals(1, fixture.providerCalls.get()); } @Test public void routesRecordFillTicketWorkToTheTicketTenant() { List routes = new ArrayList<>(); ElnRecordSourceSyncServiceImpl.TenantRoute route = new ElnRecordSourceSyncServiceImpl.TenantRoute() { @Override public void switchTo(String tenantId) { routes.add("switch:" + tenantId); } @Override public void clear() { routes.add("clear"); } }; Fixture fixture = new Fixture("old basis", "new basis", () -> { throw new AssertionError("login user must not be read"); }, route); fixture.service.preview(recordTicket("revision-1.docx")); assertEquals(Arrays.asList("switch:tenant-a", "clear"), routes); } @Test public void rejectsRecordFillTicketForAnotherDocumentBeforeReadingProvider() { Fixture fixture = new Fixture("old basis", "new basis", () -> { throw new AssertionError("login user must not be read"); }); try { fixture.service.preview(recordTicket("stale-revision.docx")); fail("expected DataException"); } catch (DataException e) { assertTrue(e.getMessage().contains("文档与当前修订不匹配")); } assertEquals(0, fixture.providerCalls.get()); } @Test public void synchronizesWithTheRecordFillTicketActorAndCurrentDocument() { MemoryOperationCoordinator operations = new MemoryOperationCoordinator(); Fixture fixture = new Fixture("old basis", "new basis", () -> { throw new AssertionError("login user must not be read"); }, null, bindingIds -> Collections.emptyMap(), operations); ElnRecordFillTicketService.FillTicketContext ticket = recordTicket("revision-1.docx"); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview(ticket); ElnRecordForms.SourceSync command = new ElnRecordForms.SourceSync(); command.setExpectedRecordVersion(preview.getRecordVersion()); command.setPreviewToken(preview.getPreviewToken()); command.setOperationCode("sync-1"); ElnRecordVos.SourceSynchronized result = fixture.service.synchronize(ticket, command); Date fetchedTime = new Date(123L); result.getDifferences().get(0).setCurrentFetchedTime(fetchedTime); assertEquals(Integer.valueOf(5), result.getRecordVersion()); assertEquals("new basis", fixture.limsValue.getZhiWenben()); assertEquals(2, fixture.providerCalls.get()); assertEquals(1, fixture.casCalls.get()); assertEquals(0, fixture.auditEvents.size()); assertEquals("sync-1", fixture.service.pending(ticket).getOperationCode()); ElnRecordForms.SourceSync second = new ElnRecordForms.SourceSync(); second.setExpectedRecordVersion(preview.getRecordVersion()); second.setPreviewToken(preview.getPreviewToken()); second.setOperationCode("sync-2"); try { fixture.service.synchronize(ticket, second); fail("expected pending document confirmation to block another operation"); } catch (DataException expected) { assertEquals("电子记录已有来源同步等待文档保存", expected.getMessage()); } assertEquals(1, fixture.casCalls.get()); fixture.service.confirmSavedDocument( "tenant-1", "user-1", "record-1", "revision-1"); assertEquals("user-1", fixture.auditEvents.get(0).getOperatorId()); assertEquals(fetchedTime, fixture.auditEvents.get(0).getEventTime()); fixture.service.confirmSavedDocument( "tenant-1", "user-1", "record-1", "revision-1"); assertEquals(1, fixture.auditEvents.size()); assertEquals(null, fixture.service.pending(ticket)); } @Test public void previewsOnlyLimsTaskFieldsWithoutWriting() { Fixture fixture = new Fixture("old basis", "new basis"); ElnRecordVos.SourceSyncPreview result = fixture.service.preview("record-1"); assertEquals(Integer.valueOf(4), result.getRecordVersion()); assertEquals(64, result.getPreviewToken().length()); assertEquals(1, result.getDifferences().size()); assertEquals(1, result.getChangedCount()); assertEquals("test.basis", result.getDifferences().get(0).getSourceProperty()); assertEquals("old basis", result.getDifferences().get(0).getPreviousValue()); assertEquals("new basis", result.getDifferences().get(0).getCurrentValue()); assertTrue(result.getDifferences().get(0).isChanged()); assertEquals(1, fixture.providerCalls.get()); assertEquals(0, fixture.casCalls.get()); assertEquals(0, fixture.updateCalls.get()); assertEquals(0, fixture.auditEvents.size()); assertEquals(Collections.singletonList("test.basis"), fixture.requestedProperties.get()); } @Test public void previewsLimsAndCrossTemplateDifferencesWithSourceLineage() { ElnRecordFieldValueEntity source = value("source-value", "source-binding", "source-version", "source-definition", "source_field", "MANUAL", "new source"); source.setDianziJiluId("source-record"); source.setXiudingId("source-revision"); Fixture fixture = new Fixture("old basis", "new basis", targetBindingIds -> Collections.singletonMap("binding-cross", source)); fixture.crossValue = value("target-cross", "binding-cross", "version-cross", "definition-cross", "cross_field", "CROSS_TEMPLATE", "old source"); fixture.crossValue.setKuamoLaiyuanJiluId("old-source-record"); fixture.crossValue.setKuamoLaiyuanXiudingId("old-source-revision"); fixture.crossValue.setKuamoLaiyuanZhiId("old-source-value"); fixture.crossValue.setKuamoLaiyuanZiduanBanbenId("old-source-version"); ElnRecordVos.SourceSyncPreview result = fixture.service.preview("record-1"); assertEquals(2, result.getDifferences().size()); assertEquals(2, result.getChangedCount()); ElnRecordVos.SourceSyncDifference cross = result.getDifferences().get(1); assertEquals("CROSS_TEMPLATE", cross.getSourceType()); assertEquals("old source", cross.getPreviousValue()); assertEquals("new source", cross.getCurrentValue()); assertEquals("source-record", cross.getSourceRecordId()); assertEquals("source-revision", cross.getSourceRevisionId()); assertEquals("source-value", cross.getSourceValueId()); assertEquals("source-version", cross.getSourceFieldVersionId()); assertNotNull(cross.getSourceFetchedAt()); } @Test public void rejectsWholeSyncWhenCrossTemplateSourceChangesAfterPreview() { AtomicReference sourceText = new AtomicReference<>("new source"); Fixture fixture = new Fixture("old basis", "new basis", targetBindingIds -> { ElnRecordFieldValueEntity source = value("source-value", "source-binding", "source-version", "source-definition", "source_field", "MANUAL", sourceText.get()); source.setDianziJiluId("source-record"); source.setXiudingId("source-revision"); return Collections.singletonMap("binding-cross", source); }); fixture.crossValue = value("target-cross", "binding-cross", "version-cross", "definition-cross", "cross_field", "CROSS_TEMPLATE", "old source"); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); sourceText.set("newer source"); ElnRecordForms.SourceSync command = new ElnRecordForms.SourceSync(); command.setExpectedRecordVersion(4); command.setPreviewToken(preview.getPreviewToken()); command.setOperationCode("sync-stale-cross"); try { fixture.service.synchronize("record-1", command); fail("expected DataException"); } catch (DataException e) { assertTrue(e.getMessage().contains("重新预览")); } assertEquals("old basis", fixture.limsValue.getZhiWenben()); assertEquals("old source", fixture.crossValue.getZhiWenben()); assertEquals(0, fixture.casCalls.get()); assertEquals(0, fixture.updateCalls.get()); } @Test public void synchronizesCrossTemplateValueAndRefreshesAllLineage() { ElnRecordFieldValueEntity source = value("source-value", "source-binding", "source-version", "source-definition", "source_field", "MANUAL", "new source"); source.setDianziJiluId("source-record"); source.setXiudingId("source-revision"); Fixture fixture = new Fixture("same basis", "same basis", targetBindingIds -> Collections.singletonMap("binding-cross", source)); fixture.crossValue = value("target-cross", "binding-cross", "version-cross", "definition-cross", "cross_field", "CROSS_TEMPLATE", "old source"); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); ElnRecordForms.SourceSync command = new ElnRecordForms.SourceSync(); command.setExpectedRecordVersion(preview.getRecordVersion()); command.setPreviewToken(preview.getPreviewToken()); command.setOperationCode("sync-1"); ElnRecordVos.SourceSynchronized result = fixture.service.synchronize("record-1", command); assertEquals(1, result.getChangedCount()); assertEquals("new source", fixture.crossValue.getZhiWenben()); assertEquals("source-record", fixture.crossValue.getKuamoLaiyuanJiluId()); assertEquals("source-revision", fixture.crossValue.getKuamoLaiyuanXiudingId()); assertEquals("source-value", fixture.crossValue.getKuamoLaiyuanZhiId()); assertEquals("source-version", fixture.crossValue.getKuamoLaiyuanZiduanBanbenId()); assertNotNull(fixture.crossValue.getKuamoHuoquShijian()); assertEquals(Collections.singleton("binding-cross"), fixture.changedBindings.get()); } @Test public void synchronizesDecimalCrossTemplateValueThroughTheNumericSlot() { ElnRecordFieldValueEntity source = value("source-decimal", "source-binding", "source-version", "source-definition", "source_decimal", "MANUAL", null); source.setDianziJiluId("source-record"); source.setXiudingId("source-revision"); source.setZhiWenben("wrong text slot"); source.setZhiShuzhi(new BigDecimal("12.50")); Fixture fixture = new Fixture("same basis", "same basis", targetBindingIds -> Collections.singletonMap("binding-cross", source)); fixture.crossDataType.set("DECIMAL"); fixture.crossValue = value("target-decimal", "binding-cross", "version-cross", "definition-cross", "cross_decimal", "CROSS_TEMPLATE", "stale text"); fixture.crossValue.setZhiWenben(null); fixture.crossValue.setZhiShuzhi(new BigDecimal("3.00")); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); ElnRecordVos.SourceSyncDifference difference = preview.getDifferences().get(1); assertEquals("DECIMAL", difference.getDataType()); assertEquals("3.00", difference.getPreviousValue()); assertEquals("12.50", difference.getCurrentValue()); ElnRecordForms.SourceSync command = syncCommand(preview); fixture.service.synchronize("record-1", command); assertEquals(new BigDecimal("12.50"), fixture.crossValue.getZhiShuzhi()); assertEquals(null, fixture.crossValue.getZhiWenben()); assertEquals(null, fixture.crossValue.getZhiRiqi()); } @Test public void synchronizesDateCrossTemplateValueThroughTheDateSlot() { Date sourceDate = new Date(1700000000000L); ElnRecordFieldValueEntity source = value("source-date", "source-binding", "source-version", "source-definition", "source_date", "MANUAL", null); source.setDianziJiluId("source-record"); source.setXiudingId("source-revision"); source.setZhiWenben("wrong text slot"); source.setZhiRiqi(sourceDate); Fixture fixture = new Fixture("same basis", "same basis", targetBindingIds -> Collections.singletonMap("binding-cross", source)); fixture.crossDataType.set("DATE"); fixture.crossValue = value("target-date", "binding-cross", "version-cross", "definition-cross", "cross_date", "CROSS_TEMPLATE", "stale text"); fixture.crossValue.setZhiWenben(null); fixture.crossValue.setZhiRiqi(new Date(1600000000000L)); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); ElnRecordVos.SourceSyncDifference difference = preview.getDifferences().get(1); assertEquals("DATE", difference.getDataType()); assertEquals(new SimpleDateFormat("yyyy-MM-dd").format(sourceDate), difference.getCurrentValue()); ElnRecordForms.SourceSync command = syncCommand(preview); fixture.service.synchronize("record-1", command); assertEquals(sourceDate, fixture.crossValue.getZhiRiqi()); assertEquals(null, fixture.crossValue.getZhiWenben()); assertEquals(null, fixture.crossValue.getZhiShuzhi()); } @Test public void synchronizesChangedSnapshotAndRecalculatesAffectedClosure() { Fixture fixture = new Fixture("old basis", "new basis"); ElnRecordSourceSyncService application = fixture.service; ElnRecordVos.SourceSyncPreview preview = application.preview("record-1"); ElnRecordForms.SourceSync command = new ElnRecordForms.SourceSync(); command.setExpectedRecordVersion(4); command.setPreviewToken(preview.getPreviewToken()); command.setOperationCode("sync-1"); ElnRecordVos.SourceSynchronized result = application.synchronize("record-1", command); assertEquals(Integer.valueOf(5), result.getRecordVersion()); assertEquals(1, result.getChangedCount()); assertEquals(1, result.getRecalculatedCount()); assertEquals(Arrays.asList("test_basis", "formula_result"), result.getChangedFieldCodes()); assertEquals("new basis", fixture.limsValue.getZhiWenben()); assertEquals("LIMS_TASK", fixture.limsValue.getZhiLaiyuan()); assertEquals("item-1", fixture.limsValue.getYuanYinyongId()); assertEquals("system user", fixture.systemValue.getZhiWenben()); assertEquals(Collections.singleton("binding-lims"), fixture.changedBindings.get()); assertEquals(1, fixture.casCalls.get()); assertEquals(2, fixture.updateCalls.get()); assertEquals(1, fixture.auditEvents.size()); assertEquals("sync-1", fixture.auditEvents.get(0).getOperationId()); String fieldDiffs = fixture.auditEvents.get(0).getFieldDiffs(); assertTrue(fieldDiffs.contains("\"field\":\"zhi_wenben\"")); assertTrue(fieldDiffs.contains("\"fieldName\":\"检验依据\"")); assertTrue(fieldDiffs.contains("\"oldData\":\"old basis\"")); assertTrue(fieldDiffs.contains("\"newData\":\"new basis\"")); assertTrue(fieldDiffs.contains("\"targetTable\":\"eln_record_field_value\"")); assertTrue(fieldDiffs.contains("\"targetId\":\"value-lims\"")); assertTrue(fieldDiffs.contains("\"elnFieldCode\":\"test_basis\"")); assertTrue(fieldDiffs.contains("\"sourceProperty\":\"test.basis\"")); } @Test public void recordsNoChangeSyncWithoutCasOrFormula() { Fixture fixture = new Fixture("same basis", "same basis"); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); ElnRecordForms.SourceSync command = new ElnRecordForms.SourceSync(); command.setExpectedRecordVersion(4); command.setPreviewToken(preview.getPreviewToken()); command.setOperationCode("sync-2"); ElnRecordVos.SourceSynchronized result = fixture.service.synchronize("record-1", command); assertEquals(Integer.valueOf(4), result.getRecordVersion()); assertEquals(0, result.getChangedCount()); assertFalse(result.getDifferences().get(0).isChanged()); assertEquals(0, fixture.casCalls.get()); assertEquals(0, fixture.updateCalls.get()); assertEquals(0, fixture.formulaCalls.get()); assertEquals(1, fixture.auditEvents.size()); assertEquals("BIZ_ACTION", fixture.auditEvents.get(0).getEventType()); } @Test public void replaysChangedAndUnchangedSyncOperationsWithoutExecutingAgain() { assertOperationReplay("old basis", "new basis", 1); assertOperationReplay("same basis", "same basis", 0); } @Test public void rejectsReusingSyncOperationCodeForAnotherRequest() { MemoryOperationCoordinator operations = new MemoryOperationCoordinator(); Fixture fixture = new Fixture("same basis", "same basis", operations); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); ElnRecordForms.SourceSync first = syncCommand(preview); fixture.service.synchronize("record-1", first); ElnRecordForms.SourceSync changed = syncCommand(preview); changed.setPreviewToken(preview.getPreviewToken() + "-other"); try { fixture.service.synchronize("record-1", changed); fail("同一操作码的不同请求必须冲突"); } catch (DataException expected) { assertTrue(expected.getMessage().contains("操作码已用于其他请求")); } assertEquals(1, fixture.auditEvents.size()); } private static void assertOperationReplay( String previousValue, String currentValue, int expectedCasCalls) { MemoryOperationCoordinator operations = new MemoryOperationCoordinator(); Fixture fixture = new Fixture(previousValue, currentValue, operations); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); ElnRecordForms.SourceSync command = syncCommand(preview); ElnRecordVos.SourceSynchronized first = fixture.service.synchronize("record-1", command); ElnRecordVos.SourceSynchronized replay = fixture.service.synchronize("record-1", command); assertEquals(first.getRecordVersion(), replay.getRecordVersion()); assertEquals(expectedCasCalls, fixture.casCalls.get()); assertEquals(1, fixture.auditEvents.size()); assertEquals(2, operations.beginCalls.get()); assertEquals(1, operations.completeCalls.get()); } @Test public void rejectsConfirmationWhenSnapshotChangedAfterPreview() { Fixture fixture = new Fixture("old basis", "new basis"); ElnRecordVos.SourceSyncPreview preview = fixture.service.preview("record-1"); fixture.currentValue.set("newer basis"); ElnRecordForms.SourceSync command = new ElnRecordForms.SourceSync(); command.setExpectedRecordVersion(4); command.setPreviewToken(preview.getPreviewToken()); command.setOperationCode("sync-stale-preview"); try { fixture.service.synchronize("record-1", command); fail("expected DataException"); } catch (DataException e) { assertTrue(e.getMessage().contains("重新预览")); } assertEquals("old basis", fixture.limsValue.getZhiWenben()); assertEquals(0, fixture.casCalls.get()); assertEquals(0, fixture.updateCalls.get()); assertEquals(0, fixture.formulaCalls.get()); assertEquals(0, fixture.auditEvents.size()); } @Test public void rejectsNonDraftRevisionBeforeReadingProvider() { Fixture fixture = new Fixture("old basis", "new basis"); fixture.revision.setBizStatus("FROZEN"); try { fixture.service.preview("record-1"); fail("expected DataException"); } catch (DataException e) { assertTrue(e.getMessage().contains("不允许重新同步")); } assertEquals(0, fixture.providerCalls.get()); } @Test public void rejectsSyncWhenTestWorkItemIsNoLongerPendingTest() { Fixture fixture = new Fixture("old basis", "new basis"); fixture.taskStatus.set("pending_audit"); try { fixture.service.preview("record-1"); fail("expected DataException"); } catch (DataException e) { assertTrue(e.getMessage().contains("不允许重新同步")); } assertEquals(1, fixture.providerCalls.get()); assertEquals(0, fixture.casCalls.get()); } private static final class Fixture { private final ElnRecordEntity record = record(); private final ElnRecordRevisionEntity revision = revision(); private final ElnRecordFieldValueEntity limsValue; private final ElnRecordFieldValueEntity systemValue; private ElnRecordFieldValueEntity crossValue; private final ElnFieldVersionEntity limsVersion = version("version-lims", "definition-lims", "检验依据", "LIMS_TASK", "test.basis"); private ElnSourceSnapshot configuredSnapshot; private final AtomicInteger providerCalls = new AtomicInteger(); private final AtomicInteger casCalls = new AtomicInteger(); private final AtomicInteger insertCalls = new AtomicInteger(); private final AtomicInteger updateCalls = new AtomicInteger(); private final AtomicInteger formulaCalls = new AtomicInteger(); private final AtomicBoolean includeLimsValue = new AtomicBoolean(true); private final AtomicReference insertedValue = new AtomicReference<>(); private final AtomicReference> requestedProperties = new AtomicReference<>(); private final AtomicReference> changedBindings = new AtomicReference<>(); private final AtomicReference currentValue = new AtomicReference<>(); private final AtomicReference taskStatus = new AtomicReference<>("pending_test"); private final AtomicReference crossDataType = new AtomicReference<>("TEXT"); private final List auditEvents = new ArrayList<>(); private final ElnRecordSourceSyncServiceImpl service; private Fixture(String previousValue, String currentValue) { this(previousValue, currentValue, () -> new ElnRecordSourceSyncServiceImpl.UserContext( "tenant-a", "user-1")); } private Fixture(String previousValue, String currentValue, ElnRecordSourceSyncOperationCoordinator operationCoordinator) { this(previousValue, currentValue, () -> new ElnRecordSourceSyncServiceImpl.UserContext("tenant-a", "user-1"), null, bindingIds -> Collections.emptyMap(), operationCoordinator); } private Fixture(String previousValue, String currentValue, ElnRecordSourceSyncServiceImpl.CurrentUserProvider currentUserProvider) { this(previousValue, currentValue, currentUserProvider, null); } private Fixture(String previousValue, String currentValue, ElnRecordSourceSyncServiceImpl.CurrentUserProvider currentUserProvider, ElnRecordSourceSyncServiceImpl.TenantRoute tenantRoute) { this(previousValue, currentValue, currentUserProvider, tenantRoute, bindingIds -> Collections.emptyMap()); } private Fixture(String previousValue, String currentValue, java.util.function.Function, Map> crossResolver) { this(previousValue, currentValue, () -> new ElnRecordSourceSyncServiceImpl.UserContext("tenant-a", "user-1"), null, crossResolver); } private Fixture(String previousValue, String currentValue, ElnRecordSourceSyncServiceImpl.CurrentUserProvider currentUserProvider, ElnRecordSourceSyncServiceImpl.TenantRoute tenantRoute, java.util.function.Function, Map> crossResolver) { this(previousValue, currentValue, currentUserProvider, tenantRoute, crossResolver, ElnRecordSourceSyncOperationCoordinator.none()); } private Fixture(String previousValue, String currentValue, ElnRecordSourceSyncServiceImpl.CurrentUserProvider currentUserProvider, ElnRecordSourceSyncServiceImpl.TenantRoute tenantRoute, java.util.function.Function, Map> crossResolver, ElnRecordSourceSyncOperationCoordinator operationCoordinator) { this.currentValue.set(currentValue); limsValue = value("value-lims", "binding-lims", "version-lims", "definition-lims", "test_basis", "LIMS_TASK", previousValue); limsValue.setYuanShuxing("test.basis"); limsValue.setYuanYinyongLeixing("LIMS_TASK"); limsValue.setYuanYinyongId("item-1"); systemValue = value("value-system", "binding-system", "version-system", "definition-system", "operator", "SYSTEM", "system user"); ElnFieldVersionEntity systemVersion = version("version-system", "definition-system", "检验人", "SYSTEM", "current_user.real_name"); ElnFieldVersionEntity crossVersion = version("version-cross", "definition-cross", "跨模板字段", null, null); AtomicInteger versionReads = new AtomicInteger(); ElnRecordMapper recordMapper = proxy(ElnRecordMapper.class, (method, args) -> { if ("selectOne".equals(method)) return record; if ("advanceVersion".equals(method)) { assertEquals(4, args[2]); casCalls.incrementAndGet(); return 1; } return null; }); ElnRecordRevisionMapper revisionMapper = proxy( ElnRecordRevisionMapper.class, (method, args) -> "selectOne".equals(method) ? revision : null); ElnTemplateBindingMapper bindingMapper = proxy( ElnTemplateBindingMapper.class, (method, args) -> { if (!"selectList".equals(method)) return null; List bindings = new ArrayList<>(Arrays.asList( binding("binding-lims", "version-lims", "definition-lims", "test_basis"), binding("binding-system", "version-system", "definition-system", "operator"))); if (crossValue != null) { bindings.add(binding("binding-cross", "version-cross", "definition-cross", "cross_field")); } return bindings; }); ElnRecordFieldValueMapper valueMapper = proxy( ElnRecordFieldValueMapper.class, (method, args) -> { if ("selectList".equals(method)) { versionReads.set(0); List values = new ArrayList<>(); if (includeLimsValue.get()) values.add(limsValue); values.add(systemValue); if (crossValue != null) values.add(crossValue); return values; } if ("insert".equals(method)) { insertCalls.incrementAndGet(); insertedValue.set((ElnRecordFieldValueEntity) args[0]); return 1; } if ("updateById".equals(method) || "updateConfiguredSnapshot".equals(method)) { updateCalls.incrementAndGet(); return 1; } return null; }); ElnFieldVersionMapper versionMapper = proxy( ElnFieldVersionMapper.class, (method, args) -> { if (!"selectOne".equals(method)) return null; int index = versionReads.getAndIncrement(); if (!includeLimsValue.get()) { if (index == 0) return systemVersion; return limsVersion; } index %= 3; if (index == 1) return systemVersion; if (index == 2) { crossVersion.setShujuLeixing(crossDataType.get()); return crossVersion; } return limsVersion; }); ElnTestWorkItemSnapshotProvider provider = new ElnTestWorkItemSnapshotProvider() { @Override public List resolveConfiguredAsActor(String tenantId, String userId, List itemIds, List properties) { assertEquals("tenant-a", tenantId); assertEquals("user-1", userId); assertEquals(Collections.singletonList("item-1"), itemIds); assertEquals(Collections.singletonList(new ElnSourcePropertyRef("test.basis", 1)), properties); return Collections.singletonList(configuredSnapshot); } @Override public TestWorkItemSnapshotBatch resolve( List testWorkItemIds, List properties) { throw new AssertionError("explicit actor provider required"); } @Override public TestWorkItemSnapshotBatch resolveAsActor( String tenantId, String userId, List testWorkItemIds, List properties) { assertEquals("tenant-a", tenantId); assertEquals("user-1", userId); assertEquals(Collections.singletonList("item-1"), testWorkItemIds); providerCalls.incrementAndGet(); requestedProperties.set(properties); return snapshot(Fixture.this.currentValue.get(), Fixture.this.taskStatus.get()); } }; ElnRecordFormulaCalculationService calculation = new ElnRecordFormulaCalculationService() { @Override public List recalculateAffected( String tenantId, ElnRecordEntity actualRecord, ElnRecordRevisionEntity actualRevision, List values, Set changedBindingIds, String operationCode, String operatorUserId) { formulaCalls.incrementAndGet(); changedBindings.set(changedBindingIds); assertEquals("sync-1", operationCode); ElnRecordFieldValueEntity result = new ElnRecordFieldValueEntity(); result.setId("formula-value"); result.setZiduanBianma("formula_result"); return Collections.singletonList(result); } @Override public List recalculateAll( String tenantId, ElnRecordEntity record, ElnRecordRevisionEntity revision, List values, String operationCode, String operatorUserId) { return Collections.emptyList(); } }; ElnCrossTemplateSnapshotResolver resolver = new ElnCrossTemplateSnapshotResolver() { @Override public Map resolve(String tenantId, String testWorkItemId, List bindings) { return Collections.emptyMap(); } @Override public Map resolveByBindingIds(String tenantId, String testWorkItemId, Set bindingIds) { return crossResolver.apply(bindingIds); } }; service = tenantRoute == null ? new ElnRecordSourceSyncServiceImpl( recordMapper, revisionMapper, valueMapper, bindingMapper, versionMapper, provider, calculation, auditEvents::add, currentUserProvider, resolver, operationCoordinator) : new ElnRecordSourceSyncServiceImpl( recordMapper, revisionMapper, valueMapper, bindingMapper, versionMapper, provider, calculation, auditEvents::add, currentUserProvider, tenantRoute, null, null, resolver, operationCoordinator); } } private static final class MemoryOperationCoordinator implements ElnRecordSourceSyncOperationCoordinator { private final AtomicInteger beginCalls = new AtomicInteger(); private final AtomicInteger completeCalls = new AtomicInteger(); private String operationCode; private String digest; private ElnRecordVos.SourceSynchronized result; private boolean completed; @Override public ElnRecordVos.SourceSynchronized begin( String tenantId, String userId, String operationCode, String requestDigest) { beginCalls.incrementAndGet(); if (digest == null) { this.operationCode = operationCode; digest = requestDigest; return null; } if (!this.operationCode.equals(operationCode)) return null; if (!digest.equals(requestDigest)) { throw new DataException("来源同步操作码已用于其他请求"); } return result; } @Override public ElnRecordVos.SourceSynchronized complete( String tenantId, String userId, String operationCode, String requestDigest, ElnRecordVos.SourceSynchronized completed) { completeCalls.incrementAndGet(); result = completed; this.completed = true; return completed; } @Override public ElnRecordVos.SourceSynchronized prepare( String tenantId, String userId, String operationCode, String requestDigest, ElnRecordVos.SourceSynchronized prepared) { result = prepared; return prepared; } @Override public ElnRecordVos.SourceSynchronized pending( String tenantId, String userId, String recordId) { return completed || result == null || !recordId.equals(result.getRecordId()) ? null : result; } @Override public boolean confirm(String tenantId, String userId, String operationCode) { if (completed) return false; completed = true; return true; } @Override public boolean hasPendingDocumentConfirmation(String tenantId, String recordId) { return !completed && result != null && recordId.equals(result.getRecordId()); } } private static ElnRecordEntity record() { ElnRecordEntity value = new ElnRecordEntity(); value.setId("record-1"); value.setJiluBianhao("ELN-1"); value.setJianyanXiangId("item-1"); value.setMubanId("template-1"); value.setDangqianXiudingId("revision-1"); value.setFVersion(4); return value; } private static ElnRecordRevisionEntity revision() { ElnRecordRevisionEntity value = new ElnRecordRevisionEntity(); value.setId("revision-1"); value.setDianziJiluId("record-1"); value.setJiluWendang("revision-1.docx"); value.setBizStatus("DRAFT"); return value; } private static ElnRecordFillTicketService.FillTicketContext recordTicket(String fileId) { Map payload = ticketPayload(fileId); payload.put("scope", ElnRecordFillTicketService.SCOPE); payload.put("bizModule", ElnRecordFillTicketService.BIZ_MODULE); payload.put("bizScene", ElnRecordFillTicketService.BIZ_SCENE); return parseTicket(payload); } private static Map ticketPayload(String fileId) { Map payload = new LinkedHashMap<>(); payload.put("tenantId", "tenant-a"); payload.put("userId", "user-1"); payload.put("fileId", fileId); payload.put("bizDataId", "record-1"); payload.put("pluginGuid", "asc.{7F4E8F35-5D66-47F8-A5B4-6AB3FAACF565}"); return payload; } private static ElnRecordFillTicketService.FillTicketContext parseTicket( Map payload) { return new ElnRecordFillTicketService( ticket -> JsonUtil.getObjectToString(payload)).parse("ticket-1"); } private static ElnRecordFieldValueEntity value( String id, String bindingId, String versionId, String definitionId, String fieldCode, String source, String text) { ElnRecordFieldValueEntity value = new ElnRecordFieldValueEntity(); value.setId(id); value.setDianziJiluId("record-1"); value.setXiudingId("revision-1"); value.setMubanId("template-1"); value.setMubanBangdingId(bindingId); value.setZiduanBanbenId(versionId); value.setZiduanDingyiId(definitionId); value.setZiduanBianma(fieldCode); value.setBangdingJuese("PRIMARY"); value.setBangdingZhuangtai("ACTIVE"); value.setZhiLaiyuan(source); value.setZhiWenben(text); return value; } private static ElnTemplateBindingEntity binding( String id, String versionId, String definitionId, String fieldCode) { ElnTemplateBindingEntity binding = new ElnTemplateBindingEntity(); binding.setId(id); binding.setMubanId("template-1"); binding.setZiduanBanbenId(versionId); binding.setZiduanDingyiId(definitionId); binding.setZiduanBianma(fieldCode); binding.setKongjianJuese("PRIMARY"); binding.setBangdingZhuangtai("ACTIVE"); return binding; } private static ElnRecordForms.SourceSync syncCommand(ElnRecordVos.SourceSyncPreview preview) { ElnRecordForms.SourceSync command = new ElnRecordForms.SourceSync(); command.setExpectedRecordVersion(preview.getRecordVersion()); command.setPreviewToken(preview.getPreviewToken()); command.setOperationCode("sync-1"); return command; } private static ElnFieldVersionEntity version( String id, String definitionId, String name, String source, String property) { ElnFieldVersionEntity value = new ElnFieldVersionEntity(); value.setId(id); value.setZiduanDingyiId(definitionId); value.setZiduanMingcheng(name); value.setShujuLeixing("TEXT"); value.setBangdingPeizhi("{\"read\":{\"source\":\"" + source + "\",\"property\":\"" + property + "\"},\"write\":{\"target\":\"ELN_RECORD\"}}"); return value; } private static TestWorkItemSnapshotBatch snapshot(String currentValue, String taskStatus) { TestWorkItemSnapshotProperty property = new TestWorkItemSnapshotProperty(); property.setProperty("test.basis"); property.setDataType("TEXT"); property.setSource("LIMS_TASK"); property.setSourceRef("item-1"); property.setValue(currentValue); property.setOutcomeCode("SUCCESS"); TestWorkItemSnapshot item = new TestWorkItemSnapshot(); item.setTestWorkItemId("item-1"); item.setTaskStatus(taskStatus); item.setOutcomeCode("SUCCESS"); item.setProperties(Collections.singletonList(property)); TestWorkItemSnapshotBatch batch = new TestWorkItemSnapshotBatch(); batch.setItems(Collections.singletonList(item)); return batch; } @SuppressWarnings("unchecked") private static T proxy(Class type, Invocation invocation) { return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class[]{type}, (target, method, args) -> { if (method.getDeclaringClass() == Object.class) { if ("toString".equals(method.getName())) return type.getSimpleName(); if ("hashCode".equals(method.getName())) return System.identityHashCode(target); if ("equals".equals(method.getName())) return target == args[0]; } Object result = invocation.invoke(method.getName(), args); if (result != null || !method.getReturnType().isPrimitive()) return result; if (method.getReturnType() == boolean.class) return false; return 0; }); } @FunctionalInterface private interface Invocation { Object invoke(String method, Object[] args); } }