diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentService.java index 09bf82fba..b0120d7c5 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentService.java @@ -34,6 +34,7 @@ import com.baiyue.firstleg.oceanbooking.entity.OceanBookingOperationEntity; @Service public class OceanBookingDocumentService { public static final String BUSINESS_TYPE = "OCEAN_BOOKING_DOCUMENT"; + private static final long FILE_TENANT_ID = 0L; private static final Set DOCUMENT_TYPES = Set.of("BOOKING_CONFIRMATION", "BILL_OF_LADING"); @Resource private OceanBookingRepository repository; @@ -54,7 +55,7 @@ public class OceanBookingDocumentService { OceanBookingEntity booking = lockDraft(oceanBookingId, expectedVersion); OceanBookingFileClient.Metadata metadata; try { - var response = files.upload(internalToken, operatorId, booking.getCustomerId(), BUSINESS_TYPE, + var response = files.upload(internalToken, operatorId, FILE_TENANT_ID, BUSINESS_TYPE, Long.toString(oceanBookingId), file); if (response == null || !response.successful() || response.data() == null) { throw bad("订舱文件上传失败"); @@ -115,7 +116,7 @@ public class OceanBookingDocumentService { } metadata(booking, document.getFileId()); try { - ResponseEntity response = files.download(internalToken, booking.getCustomerId(), BUSINESS_TYPE, + ResponseEntity response = files.download(internalToken, FILE_TENANT_ID, BUSINESS_TYPE, Long.toString(oceanBookingId), document.getFileId()); byte[] content = response.getBody(); if (!response.getStatusCode().is2xxSuccessful() || content == null || content.length == 0) { @@ -132,9 +133,6 @@ public class OceanBookingDocumentService { } public void requireActiveValidDocument(OceanBookingEntity booking) { - if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) { - throw conflict("订舱缺少客户归属,无法校验文件"); - } boolean valid = false; for (OceanBookingDocumentEntity document : repository.activeDocuments(booking.getId())) { if (!DOCUMENT_TYPES.contains(document.getDocumentType())) continue; @@ -178,9 +176,6 @@ public class OceanBookingDocumentService { if (booking == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(expectedVersion).equals(booking.getVersion())) throw concurrent(); if (!"DRAFT".equals(booking.getStatus())) throw conflict("已放舱订舱文件永久只读"); - if (booking.getCustomerId() == null || booking.getCustomerId() <= 0) { - throw bad("请先保存订舱客户后再上传文件"); - } return booking; } @@ -192,7 +187,7 @@ public class OceanBookingDocumentService { private OceanBookingFileClient.Metadata metadata(OceanBookingEntity booking, long fileId) { try { - var response = files.metadata(internalToken, booking.getCustomerId(), BUSINESS_TYPE, + var response = files.metadata(internalToken, FILE_TENANT_ID, BUSINESS_TYPE, Long.toString(booking.getId()), fileId); if (response == null || !response.successful() || response.data() == null) { throw new BizException(ResultCode.NOT_FOUND, "订舱文件不存在或归属不匹配"); @@ -207,7 +202,7 @@ public class OceanBookingDocumentService { } private static void validateMetadata(OceanBookingEntity booking, OceanBookingFileClient.Metadata metadata) { - if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), booking.getCustomerId()) + if (metadata.fileId() == null || !Objects.equals(metadata.tenantId(), FILE_TENANT_ID) || !BUSINESS_TYPE.equals(metadata.businessType()) || !Long.toString(booking.getId()).equals(metadata.businessId())) { throw new BizException(ResultCode.VALIDATION_FAILED, "订舱文件归属不匹配"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 16f291ba8..a08d56de7 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -50,6 +50,7 @@ public class OceanBookingService { @Resource private WarehousePartnerClient partners; @Resource private FirstlegDocumentNumberGenerator documentNumbers; @Resource private OceanBookingDocumentService documents; + @Resource private com.baiyue.firstleg.service.IamCustomerSnapshotService customers; @Value("${baiyue.security.internal-service-token:}") private String internalToken; @Value("${baiyue.warehouse.public-tenant-id:0}") private long publicWarehouseTenantId; @@ -200,7 +201,7 @@ public class OceanBookingService { private void validateDraftStructure(DraftData data) { boolean hasCustomerId = data.customerId() != null; boolean hasCustomerName = StringUtils.hasText(data.customerName()); - if (hasCustomerId != hasCustomerName) throw badRequest("客户ID和客户名称必须同时填写或同时为空"); + if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交"); if (data.parties() != null) { Set types = new HashSet<>(); for (PartyRequest party : data.parties()) { @@ -221,8 +222,7 @@ public class OceanBookingService { private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } private static void validateRelease(OceanBookingEntity booking, List parties, List requirements) { - if (booking.getCustomerId() == null || !StringUtils.hasText(booking.getCustomerName()) - || !StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null + if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null || !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName()) || !StringUtils.hasText(booking.getVoyageNo()) || !StringUtils.hasText(booking.getServiceTerm()) || !StringUtils.hasText(booking.getFreightTerm()) || booking.getEstimatedSailingAt() == null @@ -258,8 +258,9 @@ public class OceanBookingService { } private record ResolvedDraft(String warehouseName, String channelName, String shippingCompanyName, - String carrierCode, String bookingAgentName) {} + String carrierCode, String bookingAgentName, String customerName) {} private ResolvedDraft resolveDraft(DraftData d) { + String customerName = resolveCustomer(d.customerId(), d.customerName()); String warehouseName = null; String channelName = null; if (d.warehouseId() != null && StringUtils.hasText(d.channelCode())) { WarehouseMasterDataClient.Warehouse warehouse = requireDomesticTransitWarehouse(d.warehouseId()); @@ -269,10 +270,10 @@ public class OceanBookingService { var carrier = resolveCarrier(d.shippingCompanyId(), d.shippingCompanyName(), d.carrierCode()); var agent = resolveBookingAgent(d.bookingAgentId(), d.bookingAgentName(), carrier == null ? null : carrier.carrierCode()); return new ResolvedDraft(warehouseName, channelName, carrier == null ? null : carrier.partnerName(), - carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name()); + carrier == null ? null : carrier.carrierCode(), agent == null ? null : agent.name(), customerName); } private static void copy(OceanBookingEntity v, DraftData d, ResolvedDraft resolved) { - v.setCustomerId(d.customerId()); v.setCustomerName(trim(d.customerName())); + v.setCustomerId(d.customerId()); v.setCustomerName(resolved.customerName()); v.setChannelCode(trim(d.channelCode())); v.setChannelName(resolved.channelName()); v.setWarehouseId(d.warehouseId()); v.setWarehouseName(resolved.warehouseName()); v.setBillOfLadingNo(trim(d.billOfLadingNo())); @@ -288,10 +289,20 @@ public class OceanBookingService { } private void validateMasterData(OceanBookingEntity booking) { + resolveCustomer(booking.getCustomerId(), booking.getCustomerName()); resolveCarrier(booking.getShippingCompanyId(), booking.getShippingCompanyName(), booking.getCarrierCode()); resolveBookingAgent(booking.getBookingAgentId(), booking.getBookingAgentName(), booking.getCarrierCode()); } + private String resolveCustomer(Long id, String name) { + if (id == null) { + if (StringUtils.hasText(name)) throw badRequest("客户名称不能脱离客户ID提交"); + return null; + } + if (id <= 0) throw badRequest("请选择有效客户"); + return customers.active(id, name).tenantName(); + } + private WarehousePartnerClient.Snapshot resolveCarrier(Long id, String suppliedName, String suppliedCode) { if (id == null) { if (StringUtils.hasText(suppliedName) || StringUtils.hasText(suppliedCode)) throw badRequest("船公司名称和编码不能脱离shippingCompanyId提交"); diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 41712f548..babc43d70 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -32,7 +32,7 @@ paths: /api/platform/v1/ocean-bookings/{oceanBookingId}/release: post: summary: Validate and permanently release a DRAFT - description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. It never requires or creates a real container number. + description: Release validates the persisted aggregate plus at least one active booking-owned BOOKING_CONFIRMATION or BILL_OF_LADING file. The customer is optional; a supplied customer must remain ACTIVE. Null denotes no single customer snapshot, never public access. It never requires or creates a real container number. parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ReleaseRequest'}}}} responses: {'200': {description: RELEASED detail}} @@ -45,7 +45,7 @@ paths: '200': {description: Booking document history, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentListResponse'}}}} post: summary: Reference an existing File object in a DRAFT booking - description: File scope must be customerId + OCEAN_BOOKING_DOCUMENT + oceanBookingId. The booking version is incremented atomically. + description: File scope must be platform tenantId=0 + OCEAN_BOOKING_DOCUMENT + oceanBookingId, independent of the optional customer snapshot. The booking version is incremented atomically. x-permission: platform:firstleg:ocean-booking:edit parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/DocumentRequest'}}}} @@ -162,8 +162,8 @@ components: type: object description: Fields may be incomplete in DRAFT. RELEASE validates the complete persisted aggregate. properties: - customerId: {type: integer, format: int64, nullable: true} - customerName: {type: string, nullable: true} + customerId: {type: integer, format: int64, minimum: 1, nullable: true, description: 可选启用客户ID;多客户订舱留空,空值不是公开访问授权} + customerName: {type: string, nullable: true, description: 可选名称提示;有客户ID时由服务端校验并保存客户主数据名称,清空客户时同时置null} channelCode: {type: string, nullable: true} warehouseId: {type: integer, format: int64, nullable: true} billOfLadingNo: {type: string, nullable: true} @@ -279,7 +279,7 @@ components: requiredQuantity: {type: integer, minimum: 1} allocatedQuantity: {type: integer, format: int64, minimum: 0} remainingQuantity: {type: integer, format: int64, minimum: 1} - customerName: {type: string} + customerName: {type: string, nullable: true} warehouseName: {type: string} channelName: {type: string} LinkCandidatePage: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 9875460e9..622b9844c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -39,6 +39,8 @@ class OceanBookingMapperContractTest { .flatMap(annotation -> Arrays.stream(annotation.value())) .reduce("", (left, right) -> left + '\n' + right); assertThat(sql) + .contains("customer_id=#{value.customerId},customer_name=#{value.customerName}") + .doesNotContain("customer_id=COALESCE", "customer_name=COALESCE") .contains("status=#{fromStatus} AND version=#{expectedVersion}") .contains("status='DRAFT' AND version=#{version}") .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java index aa534a998..373c3652c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingDocumentServiceTest.java @@ -40,11 +40,12 @@ class OceanBookingDocumentServiceTest { @Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception { OceanBookingEntity booking = draft(2); + booking.setCustomerId(null); MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes()); when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString())) .thenReturn(true, false); when(repository.findForUpdate(10L)).thenReturn(booking); - when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", file)) + when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", file)) .thenReturn(ApiResponse.success(metadata(91L))); when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1); when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString())) @@ -75,9 +76,9 @@ class OceanBookingDocumentServiceTest { when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString())) .thenReturn(true); when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1)); - when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation)) + when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation)) .thenReturn(ApiResponse.success(metadata(91L))); - when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill)) + when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", bill)) .thenReturn(ApiResponse.success(metadata(92L))); when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1); when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString())) @@ -101,7 +102,7 @@ class OceanBookingDocumentServiceTest { @Test void attachRejectsCrossScopeFileBeforePersisting() { when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); when(repository.findForUpdate(10L)).thenReturn(draft(1)); - when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L)) + when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L)) .thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L, "OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L))); @@ -144,12 +145,12 @@ class OceanBookingDocumentServiceTest { document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L); document.setDocumentType("BILL_OF_LADING"); document.setValid(true); when(repository.activeDocuments(10L)).thenReturn(List.of(document)); - when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L)) + when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L)) .thenReturn(ApiResponse.success(metadata(91L))); service.requireActiveValidDocument(booking); - verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); + verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); } @Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() { @@ -161,8 +162,8 @@ class OceanBookingDocumentServiceTest { assertThatThrownBy(() -> service.requireActiveValidDocument(booking)) .isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件"); - verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); - verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L); + verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); + verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L); verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString()); } @@ -173,13 +174,48 @@ class OceanBookingDocumentServiceTest { return document; } + @Test void changingOrClearingCustomerKeepsPlatformFileDownloadAndReleaseScope() { + OceanBookingEntity booking = draft(3); + OceanBookingDocumentEntity document = activeDocument(20L, 91L, "BOOKING_CONFIRMATION"); + document.setFileName("booking.pdf"); + when(repository.find(10L)).thenReturn(booking); + when(repository.document(10L, 20L)).thenReturn(document); + when(repository.activeDocuments(10L)).thenReturn(List.of(document)); + when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L)) + .thenReturn(ApiResponse.success(metadata(91L))); + when(files.download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L)) + .thenReturn(org.springframework.http.ResponseEntity.ok(new byte[]{1, 2, 3})); + for (Long customer : java.util.Arrays.asList(9L, 19L, null)) { + booking.setCustomerId(customer); + service.requireActiveValidDocument(booking); + assertThat(service.download(10L, 20L).content()).containsExactly(1, 2, 3); + } + verify(files, times(6)).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); + verify(files, times(3)).download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L); + } + + @Test void platformFileFromAnotherBookingIsRejected() { + when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true); + OceanBookingEntity booking = draft(1); + booking.setCustomerId(null); + when(repository.findForUpdate(10L)).thenReturn(booking); + when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L)) + .thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 0L, + "OCEAN_BOOKING_DOCUMENT", "99", "x.pdf", "hash", "application/pdf", 3L))); + assertThatThrownBy(() -> service.attach(10L, + new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"), + "other-booking", 7L, "admin")) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + verify(repository, never()).insert(any(OceanBookingDocumentEntity.class)); + } + private static OceanBookingEntity draft(int version) { OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version); return booking; } private static OceanBookingFileClient.Metadata metadata(long fileId) { - return new OceanBookingFileClient.Metadata(fileId, 9L, "OCEAN_BOOKING_DOCUMENT", "10", + return new OceanBookingFileClient.Metadata(fileId, 0L, "OCEAN_BOOKING_DOCUMENT", "10", "booking.pdf", "hash", "application/pdf", 3L); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index ee9c1dd6d..ab6b3acd1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -40,6 +40,7 @@ class OceanBookingServiceTest { @Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners; @Mock private FirstlegDocumentNumberGenerator documentNumbers; @Mock private OceanBookingDocumentService documents; + @Mock private com.baiyue.firstleg.service.IamCustomerSnapshotService customers; @InjectMocks private OceanBookingService service; private ObjectMapper objectMapper; @@ -47,6 +48,8 @@ class OceanBookingServiceTest { void setUp() throws Exception { MockitoAnnotations.openMocks(this); objectMapper = new ObjectMapper().findAndRegisterModules(); + lenient().when(customers.active(eq(100L), any())).thenReturn( + new com.baiyue.firstleg.client.IamCustomerSnapshotClient.Customer(100L, "C100", "客户", "ACTIVE", null, null)); lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001"); var field = OceanBookingService.class.getDeclaredField("objectMapper"); field.setAccessible(true); @@ -76,6 +79,70 @@ class OceanBookingServiceTest { verifyNoInteractions(priceBooks, suppliers, warehouses); } + @Test + void customerlessCreateSaveAndReleaseRetainsOtherRequirements() throws Exception { + saveAndReleaseWithoutCustomer(false); + } + + @Test + void clearingExistingCustomerPersistsNullAndAllowsRelease() throws Exception { + saveAndReleaseWithoutCustomer(true); + } + + private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception { + stubMasterData(); + when(suppliers.booking(81L, "订舱代理")).thenReturn( + new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON"))); + var tree = objectMapper.valueToTree(completeDraft()); + ((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName"); + DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class); + AtomicReference stored = stubCreatedDraft(); + var created = service.create(9L, "admin", "optional-create", + new CreateRequest(0, initialCustomer ? completeDraft() : emptyCustomer)); + long id = created.oceanBooking().getId(); + when(repository.updateDraft(any(), eq(0), eq(9L), eq("admin"))).thenAnswer(invocation -> { + OceanBookingEntity value = invocation.getArgument(0); + assertThat(value.getCustomerId()).isNull(); + assertThat(value.getCustomerName()).isNull(); + value.setVersion(1); + return 1; + }); + service.updateDraft(id, 9L, "admin", "optional-save", new UpdateRequest(0, emptyCustomer)); + when(repository.parties(id)).thenReturn(parties()); + when(repository.requirements(id)).thenReturn(List.of(requirement())); + when(repository.transition(id, "DRAFT", "RELEASED", 1, 9L, "admin")).thenAnswer(invocation -> { + stored.get().setStatus("RELEASED"); stored.get().setVersion(2); return 1; + }); + + var result = service.release(id, 9L, "admin", "optional-release", new ReleaseRequest(1, "确认")); + assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED"); + assertThat(result.oceanBooking().getCustomerId()).isNull(); + assertThat(result.oceanBooking().getCustomerName()).isNull(); + verify(documents).requireActiveValidDocument(stored.get()); + verify(customers, times(initialCustomer ? 1 : 0)).active(anyLong(), any()); + } + + @Test + void invalidSpecifiedCustomerIsRejectedBeforeCreatingDraft() { + when(customers.active(100L, "客户")).thenThrow(new BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用")); + assertThatThrownBy(() -> service.create(9L, "admin", "bad-customer", new CreateRequest(0, completeDraft()))) + .isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用"); + verify(repository, never()).insert(any(OceanBookingEntity.class)); + } + + @Test + void releaseRevalidatesSpecifiedCustomer() { + when(repository.find(1L)).thenReturn(completeBooking("DRAFT", 3)); + when(repository.parties(1L)).thenReturn(parties()); + when(repository.requirements(1L)).thenReturn(List.of(requirement())); + when(customers.active(100L, "客户")).thenThrow(new BizException( + com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用")); + assertThatThrownBy(() -> service.release(1L, 9L, "admin", "inactive-customer", new ReleaseRequest(3, null))) + .isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用"); + verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString()); + } + @Test void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() { stubMasterData(); @@ -86,6 +153,9 @@ class OceanBookingServiceTest { assertThat(inserted.get().getChannelName()).isEqualTo("美西海运"); assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓"); assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理"); + assertThat(inserted.get().getCustomerId()).isEqualTo(100L); + assertThat(inserted.get().getCustomerName()).isEqualTo("客户"); + verify(customers).active(100L, "客户"); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());