fix(firstleg): remove booking document prerequisites from release and trucking

This commit is contained in:
2026-10-23 10:56:37 +08:00
parent 3616e9db04
commit c38f1758c5
11 changed files with 50 additions and 57 deletions
@@ -30,7 +30,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","database booking-level unique key");}
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","bookingDocumentReady","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
@Test void openApiPublishesBookingLevelCandidateContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/candidates:","Paged booking candidates","Container allocation and loading status do not affect eligibility").doesNotContain("requirementId:","containerBookingId:","loadingPlanId:");}
@Test void openApiRequiresReleasedBookingWithoutContainerWriteProtocol() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","PICKUP does not accept or write a container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}").doesNotContain("containerBookingId:","loadingPlanId:","containerNo:","customerId", "customerName");}
@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:","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]","default: RMB").doesNotContain("receivableAmount");}
@@ -118,7 +118,7 @@ class OceanBookingServiceTest {
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
assertThat(result.oceanBooking().getCustomerId()).isNull();
assertThat(result.oceanBooking().getCustomerName()).isNull();
verify(documents).requireActiveValidDocument(stored.get());
verifyNoInteractions(documents);
verify(customers, times(initialCustomer ? 1 : 0)).active(anyLong(), any());
}
@@ -188,21 +188,22 @@ class OceanBookingServiceTest {
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
/** 无订舱附件时仍可按完整业务资料放舱。 */
@Test
void rejectsReleaseWithoutActiveBookingOwnedFileWithoutStateDrift() {
void releasesWithoutBookingFiles() {
stubMasterData();
OceanBookingEntity draft = completeBooking("DRAFT", 3);
when(repository.find(1L)).thenReturn(draft);
when(repository.find(1L)).thenReturn(draft, completeBooking("RELEASED", 4));
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(documents).requireActiveValidDocument(draft);
when(repository.documents(1L)).thenReturn(List.of());
when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1);
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "release-no-file",
new ReleaseRequest(3, "确认")))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
Detail result = service.release(1L, 9L, "admin", "release-no-file", new ReleaseRequest(3, "确认"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
assertThat(result.documents()).isEmpty();
verifyNoInteractions(documents);
verify(repository).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin");
}
@Test
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentMapperContractTest {
/** 候选不依赖附件,仍保留提单号、状态、唯一预约和乐观锁条件。 */
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
@@ -18,9 +19,10 @@ class TruckAppointmentMapperContractTest {
.doesNotContain("fl_container_booking", "cabinet_no", "container_no");
assertThat(page).doesNotContain("customer_id", "customerId", "customer_name");
String candidates = TruckAppointmentMapper.class.getMethod("candidates", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery.class).getAnnotation(Select.class).value()[0];
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'", "d.valid=1",
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "t.ocean_booking_id=o.id",
"t.status NOT IN ('CANCELLED','EXCEPTION')", "bookingDocumentReady");
assertThat(candidates).contains("FROM fl_ocean_booking o", "o.status='RELEASED'",
"o.bill_of_lading_no IS NOT NULL", "o.bill_of_lading_no<>''", "t.ocean_booking_id=o.id",
"t.status NOT IN ('CANCELLED','EXCEPTION')");
assertThat(candidates).doesNotContain("fl_ocean_booking_document", "bookingDocumentReady");
assertThat(candidates).doesNotContain("fl_ocean_booking_container_allocation", "fl_container_booking",
"loadingPlanId", "containerBookingId", "cabinetNo", "containerNo");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
@@ -84,7 +84,7 @@ class TruckAppointmentServiceTest {
}
@Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() {
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西", true);
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西");
when(repository.candidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1));
PageResult<Candidate> result = service.candidates(new CandidateQuery(" OB-1 ", 1, 20));
@@ -170,7 +170,8 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createFreezesBookingBillAndRequiresBookingOwnedDocument() {
/** 无附件订舱可创建预约,提单号仍取订舱冻结值。 */
@Test void createFreezesBookingBillWithoutRequiringBookingDocument() {
SaveRequest request = validRequest();
service.create(7L, "admin", request);
@@ -178,7 +179,7 @@ class TruckAppointmentServiceTest {
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
verify(oceanBookingDocuments).requireActiveValidDocument(any(OceanBookingEntity.class));
verifyNoInteractions(oceanBookingDocuments);
verifyNoInteractions(files);
}
@@ -307,14 +308,14 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createStillRequiresBookingDocumentWithoutAnyContainerAssociation() {
/** 没有附件或货柜关联时,完整已放舱订舱仍可预约。 */
@Test void createAllowsBookingWithoutDocumentsOrContainerAssociation() {
SaveRequest request = validRequest();
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,
"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).insert(any());
TruckAppointmentEntity result = service.create(7L, "admin", request);
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
assertThat(result.getContainerBookingId()).isNull();
verify(repository).insert(any());
verifyNoInteractions(oceanBookingDocuments);
}
@Test void editCanSwitchToAnotherUnoccupiedReleasedBooking() {
@@ -407,7 +408,8 @@ class TruckAppointmentServiceTest {
verify(repository, never()).history(any());
}
@Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() {
/** 取消附件要求后,派车仍必须具备提单号。 */
@Test void dispatchRequiresBookingBill() {
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingBill.setOceanBookingId(99L); missingBill.setContainerBookingId(88L);
OceanBookingEntity bookingWithoutBill = releasedBooking(); bookingWithoutBill.setBillOfLadingNo(null);
@@ -419,21 +421,11 @@ class TruckAppointmentServiceTest {
"dispatch-no-bill", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("提单号");
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingAttachment.setOceanBookingId(99L); missingAttachment.setContainerBookingId(88L); missingAttachment.setBillOfLadingNo("BL-BOOKING-1");
when(oceanBookings.find(99L)).thenReturn(releasedBooking());
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingAttachment);
doThrow(new BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,"订舱缺少有效订舱确认或提单文件"))
.when(oceanBookingDocuments).requireActiveValidDocument(any());
assertThatThrownBy(() -> service.action(1L, "DISPATCH", new ActionRequest(1, null),
"dispatch-no-file", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
verify(repository, never()).dispatch(anyLong(), anyString(), anyString(), anyInt());
}
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringRealContainerNumber() {
/** 无附件、无真实柜号时可派车,但仍冻结已放舱订舱的提单号并记历史。 */
@Test void dispatchFreezesReleasedBookingBillWithoutRequiringDocumentsOrRealContainerNumber() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
@@ -452,6 +444,7 @@ class TruckAppointmentServiceTest {
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.DISPATCHED);
assertThat(result.getBillOfLadingNo()).isEqualTo("BL-BOOKING-1");
verify(repository).dispatch(1L, "PENDING_DISPATCH", "BL-BOOKING-1", 1);
verifyNoInteractions(oceanBookingDocuments);
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}