feat(firstleg): capture container number at pickup

This commit is contained in:
2026-09-01 15:18:18 +08:00
parent 1e8f504f15
commit 4d7ea28a61
8 changed files with 202 additions and 29 deletions
@@ -31,7 +31,7 @@ class TruckAppointmentControllerContractTest {
}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");}
@Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
@Test void openApiRequiresReleasedBookingAndAllocatedInternalContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","allocated ACTIVE internal container identity","real ISO 6346 container number is not required for create, edit, submit or dispatch","PICKUP may omit it and still advances normally","Omitting it never blocks the transition to PICKUP_IN_PROGRESS","must use the preload correction flow","Other actions reject containerNo","OCEAN_BOOKING_DOCUMENT","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
@Test void openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
@@ -20,6 +20,9 @@ class TruckAppointmentMapperContractTest {
assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1");
String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(dispatch).contains("status='DISPATCHED'", "bill_of_lading_no=#{billOfLadingNo}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1");
String pickupContainer = TruckAppointmentMapper.class.getMethod("bindContainerNoForPickup", long.class, String.class, long.class, String.class).getAnnotation(Update.class).value()[0];
assertThat(pickupContainer).contains("UPDATE fl_container_booking", "container_no=#{containerNo}",
"id=#{containerBookingId}", "record_status='ACTIVE'", "container_no IS NULL", "version=version+1");
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
@@ -25,6 +25,7 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.InOrder;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
@@ -272,15 +273,21 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createRejectsContainerWithoutRealIsoNumberAndMissingBookingDocument() {
@Test void createAcceptsAllocatedInternalContainerWithoutRealNumber() {
SaveRequest request = validRequest();
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
when(containers.find(88L)).thenReturn(missingNo);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("集装箱号");
verify(repository, never()).insert(any());
when(containers.find(88L)).thenReturn(realContainer());
service.create(7L, "admin", request);
verify(repository).insert(any(TruckAppointmentEntity.class));
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
}
@Test void createStillRequiresBookingDocumentBeforeRealNumberIsAssigned() {
SaveRequest request = validRequest();
ContainerBookingEntity missingNo = realContainer(); missingNo.setContainerNo(null);
when(containers.find(88L)).thenReturn(missingNo);
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
@@ -385,7 +392,7 @@ class TruckAppointmentServiceTest {
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(1);
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin");
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
assertThat(result.getVersion()).isEqualTo(1);
@@ -399,12 +406,12 @@ class TruckAppointmentServiceTest {
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
operation.setAppointmentId(1L); operation.setAction("SUBMIT"); operation.setIdempotencyKey("idem-1");
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交"));
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交", null));
operation.setRequestHash(hash); operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), eq(hash))).thenReturn(false);
when(repository.operation("idem-1")).thenReturn(operation);
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin");
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交", null), "idem-1", hash, 7L, "admin");
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
@@ -412,7 +419,7 @@ class TruckAppointmentServiceTest {
}
@Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception {
var request = new ActionRequest(0, "并发提交");
var request = new ActionRequest(0, "并发提交", null);
String hash = requestHash("SUBMIT", 1L, request);
TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity();
processing.setAppointmentId(1L); processing.setAction("SUBMIT");
@@ -437,7 +444,7 @@ class TruckAppointmentServiceTest {
.thenReturn(false);
when(repository.operation("reused-key")).thenReturn(existing);
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"),
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A", null),
"reused-key", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
@@ -453,7 +460,7 @@ class TruckAppointmentServiceTest {
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-bill"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingBill);
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
"dispatch-no-bill", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
@@ -465,26 +472,28 @@ class TruckAppointmentServiceTest {
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
}
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringRealContainerNumber() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1");
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1");
ContainerBookingEntity internalContainer = realContainer(); internalContainer.setContainerNo(null);
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(before, after);
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(internalContainer);
when(repository.dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null),
TruckAppointmentEntity result = service.action(1L, "DISPATCH", new ActionRequest(1, null, null),
"dispatch-ok", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
@@ -494,8 +503,168 @@ class TruckAppointmentServiceTest {
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void pickupWithoutContainerNumberStillAdvancesWithoutBinding() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-without-number"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "PICKUP", new ActionRequest(2, "提柜", null),
"pickup-without-number", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void pickupRejectsSuppliedInvalidContainerNumberWithoutDrift() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-invalid"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
assertThatThrownBy(() -> service.action(1L, "PICKUP", new ActionRequest(2, "提柜", "MSCU6639871"),
"pickup-invalid", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("ISO 6346");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pickupNormalizesAndAtomicallyBindsContainerNumberBeforeTransition() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false);
when(repository.bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin")).thenReturn(1);
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-bind"), anyString())).thenReturn(1);
TruckAppointmentEntity result = service.action(1L, "PICKUP",
new ActionRequest(2, "司机已提柜", " ms cu 663987 0 "), "pickup-bind", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
InOrder writes = inOrder(repository);
writes.verify(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin");
writes.verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
writes.verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void pickupReusesExistingValidContainerNumberWithoutRewritingIt() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
after.setOceanBookingId(99L); after.setContainerBookingId(88L);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(realContainer());
when(repository.transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("PICKUP"), eq("pickup-reuse"), anyString())).thenReturn(1);
service.action(1L, "PICKUP", new ActionRequest(2, "复用已有柜号", null),
"pickup-reuse", null, 7L, "admin");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository).transition(1L, "DISPATCHED", "PICKUP_IN_PROGRESS", 2);
}
@Test void pickupIdempotentReplayDoesNotLockOrRewriteContainerAndChangedNumberConflicts() throws Exception {
ActionRequest request = new ActionRequest(2, "提柜", "MSCU6639870");
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PICKUP_IN_PROGRESS, 3);
completed.setOceanBookingId(99L); completed.setContainerBookingId(88L);
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
operation.setAppointmentId(1L); operation.setAction("PICKUP"); operation.setIdempotencyKey("pickup-replay");
operation.setRequestHash(requestHash("PICKUP", 1L, request));
operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-replay"), anyString()))
.thenReturn(false);
when(repository.operation("pickup-replay")).thenReturn(operation);
TruckAppointmentEntity replay = service.action(1L, "PICKUP", request,
"pickup-replay", operation.getRequestHash(), 7L, "admin");
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PICKUP_IN_PROGRESS);
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "提柜", "TSTU1503306"), "pickup-replay", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(containers, never()).findActiveForUpdate(anyLong());
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
}
@Test void pickupRejectsMismatchedOrDuplicateContainerNumberBeforeStatusDrift() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), anyString(), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(realContainer());
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "错误柜号", "TSTU1503306"), "pickup-mismatch", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不一致").hasMessageContaining("预装纠错");
ContainerBookingEntity unnumbered = realContainer(); unnumbered.setContainerNo(null);
when(containers.findActiveForUpdate(88L)).thenReturn(unnumbered);
when(containers.containerNoExists("TSTU1503306", 88L)).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "重复柜号", "TSTU1503306"), "pickup-duplicate", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void concurrentPickupContainerNumberCollisionRollsBackBeforeStatusTransition() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
before.setOceanBookingId(99L); before.setContainerBookingId(88L);
ContainerBookingEntity container = realContainer(); container.setContainerNo(null);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("PICKUP"), eq("pickup-concurrent"), anyString())).thenReturn(true);
when(containers.findActiveForUpdate(88L)).thenReturn(container);
when(containers.containerNoExists("MSCU6639870", 88L)).thenReturn(false);
doThrow(new DuplicateKeyException("uk_container_booking_container_no"))
.when(repository).bindContainerNoForPickup(88L, "MSCU6639870", 7L, "admin");
assertThatThrownBy(() -> service.action(1L, "PICKUP",
new ActionRequest(2, "并发提柜", "MSCU6639870"), "pickup-concurrent", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("其他柜次使用");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
verify(repository, never()).completeOperation(anyLong(), anyString(), anyString(), anyString());
}
@Test void nonPickupActionsRejectContainerNumberWithoutWritingIt() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0);
when(repository.find(1L)).thenReturn(before);
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("submit-with-container"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "SUBMIT",
new ActionRequest(0, "错误携带柜号", "MSCU6639870"), "submit-with-container", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("仅PICKUP动作");
verify(repository, never()).bindContainerNoForPickup(anyLong(), anyString(), anyLong(), anyString());
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void actionRejectsMissingKeyAndMismatchedClientHash() {
var request = new ActionRequest(0, "提交");
var request = new ActionRequest(0, "提交", null);
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("Idempotency-Key");
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "idem-1", "wrong", 7L, "admin"))
@@ -507,11 +676,11 @@ class TruckAppointmentServiceTest {
TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0);
when(repository.find(1L)).thenReturn(e);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-action"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), "idem-action", null, 7L, "admin"))
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null, null), "idem-action", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("专用接口");
e.setStatus(TruckAppointmentStatus.COMPLETED);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin"))
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约", null), "idem-reschedule", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不可改约");
verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt());
}
@@ -524,7 +693,7 @@ class TruckAppointmentServiceTest {
when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(1);
var at = LocalDateTime.of(2026, 8, 12, 10, 0);
var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin");
var result = service.reschedule(1L, at, new ActionRequest(2, "改约", null), "idem-reschedule", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED);
assertThat(result.getVersion()).isEqualTo(3);
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);