diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index a6ed9d70d..486fb960e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -27,6 +27,11 @@ public class PlatformOceanBookingController { @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid Query query) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.page(query)); } + @GetMapping("/link-candidates") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") + public ApiResponse> linkCandidates( + @AuthenticationPrincipal AuthenticatedUser user, @Valid LinkCandidateQuery query) { + FirstlegScopeGuard.platform(user); return ApiResponse.success(service.linkCandidates(query)); + } @GetMapping("/channels") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:list')") public ApiResponse> channels(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long originWarehouseId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 044d6b76f..b74f650d3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -12,6 +12,7 @@ import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; import java.time.LocalDateTime; import java.math.BigDecimal; @@ -26,6 +27,24 @@ public final class OceanBookingDtos { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + public record LinkCandidateQuery(@NotNull @Positive Long warehouseId, + @NotBlank @Size(max = 64) String channelCode, + @NotBlank @Size(max = 32) String containerType, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal capacityCbm, + @NotNull @DecimalMin(value = "0", inclusive = false) BigDecimal maxWeightKg, + @Min(1) Long page, + @Min(1) @Max(200) Long size) { + public LinkCandidateQuery { + if (page == null) page = 1L; + if (size == null) size = 20L; + } + } + + public record LinkCandidate(Long oceanBookingId, String bookingNo, Long requirementId, + String containerType, BigDecimal capacityCbm, BigDecimal maxWeightKg, + int requiredQuantity, long allocatedQuantity, long remainingQuantity, + String customerName, String warehouseName, String channelName) {} + public record PartyRequest(@NotBlank @Size(max = 24) String partyType, @NotBlank @Size(max = 255) String partyName, @Size(max = 1000) String address, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index d806df7a9..519d10053 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -3,6 +3,8 @@ package com.baiyue.firstleg.oceanbooking.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Delete; @@ -41,6 +43,34 @@ public interface OceanBookingMapper extends BaseMapper { """) Page selectPage(Page page, @Param("query") Query query); + @Select(""" + SELECT booking.id ocean_booking_id,booking.booking_no,requirement.id requirement_id, + requirement.container_type,requirement.capacity_cbm,requirement.max_weight_kg, + requirement.quantity required_quantity, + COALESCE(allocated.allocated_quantity,0) allocated_quantity, + requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity, + booking.customer_name,booking.warehouse_name,booking.channel_name + FROM fl_ocean_booking booking + JOIN fl_ocean_booking_container_requirement requirement + ON requirement.ocean_booking_id=booking.id + LEFT JOIN ( + SELECT ocean_booking_id,requirement_id,COUNT(*) allocated_quantity + FROM fl_ocean_booking_container_allocation + GROUP BY ocean_booking_id,requirement_id + ) allocated ON allocated.ocean_booking_id=booking.id + AND allocated.requirement_id=requirement.id + WHERE booking.status='RELEASED' + AND booking.warehouse_id=#{query.warehouseId} + AND booking.channel_code=#{query.channelCode} + AND requirement.container_type=#{query.containerType} + AND requirement.capacity_cbm=#{query.capacityCbm} + AND requirement.max_weight_kg=#{query.maxWeightKg} + AND requirement.quantity>COALESCE(allocated.allocated_quantity,0) + ORDER BY booking.updated_at DESC,booking.id DESC,requirement.sort_no ASC,requirement.id ASC + """) + Page selectLinkCandidates(Page page, + @Param("query") LinkCandidateQuery query); + @Update(""" UPDATE fl_ocean_booking SET status=#{toStatus}, updated_by=#{operatorId},updated_by_name=#{operatorName}, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index 1f5f9b27c..747c91ebc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -5,6 +5,8 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.Query; import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.RealContainer; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidate; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import com.baiyue.firstleg.oceanbooking.entity.*; import com.baiyue.firstleg.oceanbooking.mapper.*; import com.baiyue.firstleg.entity.ContainerBookingEntity; @@ -30,6 +32,9 @@ public class OceanBookingRepository { public PageResult page(Query query) { return PageResult.from(bookings.selectPage(new Page<>(query.page(), query.size()), query)); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return PageResult.from(bookings.selectLinkCandidates(new Page<>(query.page(), query.size()), query)); + } public OceanBookingEntity find(long id) { return bookings.selectById(id); } public void insert(OceanBookingEntity value) { bookings.insert(value); } public int transition(long id, String from, String to, int version, long operatorId, String operatorName) { 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 7b4ad9e48..2d68dfedb 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 @@ -55,6 +55,12 @@ public class OceanBookingService { return repository.page(new Query(query.customerId(), status, trim(query.keyword()), query.page(), query.size())); } + public PageResult linkCandidates(LinkCandidateQuery query) { + return repository.linkCandidates(new LinkCandidateQuery(query.warehouseId(), + query.channelCode().trim(), query.containerType().trim(), query.capacityCbm(), + query.maxWeightKg(), query.page(), query.size())); + } + public List channels(long originWarehouseId) { if (originWarehouseId <= 0) throw badRequest("请选择有效起运仓"); Long versionId = priceBooks.activeVersionId(); diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 363e22b73..5900284ee 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -28,6 +28,25 @@ paths: 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}} + /api/platform/v1/ocean-bookings/link-candidates: + get: + summary: Page released booking requirement candidates for one container + description: Requires platform:firstleg:ocean-booking:list. Returns only exact warehouse, channel and fixed container specification matches with remainingQuantity greater than zero. Allocation counts are calculated by one bounded set query; booking detail calls are not used. + x-permission: platform:firstleg:ocean-booking:list + parameters: + - {name: warehouseId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} + - {name: channelCode, in: query, required: true, schema: {type: string, maxLength: 64}} + - {name: containerType, in: query, required: true, schema: {type: string, maxLength: 32}} + - {name: capacityCbm, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} + - {name: maxWeightKg, in: query, required: true, schema: {type: number, exclusiveMinimum: true, minimum: 0}} + - {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: Business envelope containing a bounded candidate page + content: + application/json: + schema: {$ref: '#/components/schemas/LinkCandidateResponse'} /api/platform/v1/ocean-bookings/channels: get: summary: List ACTIVE channels for an origin warehouse @@ -94,6 +113,38 @@ components: channelName: {type: string} priceModel: {type: string} pricingTaxModes: {type: array, items: {type: string}} + LinkCandidate: + type: object + required: [oceanBookingId, bookingNo, requirementId, containerType, capacityCbm, maxWeightKg, requiredQuantity, allocatedQuantity, remainingQuantity, customerName, warehouseName, channelName] + properties: + oceanBookingId: {type: string, pattern: '^[0-9]+$'} + bookingNo: {type: string} + requirementId: {type: string, pattern: '^[0-9]+$'} + containerType: {type: string} + capacityCbm: {type: number} + maxWeightKg: {type: number} + requiredQuantity: {type: integer, minimum: 1} + allocatedQuantity: {type: integer, format: int64, minimum: 0} + remainingQuantity: {type: integer, format: int64, minimum: 1} + customerName: {type: string} + warehouseName: {type: string} + channelName: {type: string} + LinkCandidatePage: + type: object + required: [records, page, size, total, pages] + properties: + records: {type: array, items: {$ref: '#/components/schemas/LinkCandidate'}} + page: {type: integer, format: int64, minimum: 1} + size: {type: integer, format: int64, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + LinkCandidateResponse: + type: object + required: [code, data] + properties: + code: {type: string, example: '200'} + message: {type: string, nullable: true} + data: {$ref: '#/components/schemas/LinkCandidatePage'} Detail: type: object required: [oceanBooking, parties, containerRequirements, containers, histories] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index df43c1be5..6bdfc820d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -20,6 +20,7 @@ class PlatformOceanBookingControllerContractTest { .containsExactly("/api/platform/v1/ocean-bookings"); Map permissions = Map.ofEntries( Map.entry("page", "platform:firstleg:ocean-booking:list"), + Map.entry("linkCandidates", "platform:firstleg:ocean-booking:list"), Map.entry("channels", "platform:firstleg:ocean-booking:list"), Map.entry("detail", "platform:firstleg:ocean-booking:list"), Map.entry("create", "platform:firstleg:ocean-booking:create"), @@ -30,7 +31,8 @@ class PlatformOceanBookingControllerContractTest { Method method = method(name); assertThat(method.getAnnotation(PreAuthorize.class).value()) .isEqualTo("hasAuthority('" + permission + "')"); - if (!name.equals("page") && !name.equals("detail") && !name.equals("channels")) { + if (!name.equals("page") && !name.equals("detail") && !name.equals("channels") + && !name.equals("linkCandidates")) { assertThat(method.getAnnotation(BusinessAudit.class)).isNotNull(); } }); @@ -57,6 +59,20 @@ class PlatformOceanBookingControllerContractTest { .doesNotContain("change-request", "change-confirm", "/cancel:", "containers[].containerNo"); } + @Test + void linkCandidatesUsesDedicatedGetPathAndStablePageContract() throws Exception { + Method method = method("linkCandidates"); + assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()) + .containsExactly("/link-candidates"); + String yaml = Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml")); + assertThat(yaml) + .contains("/api/platform/v1/ocean-bookings/link-candidates:") + .contains("x-permission: platform:firstleg:ocean-booking:list") + .contains("warehouseId", "channelCode", "containerType", "capacityCbm", "maxWeightKg") + .contains("LinkCandidateResponse", "requiredQuantity", "allocatedQuantity", "remainingQuantity") + .contains("booking detail calls are not used"); + } + private static Method method(String name) { return Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) .filter(method -> method.getName().equals(name)).findFirst().orElseThrow(); 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 050195e76..835f826b8 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 @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.repository; import com.baiyue.firstleg.oceanbooking.mapper.OceanBookingMapper; +import com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.LinkCandidateQuery; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; import org.junit.jupiter.api.Test; @@ -36,4 +37,23 @@ class OceanBookingMapperContractTest { .contains("status='DRAFT' AND version=#{version}") .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); } + + @Test + void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { + String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", + com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, LinkCandidateQuery.class) + .getAnnotation(Select.class).value()); + assertThat(sql) + .contains("booking.status='RELEASED'") + .contains("booking.warehouse_id=#{query.warehouseId}") + .contains("booking.channel_code=#{query.channelCode}") + .contains("requirement.container_type=#{query.containerType}") + .contains("requirement.capacity_cbm=#{query.capacityCbm}") + .contains("requirement.max_weight_kg=#{query.maxWeightKg}") + .contains("FROM fl_ocean_booking_container_allocation") + .contains("GROUP BY ocean_booking_id,requirement_id") + .contains("requirement.quantity-COALESCE(allocated.allocated_quantity,0) remaining_quantity") + .contains("requirement.quantity>COALESCE(allocated.allocated_quantity,0)") + .doesNotContain("fl_container_booking", "SELECT booking.*"); + } } 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 f9b3ce64a..2bea449df 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 @@ -1,6 +1,7 @@ package com.baiyue.firstleg.oceanbooking.service; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; import com.baiyue.common.core.exception.BizException; import com.baiyue.firstleg.client.WarehouseMasterDataClient; import com.baiyue.firstleg.client.WarehouseSupplierClient; @@ -170,6 +171,29 @@ class OceanBookingServiceTest { verify(repository, times(2)).page(any()); } + @Test + void linkCandidatesDefaultsPaginationAndDelegatesOneNormalizedSetQuery() { + LinkCandidate candidate = new LinkCandidate(101L, "OB-260818-0001", 201L, "40HC", + new BigDecimal("76"), new BigDecimal("28000"), 3, 1, 2, + "客户A", "深圳中转仓", "美西海运"); + when(repository.linkCandidates(any())).thenReturn(new PageResult<>(List.of(candidate), 1, 20, 1, 1)); + + PageResult result = service.linkCandidates(new LinkCandidateQuery(701L, + " SEA-US ", " 40HC ", new BigDecimal("76.0"), new BigDecimal("28000.00"), null, null)); + + assertThat(result.records()).containsExactly(candidate); + assertThat(result.records().get(0).remainingQuantity()).isEqualTo(2); + ArgumentCaptor query = ArgumentCaptor.forClass(LinkCandidateQuery.class); + verify(repository).linkCandidates(query.capture()); + assertThat(query.getValue().channelCode()).isEqualTo("SEA-US"); + assertThat(query.getValue().containerType()).isEqualTo("40HC"); + assertThat(query.getValue().page()).isEqualTo(1L); + assertThat(query.getValue().size()).isEqualTo(20L); + verify(repository, never()).find(anyLong()); + verify(repository, never()).requirements(anyLong()); + verify(repository, never()).containers(anyLong()); + } + @Test void releaseRejectsInvalidScheduleWithoutTransition() { OceanBookingEntity draft = completeBooking("DRAFT", 3);