From df1ffbc6bc8d8315c50a7831fdf69a087d738177 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 1 Sep 2026 15:41:36 +0800 Subject: [PATCH] fix(firstleg): filter truck booking candidates --- .../PlatformTruckAppointmentController.java | 1 + .../firstleg/dto/TruckAppointmentDtos.java | 2 + .../mapper/TruckAppointmentMapper.java | 27 +++++++++- .../TruckAppointmentRepository.java | 4 +- .../service/TruckAppointmentService.java | 4 +- .../openapi/truck-appointments-v1.yaml | 49 +++++++++++++++++++ ...ruckAppointmentControllerContractTest.java | 5 +- .../TruckAppointmentMapperContractTest.java | 7 +++ .../service/TruckAppointmentServiceTest.java | 28 +++++++++++ 9 files changed, 120 insertions(+), 7 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java index 7e8df28e7..c4b2abf2d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformTruckAppointmentController.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.*; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import com.baiyue.firstleg.service.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Positive; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.time.LocalDateTime; @Tag(name="拖车预约") @Validated @RestController @RequestMapping("/api/platform/v1/truck-appointments") public class PlatformTruckAppointmentController { @Resource TruckAppointmentService service; @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser u,@Valid Query q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.page(q));} + @GetMapping("/candidates") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser u,@Valid CandidateQuery q){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.candidates(q));} @GetMapping("/{id}") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.detail(id));} @GetMapping("/{id}/history") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> history(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.history(id));} @GetMapping("/{id}/attachments") @PreAuthorize("hasAuthority('platform:firstleg:truck-appointment:list')") public ApiResponse> attachments(@AuthenticationPrincipal AuthenticatedUser u,@PathVariable @Positive long id){FirstlegScopeGuard.platform(u);return ApiResponse.success(service.attachments(id));} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java index 62782b7ba..75c569f10 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/TruckAppointmentDtos.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.dto; import jakarta.validation.constraints.*; import java.math.BigDecimal; import java.time.LocalDateTime; public final class TruckAppointmentDtos { private TruckAppointmentDtos(){} public record Query(@Size(max=64) String keyword, com.baiyue.firstleg.domain.TruckAppointmentStatus status, @Positive Long containerBookingId, @Min(1) long page, @Min(1) @Max(200) long size){ public Query{if(page==0)page=1;if(size==0)size=20;} } + public record CandidateQuery(@Size(max=64) String keyword,@Min(1) long page,@Min(1) @Max(200) long size){public CandidateQuery{if(page==0)page=1;if(size==0)size=20;}} + public record Candidate(Long oceanBookingId,String oceanBookingNo,String billOfLadingNo,Long warehouseId,String warehouseName,String channelCode,String channelName,Long requirementId,String containerType,Integer requirementQuantity,Long containerBookingId,String cabinetNo,String containerNo,Long loadingPlanId,String loadingPlanNo,String loadingPlanStatus,Boolean bookingDocumentReady){} public record SaveRequest(@Positive Long outboundOrderId,@NotNull @Positive Long oceanBookingId,@NotNull @Positive Long containerBookingId,@Positive Long loadingPlanId,@Positive Long warehouseId,@NotNull @Positive Long carrierId,@Size(max=128) String carrierName,@Size(max=128) String billOfLadingNo,@Size(max=64) String fleetContact,@Size(max=32) String fleetPhone,@Size(max=64) String driverName,@Size(max=32) String driverPhone,@Size(max=32) String driverIdCard,@Size(max=32) String vehicleNo,@Size(max=128) String storeName,@Size(max=64) String storeContact,@Size(max=32) String storePhone,@Size(max=255) String storeAddress,@Size(max=64) String storeCity,@Size(max=16) String storeZipcode,@PositiveOrZero Integer packageCount,@DecimalMin("0") BigDecimal grossWeightKg,@DecimalMin("0") BigDecimal volumeCbm,@PositiveOrZero Integer palletCount,@Size(max=255) String poNo,@NotNull LocalDateTime plannedLoadingAt,@PositiveOrZero Integer transitMinutes,@DecimalMin("0") BigDecimal payableAmount,@Pattern(regexp="RMB|USD") String currency,@Size(max=1000) String remark,@NotNull @PositiveOrZero Integer expectedVersion){} public record ActionRequest(@NotNull @PositiveOrZero Integer expectedVersion,@Size(max=500) String reason,@Size(max=32) String containerNo){} public record AttachmentRequest(@NotNull @Positive Long fileId,@NotBlank @Pattern(regexp="DISPATCH_ORDER|DRIVER_DOCUMENT|VEHICLE_DOCUMENT|OTHER") String attachmentType,@Size(max=255) String fileName,@Size(max=128) String fileHash){ } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java index 68757f92d..12d1aa57a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/TruckAppointmentMapper.java @@ -1,6 +1,31 @@ package com.baiyue.firstleg.mapper; -import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.TruckAppointmentEntity; import org.apache.ibatis.annotations.*; @Mapper public interface TruckAppointmentMapper extends BaseMapper{ + @Select(""" + + """) Page candidates(Page p,@Param("q") CandidateQuery q); @Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY created_at,id LIMIT 1 FOR UPDATE") TruckAppointmentEntity firstActiveForUpdate(@Param("containerBookingId") long containerBookingId); @Select("SELECT * FROM fl_truck_appointment WHERE container_booking_id=#{containerBookingId} AND status NOT IN ('COMPLETED','CANCELLED','EXCEPTION') ORDER BY id FOR UPDATE") java.util.List activeForUpdate(@Param("containerBookingId") long containerBookingId); @Select("") Page page(Page p,@Param("q") Query q); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java index a1ce74211..bdfca4c94 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/TruckAppointmentRepository.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.repository; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.Query; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.dto.TruckAppointmentDtos.*; import com.baiyue.firstleg.entity.*; import com.baiyue.firstleg.mapper.*; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @Repository public class TruckAppointmentRepository { @Resource TruckAppointmentMapper mapper; @Resource TruckAppointmentHistoryMapper history; @Resource TruckAppointmentAttachmentMapper attachments; @Resource TruckAppointmentOperationMapper operations; - public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int bindContainerNoForPickup(long containerBookingId,String containerNo,long operatorId,String operatorName){return mapper.bindContainerNoForPickup(containerBookingId,containerNo,operatorId,operatorName);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} + public PageResult page(Query q){return PageResult.from(mapper.page(new Page<>(q.page(),q.size()),q));} public PageResult candidates(CandidateQuery q){return PageResult.from(mapper.candidates(new Page<>(q.page(),q.size()),q));} public TruckAppointmentEntity find(long id){return mapper.selectById(id);} public void insert(TruckAppointmentEntity e){mapper.insert(e);} public int transition(long id,String from,String to,int v){return mapper.transition(id,from,to,v);} public int dispatch(long id,String from,String billOfLadingNo,int v){return mapper.dispatch(id,from,billOfLadingNo,v);} public int bindContainerNoForPickup(long containerBookingId,String containerNo,long operatorId,String operatorName){return mapper.bindContainerNoForPickup(containerBookingId,containerNo,operatorId,operatorName);} public int reschedule(long id,java.time.LocalDateTime at,String reason,int v){return mapper.reschedule(id,at,reason,v);} public void history(TruckAppointmentHistoryEntity e){history.insert(e);} public void operation(TruckAppointmentOperationEntity e){operations.insert(e);} public void attachment(TruckAppointmentAttachmentEntity e){attachments.insert(e);} public boolean claimOperation(long operationId,long appointmentId,String action,String key,String hash){return operations.claim(operationId,appointmentId,action,key,hash)==1;} public TruckAppointmentOperationEntity operation(String key){return operations.find(key);} public int completeOperation(long appointmentId,String action,String key,String response){return operations.complete(appointmentId,action,key,response);} public int updateEdit(TruckAppointmentEntity e,int v){return mapper.updateEdit(e,v);} public java.util.List histories(long id){return history.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentHistoryEntity::getAppointmentId,id).orderByAsc(TruckAppointmentHistoryEntity::getCreatedAt));} public java.util.List attachments(long id){return attachments.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,id).orderByAsc(TruckAppointmentAttachmentEntity::getUploadedAt));} public int deleteAttachment(long appointmentId,long attachmentId){return attachments.delete(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper().eq(TruckAppointmentAttachmentEntity::getAppointmentId,appointmentId).eq(TruckAppointmentAttachmentEntity::getId,attachmentId));} public TruckAppointmentEntity firstActiveForUpdate(long containerBookingId){return mapper.firstActiveForUpdate(containerBookingId);} public java.util.List activeForUpdate(long containerBookingId){return mapper.activeForUpdate(containerBookingId);} public int completeLoadingFromPda(long id,String fromStatus,int version){return mapper.completeLoadingFromPda(id,fromStatus,version);} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java index a3cb9b9e4..a19c2fec3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/TruckAppointmentService.java @@ -47,7 +47,7 @@ public class TruckAppointmentService { @Resource FirstlegDocumentNumberGenerator documentNumbers; @Resource ObjectMapper objectMapper; @Value("${baiyue.security.internal-service-token}") String internalToken; - public PageResult page(Query q){return repo.page(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} + public PageResult page(Query q){return repo.page(q);} public PageResult candidates(CandidateQuery q){return repo.candidates(q);} public TruckAppointmentEntity detail(long id){return enrich(required(id));} public java.util.List history(long id){required(id);return repo.histories(id);} public java.util.List attachments(long id){required(id);return repo.attachments(id);} @Transactional public TruckAppointmentAttachmentEntity upload(long id,String type,MultipartFile file,long uid){String attachmentType=attachmentType(type);required(id);try{var response=files.upload(internalToken,uid,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),file);if(response==null||!response.successful()||response.data()==null)throw bad("附件上传失败");var m=response.data();TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(m.fileId());a.setAttachmentType(attachmentType);a.setFileName(m.fileName());a.setFileHash(m.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");}} @Transactional public TruckAppointmentAttachmentEntity attach(long id,AttachmentRequest r,long uid){String attachmentType=attachmentType(r.attachmentType());required(id);TruckAppointmentFileClient.Metadata metadata;try{var response=files.metadata(internalToken,0L,"TRUCK_APPOINTMENT_ATTACHMENT",Long.toString(id),r.fileId());if(response==null||!response.successful()||response.data()==null)throw bad("附件不存在或归属不匹配");metadata=response.data();}catch(FeignException ex){throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"文件服务暂不可用");} TruckAppointmentAttachmentEntity a=new TruckAppointmentAttachmentEntity();a.setId(IdWorker.getId());a.setAppointmentId(id);a.setFileId(metadata.fileId());a.setAttachmentType(attachmentType);a.setFileName(metadata.fileName());a.setFileHash(metadata.sha256());a.setVisibility("PLATFORM");a.setUploadedBy(uid);a.setUploadedAt(LocalDateTime.now());repo.attachment(a);return a;} @@ -71,7 +71,7 @@ public class TruckAppointmentService { boolean allocated=oceanBookings.allocations(booking.getId()).stream().anyMatch(a->Objects.equals(a.getContainerBookingId(),containerId)); if(!allocated)throw bad("containerBookingId未分配至该订舱"); oceanBookingDocuments.requireActiveValidDocument(booking); - if(r.loadingPlanId()!=null){LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId()))throw bad("loadingPlanId与柜次当前装柜计划不一致");} + LoadingPlanEntity plan=loadingPlans.planByBooking(container.getId());if(plan!=null&&"COMPLETED".equals(plan.getPlanStatus()))throw conflict("关联柜次已装柜完成,不能新建或修改拖车预约");if(r.loadingPlanId()!=null&&(plan==null||!Objects.equals(r.loadingPlanId(),plan.getId())))throw bad("loadingPlanId与柜次当前装柜计划不一致"); return new AssociationContext(booking,container,warehouseId,billOfLadingNo); } private static void validateRequired(SaveRequest r){if(r.oceanBookingId()==null||r.oceanBookingId()<=0)throw bad("oceanBookingId不能为空");if(r.containerBookingId()==null||r.containerBookingId()<=0)throw bad("containerBookingId不能为空");if(r.carrierId()==null||r.carrierId()<=0)throw bad("carrierId不能为空");if(r.plannedLoadingAt()==null)throw bad("plannedLoadingAt不能为空");if(r.expectedVersion()==null||r.expectedVersion()<0)throw bad("expectedVersion不合法");} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index e2e7d03a1..4cea5d827 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -30,6 +30,16 @@ paths: responses: '200': {description: 'Created appointment', content: {application/json: {schema: {$ref: '#/components/schemas/DetailEnvelope'}}}} '409': {description: 'A0409 - this container already has a non-terminal appointment; message includes its appointment number and asks the operator to continue editing'} + /api/platform/v1/truck-appointments/candidates: + get: + summary: Query authoritative create-time ocean-booking and internal-container candidates + description: Requires platform:firstleg:truck-appointment:list. Returns only RELEASED bookings with an active booking-owned confirmation or bill file and allocated ACTIVE internal containers that have no non-terminal truck appointment and whose loading plan is absent or not COMPLETED. A missing containerNo remains eligible. Completed/LOADED containers, occupied containers and bookings without any remaining eligible allocated container are absent. Edit pages must display their current binding from appointment detail rather than injecting it into this create-only candidate result. + parameters: + - {name: keyword, in: query, required: false, schema: {type: string, maxLength: 64}, description: Matches booking number, bill of lading, cabinet number or real container number} + - {name: page, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, default: 1}} + - {name: size, in: query, required: false, schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}} + responses: + '200': {description: Paged flat booking-container candidates, content: {application/json: {schema: {$ref: '#/components/schemas/CandidatePageEnvelope'}}}} /api/platform/v1/truck-appointments/{id}: parameters: [{$ref: '#/components/parameters/AppointmentId'}] get: @@ -138,6 +148,45 @@ components: IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} RequestHash: {name: X-Request-Hash, in: header, required: false, schema: {type: string, pattern: '^[0-9a-fA-F]{64}$'}} schemas: + CandidatePageEnvelope: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/Candidate'}} + page: {type: integer, format: int64} + size: {type: integer, format: int64} + total: {type: integer, format: int64} + pages: {type: integer, format: int64} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + Candidate: + type: object + required: [oceanBookingId, oceanBookingNo, warehouseId, requirementId, containerType, requirementQuantity, containerBookingId, cabinetNo, bookingDocumentReady] + properties: + oceanBookingId: {type: integer, format: int64} + oceanBookingNo: {type: string} + billOfLadingNo: {type: string, nullable: true} + warehouseId: {type: integer, format: int64} + warehouseName: {type: string, nullable: true} + channelCode: {type: string, nullable: true} + channelName: {type: string, nullable: true} + requirementId: {type: integer, format: int64} + containerType: {type: string} + requirementQuantity: {type: integer, minimum: 1} + containerBookingId: {type: integer, format: int64} + cabinetNo: {type: string} + containerNo: {type: string, nullable: true, description: Optional until PICKUP or preload correction; absence does not make the candidate unavailable} + loadingPlanId: {type: integer, format: int64, nullable: true} + loadingPlanNo: {type: string, nullable: true} + loadingPlanStatus: {type: string, nullable: true} + bookingDocumentReady: {type: boolean, enum: [true]} DetailEnvelope: type: object required: [code, message, data, timestamp] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java index 37153971b..8df54537a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/TruckAppointmentControllerContractTest.java @@ -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 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"), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java index 3c979281c..a0b5cd5c8 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/TruckAppointmentMapperContractTest.java @@ -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<>'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]; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java index aa8cd7066..cdcb39a55 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java @@ -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 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);