fix(firstleg): filter truck booking candidates

This commit is contained in:
2026-09-01 15:41:36 +08:00
parent 8b2a964bfb
commit df1ffbc6bc
9 changed files with 120 additions and 7 deletions
@@ -18,7 +18,7 @@ import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","candidates","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
@@ -31,12 +31,13 @@ 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 openApiPublishesCreateOnlyAuthoritativeCandidateContract() 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 flat booking-container candidates","bookingDocumentReady","A missing containerNo remains eligible","Completed/LOADED containers, occupied containers","Edit pages must display their current binding from appointment detail");}
@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() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("candidates","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
Map.entry("history","platform:firstleg:truck-appointment:list"), Map.entry("attachments","platform:firstleg:truck-appointment:list"),
Map.entry("attach","platform:firstleg:truck-appointment:edit"), Map.entry("upload","platform:firstleg:truck-appointment:edit"),
Map.entry("deleteAttachment","platform:firstleg:truck-appointment:edit"), Map.entry("create","platform:firstleg:truck-appointment:create"),
@@ -16,6 +16,13 @@ class TruckAppointmentMapperContractTest {
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}",
"fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count");
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("o.status='RELEASED'", "fl_ocean_booking_container_allocation",
"fl_ocean_booking_container_requirement", "c.record_status='ACTIVE'", "d.valid=1",
"'BOOKING_CONFIRMATION','BILL_OF_LADING'", "p.plan_status&lt;&gt;'COMPLETED'",
"t.status NOT IN ('COMPLETED','CANCELLED','EXCEPTION')", "containerBookingId",
"bookingDocumentReady");
assertThat(candidates).doesNotContain("c.container_no IS NOT NULL", "currentAppointmentId", "currentContainerBookingId");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
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];
@@ -1,9 +1,12 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.PageResult;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.AttachmentRequest;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.Candidate;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.CandidateQuery;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
@@ -91,6 +94,18 @@ class TruckAppointmentServiceTest {
assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567");
}
@Test void createCandidatesDelegateOneBoundedAuthoritativeQuery() {
Candidate candidate = new Candidate(99L, "OB-1", "BL-1", 18L, "义乌仓", "SEA-US", "美西",
70L, "45HC", 1, 88L, "CAB-1", null, 77L, "LP-1", "EDITING", true);
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));
assertThat(result.records()).containsExactly(candidate);
verify(repository, times(1)).candidates(any(CandidateQuery.class));
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}
@Test void createRequiresZeroVersionAndInitialisesDraft() {
SaveRequest invalid = request(1);
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
@@ -384,6 +399,19 @@ class TruckAppointmentServiceTest {
verify(repository, never()).insert(any());
}
@Test void createRejectsCompletedLoadingContainerEvenWhenAppointmentIsTerminal() {
SaveRequest request = validRequest();
LoadingPlanEntity completed = new LoadingPlanEntity(); completed.setId(77L);
completed.setContainerBookingId(88L); completed.setPlanStatus("COMPLETED");
when(loadingPlans.planByBooking(88L)).thenReturn(completed);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已装柜完成");
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
}
@Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);