From 136a2bc3b538766beb42f94c5a3277d9a28539e1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 8 Oct 2026 14:11:55 +0800 Subject: [PATCH 01/12] fix(lastleg): expose dispatch reviews and filter available capacity offers --- .../dispatch/dto/DispatchRequests.java | 14 +++++- .../dispatch/dto/DispatchResponse.java | 2 +- .../dispatch/service/DispatchService.java | 15 ++++++- .../fleet/trip/FleetCapacityOfferMapper.java | 6 ++- .../fleet/trip/FleetCapacityOfferModels.java | 4 +- .../fleet/trip/FleetCapacityOfferService.java | 3 +- .../trip/FleetDispatchWorkflowQuery.java | 43 +++++++++++++++++++ .../openapi/fleet-capacity-offers-v1.yaml | 4 ++ .../openapi/overseas-business-v1.yaml | 22 ++++++++++ .../DispatchQueryControllerTest.java | 2 +- .../service/DispatchBolPrintServiceTest.java | 4 +- .../dispatch/service/DispatchServiceTest.java | 22 +++++++++- .../trip/FleetCapacityOfferServiceTest.java | 38 +++++++++++++--- .../trip/FleetDispatchWorkflowQueryTest.java | 33 ++++++++++++++ 14 files changed, 194 insertions(+), 18 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java index 52a94c557..a8d9e405d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java @@ -15,10 +15,22 @@ public final class DispatchRequests { } public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo, - String vehiclePlate, String status, String fbaAppointmentNo) { + String vehiclePlate, String status, String fbaAppointmentNo, Stage stage) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + public enum Stage { + BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED; + public List statuses() { + return switch (this) { + case BEFORE_LOADING -> List.of("PENDING_DEPARTURE"); + case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART"); + case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED"); + case CANCELLED -> List.of("CANCELLED"); + }; + } + } + public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds, @Size(max = 64) String fbaAppointmentNo, @Size(max = 32) String fbaAppointmentStatus, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java index b487c19b0..1eacc603e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -12,7 +12,7 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, int itemCount, int palletCount, int packageCount, int pickedPackageCount, int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, LocalDateTime createdAt, - List items, List bolLines) { + List items, List bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, String overseasWarehouseId, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 62a86b738..800b4c12e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -75,6 +75,7 @@ public class DispatchService { @Resource private LastlegOrderMapper tailOrders; @Resource private OutboundOrderMapper outboundOrders; @Resource private FleetDispatchBindingGuard fleetBindings; + @Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; @Resource private RelabelApplicationMapper relabelApplications; @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; @@ -623,11 +624,16 @@ public class DispatchService { .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) + .in(query.stage() != null, DispatchOrder::getStatus, + query.stage() == null ? List.of() : query.stage().statuses()) .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo, query.fbaAppointmentNo()) .orderByDesc(DispatchOrder::getCreatedAt)); + var workflows = acrossCustomers ? fleetWorkflow.read(page.getRecords().stream() + .map(DispatchOrder::getDispatchNo).toList()) : java.util.Map.of(); List records = page.getRecords().stream() - .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()))).toList(); + .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()), + List.of(), workflows.get(order.getDispatchNo()))).toList(); return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -764,6 +770,11 @@ public class DispatchService { private DispatchResponse response(DispatchOrder order, List itemList, List bolLineList) { + return response(order, itemList, bolLineList, null); + } + + private DispatchResponse response(DispatchOrder order, List itemList, + List bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) .distinct().collect(Collectors.joining(",")); return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), @@ -782,7 +793,7 @@ public class DispatchService { item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(), item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(), - bolLineList == null ? List.of() : bolLineList); + bolLineList == null ? List.of() : bolLineList, workflow); } private static String candidateRoute(List selected) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java index 188c4731c..10f34198d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java @@ -7,7 +7,9 @@ public interface FleetCapacityOfferMapper extends BaseMapper SELECT o.* FROM ll_fleet_capacity_offer o JOIN ll_fleet_trip t ON t.id=o.trip_id - o.status='OPEN' + o.status='OPEN' AND t.status='PLANNED' + AND o.cutoff_at > #{now} AND o.cutoff_at <= t.planned_departure_at + AND o.status=#{q.status} AND o.trip_no=#{q.tripNo} AND (o.offer_no LIKE CONCAT('%',#{q.keyword},'%') OR o.trip_no LIKE CONCAT('%',#{q.keyword},'%')) @@ -21,7 +23,7 @@ public interface FleetCapacityOfferMapper extends BaseMapper pageOffers( com.baomidou.mybatisplus.extension.plugins.pagination.Page page, - @Param("q") FleetCapacityOfferModels.Query query,@Param("salesOnly") boolean salesOnly); + @Param("q") FleetCapacityOfferModels.Query query,@Param("availableOnly") boolean availableOnly,@Param("now") java.time.LocalDateTime now); @Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo}") FleetCapacityOfferEntity byNo(@Param("offerNo") String offerNo); @Select("SELECT * FROM ll_fleet_capacity_offer WHERE offer_no=#{offerNo} FOR UPDATE") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java index 875513689..51d53dd31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java @@ -16,8 +16,8 @@ public final class FleetCapacityOfferModels { public record Query(@Min(1) Integer page,@Min(1) @Max(100) Integer size, @Size(max=128) String keyword,@Size(max=128) String origin,@Size(max=128) String destination, LocalDate departureFrom,LocalDate departureTo,@Pattern(regexp="DRAFT|OPEN|PAUSED|CLOSED") String status, - Boolean canConfirm,@Size(max=64) String tripNo) { - public Query {page=page==null?1:page;size=size==null?20:size;} + Boolean canConfirm,@Size(max=64) String tripNo,@Pattern(regexp="AVAILABLE|ALL") String scope) { + public Query {page=page==null?1:page;size=size==null?20:size;scope=scope==null?"AVAILABLE":scope;} } public record InquiryQuery(@Min(1) Integer page,@Min(1) @Max(100) Integer size, @Size(max=128) String keyword,@Size(max=64) String offerNo,@Size(max=32) String status, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index d89d3054f..0d2ac1b1e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -52,7 +52,8 @@ public class FleetCapacityOfferService { @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public PageResult page(FleetCapacityOfferModels.Query query){ readPermission(); - var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!manage()); + if("ALL".equals(query.scope())&&!manage())throw forbidden(); + var result=offers.pageOffers(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(query.canConfirm()==null?query.page():1,query.canConfirm()==null?query.size():100),query,!"ALL".equals(query.scope()),LocalDateTime.ofInstant(clock.instant(),ZoneOffset.UTC)); if(query.canConfirm()!=null&&result.getTotal()>100)throw bad("实时接单资格筛选最多核对100趟车次,请缩小起运仓、目的地或日期范围"); var values=result.getRecords().stream().map(row->project(row,trip(row.getTripNo(),false))) .filter(value->query.canConfirm()==null||query.canConfirm()==value.canConfirm()).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java new file mode 100644 index 000000000..2c9fef6c2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.stream.Collectors; + +/** Read existing reviews in batches; never infer approval from dispatch execution status. */ +@Service +public class FleetDispatchWorkflowQuery { + public record Review(String status, String reason, java.time.Instant reviewedAt) { } + public record Workflow(String tripNo, Review dispatchReview, Review loadingReview) { } + private final FleetTripDispatchMapper bindings; + private final FleetTripMapper trips; + private final ObjectMapper json; + + public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, ObjectMapper json) { + this.bindings = bindings; this.trips = trips; this.json = json; + } + + public Map read(List dispatchNos) { + if (dispatchNos.isEmpty()) return Map.of(); + var members = bindings.findByDispatchNos(dispatchNos); + if (members.isEmpty()) return Map.of(); + var parents = trips.selectBatchIds(members.stream().map(FleetTripDispatch::getTripId).distinct().toList()) + .stream().collect(Collectors.toMap(FleetTripEntity::getId, trip -> trip)); + Map result = new HashMap<>(); + for (var member : members) { + var trip = parents.get(member.getTripId()); + if (trip == null) throw new IllegalStateException("关联车次不存在"); + result.put(member.getDispatchNo(), new Workflow(member.getTripNo(), + review(member.getReviewJson()), review(trip.getLoadingReviewJson()))); + } + return result; + } + + private Review review(String value) { + var state = FleetTripReview.read(json, value); + return new Review(state.status(), state.reason(), state.reviewedAt()); + } +} diff --git a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml index 7ec891f89..96ff377c3 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml @@ -92,6 +92,10 @@ paths: - OPEN - PAUSED - CLOSED + - name: scope + in: query + description: 默认AVAILABLE,仅开放且车次仍PLANNED、当前服务端时间早于交货截止且截止不晚于计划发车;数据库分页前筛选,不以容量已知或有余位为条件。ALL包含草稿和历史,仅管理权限可用;车次发布设置读取已有发布须显式传ALL。 + schema: { type: string, enum: [AVAILABLE, ALL], default: AVAILABLE } - name: canConfirm in: query required: false diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 6cfc4e2cd..67ebdd190 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -586,6 +586,10 @@ paths: - { name: vehiclePlate, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string } } - { name: fbaAppointmentNo, in: query, schema: { type: string } } + - name: stage + in: query + description: 服务端在分页前筛选;BEFORE_LOADING=待发车未拣货,LOADING=拣货中或拣货完成待发车,AFTER_LOADING=已发车(含部分),CANCELLED=已取消;与status取交集。 + schema: { type: string, enum: [BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED] } responses: '200': { description: 统一 ApiResponse 包装的排车单分页,records 元素为 DispatchResponse } /api/platform/v1/last-leg/dispatches/{dispatchNo}: @@ -2947,10 +2951,28 @@ components: partialCompletionReason: { type: string, nullable: true } createdAt: { type: string, format: date-time } items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } } + workflow: + nullable: true + description: 仅平台分页返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。 + allOf: [{ $ref: '#/components/schemas/DispatchWorkflow' }] bolLines: type: array description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大 items: { $ref: '#/components/schemas/DispatchBolLine' } + DispatchWorkflow: + type: object + required: [tripNo, dispatchReview, loadingReview] + properties: + tripNo: { type: string } + dispatchReview: { $ref: '#/components/schemas/DispatchReview' } + loadingReview: { $ref: '#/components/schemas/DispatchReview' } + DispatchReview: + type: object + required: [status] + properties: + status: { type: string, enum: [PENDING, APPROVED, REJECTED] } + reason: { type: string, nullable: true } + reviewedAt: { type: string, format: date-time, nullable: true } DispatchItem: type: object properties: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java index 483f033f3..692fa2784 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -38,7 +38,7 @@ class DispatchQueryControllerTest { @Test void platformPageQueriesAcrossCustomersWithoutCustomerId() { - DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null); + DispatchRequests.Query query = new DispatchRequests.Query(1, 20, null, null, null, null, null); when(service.platformPage(query)).thenReturn(new PageResult<>(List.of(), 1, 20, 0, 0)); var response = controller.page(platform(), query); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index c0d40b167..bda740e5f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -55,7 +55,7 @@ class DispatchBolPrintServiceTest { null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", - "NYEXX2609110214", null, null, 1))); + "NYEXX2609110214", null, null, 1)), null); var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, @@ -87,7 +87,7 @@ class DispatchBolPrintServiceTest { new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", "NYEXX2609110214", null, null, 1), new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", - "NYEXX2609110215", null, null, 1))); + "NYEXX2609110215", null, null, 1)), null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE"); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 1cbb76918..b3da416be 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -63,11 +63,13 @@ class DispatchServiceTest { @Mock LastlegOrderMapper tailOrders; @Mock OutboundOrderMapper outboundOrders; @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; + @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; private DispatchService service; @BeforeEach void setUp() { service = new DispatchService(); + ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "items", items); @@ -435,6 +437,24 @@ class DispatchServiceTest { verify(bolLines).findByDispatchOrder("200", 1L); } + @Test + void filtersStageBeforePaginationAndPreservesTheServerTotal() { + com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( + new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "test"), DispatchOrder.class); + Page resultPage = new Page<>(2, 10, 47); + resultPage.setRecords(List.of()); + when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenAnswer(invocation -> { + LambdaQueryWrapper wrapper = invocation.getArgument(1); + assertTrue(wrapper.getSqlSegment().contains("status IN")); + assertTrue(wrapper.getParamNameValuePairs().values().containsAll( + List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART"))); + return resultPage; + }); + var result = service.platformPage(new DispatchRequests.Query(2, 10, null, null, null, null, DispatchRequests.Stage.LOADING)); + assertEquals(47, result.total()); + assertEquals(List.of("DEPARTED", "PARTIAL_DEPARTED"), DispatchRequests.Stage.AFTER_LOADING.statuses()); + } + @Test void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() { DispatchOrder first = new DispatchOrder(); @@ -445,7 +465,7 @@ class DispatchServiceTest { resultPage.setRecords(List.of(first, second)); when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage); - var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null)); + var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null, null)); assertEquals(2, result.total()); verify(items).findByDispatchOrder("100", 1L); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java index 946c14a87..25a81baf9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java @@ -36,22 +36,50 @@ class FleetCapacityOfferServiceTest { @AfterEach void clear(){SecurityContextHolder.clearContext();} @Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){ var rows=List.of(offer(1),offer(2));var page=new Page(3,20,2000);page.setRecords(rows); - when(offers.pageOffers(any(),any(),eq(true))).thenReturn(page); + when(offers.pageOffers(any(),any(),eq(true),any())).thenReturn(page); for(var row:rows){var trip=trip(row.getTripNo());when(trips.findByNo(row.getTripNo())).thenReturn(trip); when(reservations.stops(trip)).thenReturn(List.of(new FleetTripModels.Stop("起点",null,null),new FleetTripModels.Stop("ONT8",null,"FBA:ONT8"))); when(execution.capacityPlan(row.getTripNo())).thenReturn(new FleetExecutionModels.Planning(row.getTripNo(),1L,List.of(),"hash",null,false,"计划待核对",List.of()));} - var query=new FleetCapacityOfferModels.Query(3,20,"FO",null,null,null,null,null,null,null); + var query=new FleetCapacityOfferModels.Query(3,20,"FO",null,null,null,null,null,null,null,null); var result=service.page(query); assertThat(result.total()).isEqualTo(2000);assertThat(result.page()).isEqualTo(3);assertThat(result.records()).hasSize(2); - var captor=ArgumentCaptor.forClass(Page.class);verify(offers).pageOffers(captor.capture(),same(query),eq(true)); + assertThat(result.records()).allMatch(FleetCapacityOfferModels.Offer::canCreateInquiry).allMatch(value -> !value.canConfirm()); + var captor=ArgumentCaptor.forClass(Page.class);verify(offers).pageOffers(captor.capture(),same(query),eq(true),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC))); assertThat(captor.getValue().getCurrent()).isEqualTo(3);assertThat(captor.getValue().getSize()).isEqualTo(20); verify(execution,times(2)).capacityPlan(anyString());verify(trips,times(2)).findByNo(anyString());verifyNoMoreInteractions(execution,trips); } @Test void broadDynamicAvailabilityFilterRejectsBeforeAnyPerTripCapacityQuery(){ - when(offers.pageOffers(any(),any(),eq(true))).thenReturn(new Page<>(1,100,101)); - assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null))).hasMessageContaining("100"); + when(offers.pageOffers(any(),any(),eq(true),any())).thenReturn(new Page<>(1,100,101)); + assertThatThrownBy(()->service.page(new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,true,null,null))).hasMessageContaining("100"); verifyNoInteractions(execution,trips); } + @Test void historyScopeRequiresManagementAndDefaultScopeIsAvailable(){ + var query=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,null); + assertThat(query.scope()).isEqualTo("AVAILABLE"); + var history=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,"ALL"); + assertThatThrownBy(()->service.page(history)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + verifyNoInteractions(offers); + var user=new AuthenticatedUser(101L,0L,0L,101L,"admin","ADMIN","WEB",Set.of(FleetCapacityOfferService.MANAGE)); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.MANAGE)))); + when(offers.pageOffers(any(),same(query),eq(true),any())).thenReturn(new Page<>(1,20,0)); + service.page(query); + verify(offers).pageOffers(any(),same(query),eq(true),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC))); + when(offers.pageOffers(any(),same(history),eq(false),any())).thenReturn(new Page<>(1,20,0)); + assertThat(service.page(history).records()).isEmpty(); + verify(offers).pageOffers(any(),same(history),eq(false),eq(LocalDateTime.ofInstant(now,ZoneOffset.UTC))); + } + @Test void availableSqlFiltersBeforePaginationWithoutRequiringKnownCapacity() throws Exception { + var method=Arrays.stream(FleetCapacityOfferMapper.class.getMethods()).filter(m->m.getName().equals("pageOffers")).findFirst().orElseThrow(); + String script=String.join("\n",method.getAnnotation(org.apache.ibatis.annotations.Select.class).value()); + var sql=new org.apache.ibatis.scripting.xmltags.XMLLanguageDriver().createSqlSource(new org.apache.ibatis.session.Configuration(),script,Map.class); + var query=new FleetCapacityOfferModels.Query(1,20,null,null,null,null,null,null,null,null,null); + var parameters=new HashMap();parameters.put("q",query);parameters.put("availableOnly",true);parameters.put("now",LocalDateTime.ofInstant(now,ZoneOffset.UTC)); + String active=sql.getBoundSql(parameters).getSql().replaceAll("\\s+"," "); + assertThat(active).contains("o.status='OPEN'", "t.status='PLANNED'", "o.cutoff_at > ?", "o.cutoff_at <= t.planned_departure_at"); + assertThat(active).doesNotContain("planned_load_json", "remaining", "NOW()"); + parameters.put("availableOnly",false); + assertThat(sql.getBoundSql(parameters).getSql()).doesNotContain("o.cutoff_at", "t.status='PLANNED'"); + } @Test void cutoffUsesStrictServerInstantAndRechecksChangedDeparture(){ var trip=trip("FT-A");var offer=offer(1);offer.setStatus("OPEN"); assertThat(FleetCapacityOfferService.openBlock(offer,trip,now)).isNull(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java new file mode 100644 index 000000000..0fff22d9f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.any; + +class FleetDispatchWorkflowQueryTest { + @Test + void batchesReviewsAndKeepsCustomerDispatchDecisionsIndependent() { + var bindings = mock(FleetTripDispatchMapper.class); + var trips = mock(FleetTripMapper.class); + var first = new FleetTripDispatch(); first.setDispatchNo("PC-A"); first.setTripId(1L); first.setTripNo("TR-A"); + first.setReviewJson("{\"status\":\"REJECTED\",\"reason\":\"货物需重新核对\"}"); + var second = new FleetTripDispatch(); second.setDispatchNo("PC-B"); second.setTripId(1L); second.setTripNo("TR-A"); + var trip = new FleetTripEntity(); trip.setId(1L); + trip.setLoadingReviewJson("{\"status\":\"APPROVED\",\"reason\":\"审核通过\"}"); + when(bindings.findByDispatchNos(List.of("PC-A", "PC-B", "PC-C"))).thenReturn(List.of(first, second)); + when(trips.selectBatchIds(any())).thenReturn(List.of(trip)); + var query = new FleetDispatchWorkflowQuery(bindings, trips, new ObjectMapper().findAndRegisterModules()); + var result = query.read(List.of("PC-A", "PC-B", "PC-C")); + assertEquals("REJECTED", result.get("PC-A").dispatchReview().status()); + assertEquals("货物需重新核对", result.get("PC-A").dispatchReview().reason()); + assertEquals("PENDING", result.get("PC-B").dispatchReview().status()); + assertEquals("APPROVED", result.get("PC-B").loadingReview().status()); + assertFalse(result.containsKey("PC-C")); + verify(trips).selectBatchIds(List.of(1L)); + verifyNoMoreInteractions(trips); + assertTrue(query.read(List.of()).isEmpty()); + } +} From 27a89a6ff36d4004f1a93808f7efe84c278072c8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 8 Oct 2026 15:12:47 +0800 Subject: [PATCH 02/12] fix(lastleg): filter domestic first-leg orders by sailing stage --- .../lastleg/dto/LastlegOrderRequests.java | 5 +- .../lastleg/mapper/LastlegOrderMapper.java | 10 +++ .../lastleg/service/LastlegOrderService.java | 2 + .../openapi/overseas-business-v1.yaml | 5 ++ .../LastlegOrderControllerTest.java | 6 +- .../MerchantLastlegOrderControllerTest.java | 2 +- .../service/LastlegOrderSailingStageTest.java | 82 +++++++++++++++++++ .../service/LastlegOrderServiceTest.java | 10 +-- 8 files changed, 112 insertions(+), 10 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java index 6b1262676..9be19ed6f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java @@ -18,6 +18,8 @@ public final class LastlegOrderRequests { private LastlegOrderRequests() { } + public enum SailingStage { BEFORE_DEPARTURE, AFTER_DEPARTURE } + public record Query( @Min(1) Long page, @Min(1) @Max(200) Long size, @@ -34,7 +36,8 @@ public final class LastlegOrderRequests { Boolean abnormal, Boolean detained, LocalDateTime createdFrom, - LocalDateTime createdTo + LocalDateTime createdTo, + SailingStage sailingStage ) { public Query { if (page == null || page == 0) page = 1L; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index ac6290793..e2c1f5ec0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -16,6 +16,16 @@ import java.math.BigDecimal; @Mapper public interface LastlegOrderMapper extends BaseMapper { + // Manual node snapshots replace container nodes permanently for the order. + String SAILING_STAGE_FILTER_SQL = "(ll_order.order_type='FIRST_LEG' AND ll_order.source_region='FIRSTLEG' AND " + + "(CASE WHEN COALESCE(ll_order.manual_node_override,FALSE)=TRUE THEN " + + "CASE WHEN EXISTS (SELECT 1 FROM ll_order_manual_node n WHERE n.order_id=ll_order.id " + + "AND n.customer_id=ll_order.customer_id AND n.node_code='ACTUAL_DEPARTURE' AND n.node_at IS NOT NULL) " + + "THEN 1 ELSE 0 END ELSE " + + "CASE WHEN EXISTS (SELECT 1 FROM ll_container_shipping_gate g " + + "WHERE g.container_booking_id=ll_order.source_container_booking_id " + + "AND g.node_sequence>=40 AND g.actual_at IS NOT NULL) THEN 1 ELSE 0 END END)={0})"; + @Select("") + List nativeReturnRelabelAvailable(@Param("customerId") String customerId, + @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'") int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java index 9b57005ec..a6d90e94e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java @@ -44,6 +44,7 @@ public class DispatchPalletCandidate { private String contactPhone; private Long returnReceiptId; private String returnReceiptNo; + private boolean returnCartonSource; @com.fasterxml.jackson.annotation.JsonProperty("sourceType") public String sourceType() { return returnReceiptId == null ? "INBOUND_PLAN" : "RETURN_RECEIPT"; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java index 0d5613033..f8591d251 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java @@ -8,8 +8,12 @@ import jakarta.validation.constraints.Size; public final class DispatchPdaRequests { private DispatchPdaRequests() {} - public record Pick(@NotNull Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, - @NotBlank String idempotencyKey, String remark) {} + public record Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, + @NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) { + public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) { + this(positionId, packages, productQuantity, idempotencyKey, remark, null); + } + } public record Depart(@NotBlank String idempotencyKey, String remark) {} public record CompletePicking(@NotBlank String idempotencyKey) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java index 3a0c1aa9e..845cc9847 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -16,7 +16,15 @@ public final class DispatchPdaResponses { public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, int scheduledPackages, - long pickedPackages, List positions) {} + long pickedPackages, List positions, List returnCartons) { + public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, + String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, + int scheduledPackages, long pickedPackages, List positions) { + this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, + warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of()); + } + } + public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {} public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages, long pickedPackages) {} public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java index fe47e73c9..12daa7f71 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java @@ -15,6 +15,7 @@ public class DispatchPdaPick { private Long taskId; private Long dispatchItemId; private Long inventoryPositionId; + private Long returnCartonId; private Long packages; private Long productQuantity; private String idempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java index c59eb46d5..225cb9aa5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -16,6 +16,7 @@ public class DispatchPdaPickOrder { private Long pickId; private Long receiptId; private Long returnReceiptId; + private Long returnCartonId; private Long orderId; private Long packages; private Long productQuantity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 3eac86651..8e297a778 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -54,6 +54,7 @@ public class DispatchPdaWorkflowService { @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private DispatchPickOrderAttributionService pickAttribution; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnRelabelStock; @Transactional(readOnly = true) public List list(String warehouseId, String status) { @@ -115,13 +116,23 @@ public class DispatchPdaWorkflowService { throw notFound("Dispatch pallet item not found"); } if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse"); - InventoryPosition position = positions.selectById(request.positionId()); - if (position == null || !customerId.equals(position.getCustomerId()) - || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId())) { + boolean nativeReturn = "RETURN_RELABEL".equals(item.getSourceStage()); + if (!nativeReturn && request.positionId() == null) throw conflict("普通入仓拣货必须选择库存仓位"); + if (nativeReturn && request.positionId() != null) throw conflict("退货箱拣货请扫描来源箱唛,不使用普通库存仓位"); + InventoryPosition position = nativeReturn ? null : positions.selectById(request.positionId()); + if (!nativeReturn && (position == null || !customerId.equals(position.getCustomerId()) + || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId()))) { throw conflict("Inventory position does not belong to this dispatch pallet"); } long picked = picks.totalItem(customerId, task.getId(), itemId); if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); + long pickId = IdWorker.getId(); + Long returnCartonId = null; + if (nativeReturn) { + pickAttribution.prepareNativeReturn(customerId, item.getOrderId()); + returnCartonId = returnRelabelStock.pick(item, request.cartonMark(), request.packages(), request.productQuantity(), + pickId, order.getDispatchNo()).getId(); + } else { pickAttribution.preparePicking(customerId, request.positionId(), "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); @@ -142,12 +153,14 @@ public class DispatchPdaWorkflowService { inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, keyBase + ":SHIP", request.remark())); + } DispatchPdaPick pick = new DispatchPdaPick(); - pick.setId(IdWorker.getId()); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); + pick.setId(pickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setReturnCartonId(returnCartonId); pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); - pickAttribution.attribute(customerId, pick, + if (nativeReturn) pickAttribution.attributeNativeReturn(customerId, pick, item.getOrderId()); + else pickAttribution.attribute(customerId, pick, "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId()); long total = picked + request.packages(); order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); @@ -289,6 +302,13 @@ public class DispatchPdaWorkflowService { private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List source) { List values = new ArrayList<>(); for (DispatchItem item : source) { + if ("RETURN_RELABEL".equals(item.getSourceStage())) { + values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), + item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), + warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), + picks.totalItem(customerId, task.getId(), item.getId()), List.of(), returnRelabelStock.detail(item))); + continue; + } List palletPositions = item.getWarehousePalletId() == null ? List.of() : positions.listPallet(customerId, item.getWarehousePalletId()); boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index 3c1bcc9e5..9d1f51a3d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -162,6 +162,24 @@ public class DispatchPickOrderAttributionService { } public record OrderPackages(Long orderId, long packages) {} + /** Use the same order-before-stock lock order as ordinary dispatch picks. */ + @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void prepareNativeReturn(String customerId, Long orderId) { + LastlegOrder order = orderId == null ? null : orders.lockById(customerId, orderId); + if (order == null || Boolean.TRUE.equals(order.getDetained())) + throw conflict("退货换标履约订单缺失或暂扣"); + } + /** Native return boxes have their own stock ledger, but fulfill this relabel tail order. */ + public void attributeNativeReturn(String customerId, DispatchPdaPick pick, Long orderId) { + LastlegOrder order = orderId == null ? null : orders.lockById(customerId, orderId); + if (order == null || Boolean.TRUE.equals(order.getDetained())) throw conflict("退货换标履约订单缺失或暂扣"); + DispatchPdaPickOrder row = new DispatchPdaPickOrder(); + row.setId(IdWorker.getId()); row.setCustomerId(customerId); row.setPickId(pick.getId()); row.setOrderId(orderId); + row.setReturnCartonId(pick.getReturnCartonId()); + row.setPackages(pick.getPackages()); row.setProductQuantity(pick.getProductQuantity()); row.setCreatedAt(pick.getPickedAt()); + if (attributions.insert(row) != 1 || orders.increasePicked(customerId, orderId, pick.getPackages()) != 1) + throw conflict("退货换标排车拣货归属或箱数不一致"); + } private static long value(Long value) { return value == null ? 0 : value; } private static long value(Integer value) { return value == null ? 0 : value; } private static ResponseStatusException conflict(String message) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 800b4c12e..0ce8e808d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -78,6 +78,7 @@ public class DispatchService { @Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; @Resource private RelabelApplicationMapper relabelApplications; @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; + @Resource private ReturnRelabelDispatchStock returnRelabelStock; public String resolveCustomerScopeForPlans(List planIds) { return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), @@ -122,7 +123,9 @@ public class DispatchService { List result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(sourceStage), trimToNull(draftToken)); if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) { - List relabel = candidates.relabelAvailable(null, trimToNull(orderNo), null); + List relabel = java.util.stream.Stream.concat( + candidates.relabelAvailable(null, trimToNull(orderNo), null).stream(), + returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList(); if (relabel != null && !relabel.isEmpty()) { result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item -> !StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "") @@ -567,15 +570,22 @@ public class DispatchService { @Transactional private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { - RelabelApplication application = relabelApplications.findNo(customerId, request.relabelNo()); + RelabelApplication application = relabelApplications.lockNo(customerId, request.relabelNo()); if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); } relabelBindingGuard.requireBound(application); - List selected = candidates.relabelAvailable(customerId, null, request.relabelNo()); + if (!"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态"); + List selected = java.util.stream.Stream.concat( + candidates.relabelAvailable(customerId, null, request.relabelNo()).stream(), + returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList(); Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); + if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource) + && !requested.equals(selected.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()))) + throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标排车须选择本申请全部已绑定托盘"); selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); - if (selected.isEmpty() || selected.size() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); + if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); String warehouse = selected.get(0).overseasWarehouseId(); if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓"); LocalDateTime now = LocalDateTime.now(); @@ -584,6 +594,12 @@ public class DispatchService { dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); + for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) { + if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId()) + && Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) { + item.setSourceStage("RETURN_RELABEL"); items.updateById(item); returnRelabelStock.reserve(item); + } + } if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); } @@ -685,6 +701,8 @@ public class DispatchService { requirePalletEditingAllowed(dispatch); DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId); if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found"); + if ("RETURN_RELABEL".equals(item.getSourceStage())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标按整单排车,请取消整张排车单后重新排车"); List activeLocks = locks.selectList(new LambdaQueryWrapper() .eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId()) @@ -734,10 +752,17 @@ public class DispatchService { throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); } String customerId = dispatch.getCustomerId(); + if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) + throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱"); + if (outboundOrders.findByDispatch(customerId, dispatch.getId()) != null) + throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车"); LocalDateTime now = LocalDateTime.now(); dispatch.setStatus(CANCELLED); dispatch.setUpdatedAt(now); orders.updateById(dispatch); + for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) { + if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item); + } items.findByDispatchOrder(customerId, dispatch.getId()).stream() .filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) .map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java new file mode 100644 index 000000000..887b5d037 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +/** Native return stock stays in its own ledger; no synthetic InventoryPosition rows. */ +@Service +public class ReturnRelabelDispatchStock { + @Resource private DispatchCandidateMapper candidates; + @Resource private ReturnInventoryCartonMapper cartons; + @Resource private PalletPhysicalGateway physical; + @Resource private ReturnInventoryService inventory; + + public List candidates(String customer, String orderNo, String relabelNo) { + List result = new ArrayList<>(); + for (var candidate : candidates.nativeReturnRelabelAvailable(customer, orderNo, relabelNo)) { + var pallet = physical.resolve(candidate.palletNo()); + if (pallet == null || !String.valueOf(pallet.warehouseId()).equals(candidate.overseasWarehouseId()) + || !Set.of("IDLE", "OCCUPIED").contains(pallet.status())) continue; + candidate.warehousePalletId(pallet.palletId()); + result.add(candidate); + } + return result; + } + + public void reserve(DispatchItem item) { + for (String mark : marks(item).stream().sorted().toList()) { + var carton = require(item, mark, "RELABEL_PENDING"); + carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(LocalDateTime.now()); + if (cartons.updateById(carton) != 1) throw conflict("退货箱排车预占失败"); + } + } + + public void release(DispatchItem item) { + for (String mark : marks(item).stream().sorted().toList()) { + var carton = require(item, mark, "OUTBOUND_PENDING"); + carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(LocalDateTime.now()); + if (cartons.updateById(carton) != 1) throw conflict("退货箱排车取消释放失败"); + } + } + + public ReturnInventoryCarton pick(DispatchItem item, String mark, long packages, long quantity, long pickId, String dispatchNo) { + if (packages != 1 || !marks(item).contains(mark)) throw conflict("请扫描本排车的来源箱唛,每次拣货一箱"); + var carton = require(item, mark, "OUTBOUND_PENDING"); + if (quantity != carton.getTotalQuantity()) throw conflict("退货箱 SKU 总数量已变化,请刷新后重试"); + inventory.shipCarton(item.getCustomerId(), item.getOverseasWarehouseId(), carton.getSourceReturnNo(), + carton.getCartonMark(), dispatchNo, pickId); + return carton; + } + + public List detail(DispatchItem item) { + return marks(item).stream().map(mark -> { + var carton = cartons.findCustomerMark(item.getCustomerId(), mark); + if (carton == null || !item.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) + throw conflict("排车来源退货箱缺失"); + return new DispatchPdaResponses.ReturnCarton(carton.getId(), mark, carton.getStatus(), carton.getTotalQuantity()); + }).toList(); + } + + private ReturnInventoryCarton require(DispatchItem item, String mark, String status) { + var carton = cartons.lockMark(mark); + if (carton == null || !item.getCustomerId().equals(carton.getCustomerId()) + || !item.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId()) + || !Objects.equals(item.getPalletNo(), carton.getPalletCode()) + || !Objects.equals(item.getSourceBusinessNo(), carton.getRelabelNo()) || !status.equals(carton.getStatus())) + throw conflict("退货箱已被占用或来源、客户、仓库、托盘不一致"); + return carton; + } + private Set marks(DispatchItem item) { + return new LinkedHashSet<>(Arrays.asList(Objects.toString(item.getCartonMarks(), "").split(","))); + } + private ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java index 6c39fe4e8..5aa78e426 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java @@ -26,6 +26,12 @@ public interface InventoryPositionMapper extends BaseMapper { List listPallet(@Param("customerId") String customerId, @Param("palletId") Long palletId); + /** Physical carriers can contain stock owned by more than one customer. */ + @Select("SELECT COUNT(*) FROM ll_inventory_position WHERE overseas_warehouse_id=#{warehouseId} " + + "AND pallet_id=#{palletId} AND (available_packages>0 OR hold_packages>0 OR allocated_packages>0 " + + "OR picked_packages>0 OR receiving_packages>0)") + long activePhysicalPallet(@Param("warehouseId") String warehouseId, @Param("palletId") Long palletId); + @Select("SELECT * FROM ll_inventory_position WHERE customer_id=#{customerId} AND balance_id=#{balanceId} " + "AND pallet_id=#{palletId} AND batch_no=#{batchNo} LIMIT 1 FOR UPDATE") InventoryPosition lockPosition(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 1e4cf9624..2330c0b53 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -108,7 +108,8 @@ public class PdaPalletOperationService { PalletPhysicalGateway.PalletReference pallet = physical.resolve(carton.getPalletCode()); if (pallet == null || pallet.warehouseId() != identity.overseasWarehouseId()) throw conflict("退货换标来源托盘不属于当前仓库"); - long quantity = value(carton.getTotalQuantity()); + long quantity = entry.getValue().stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) + ? value(carton.getTotalQuantity()) : 1; result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L, pallet.palletId(), pallet.palletCode(), null, carton.getSourceBatchNo(), quantity, quantity, entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList())); @@ -159,11 +160,12 @@ public class PdaPalletOperationService { String fbaValue = destination == null ? pallet.fbaWarehouseCode() : destination; String fba = HtmlUtils.htmlEscape(fbaValue == null ? "" : fbaValue); if (fba == null || fba.isBlank()) fba = "待绑定"; - String html = "" + code - + "

托盘标签

" - + code + "
换标单:" + order + "
FBA目的仓:" + fba + "
"; + String html = "托盘标签 " + code + + "
" + + code + "
" + + com.baiyue.lastleg.returnreceipt.service.ReturnCartonBarcode.svg(pallet.palletCode()) + + "
" + order + "
" + fba + "
"; return new PalletOperationResponses.PrintablePallet(pallet.palletId(), pallet.palletCode(), pallet.version(), relabelNo, html); } @@ -335,7 +337,8 @@ public class PdaPalletOperationService { String requestHash = hash(request); PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash); if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); - physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托"); + var transition = physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托"); + try { return transaction.execute(status -> { for (int index = 0; index < cartons.size(); index++) { RelabelItem item = selected.get(index); @@ -345,7 +348,8 @@ public class PdaPalletOperationService { || !Objects.equals(carton.getRelabelApplicationId(), application.getId()) || !"RELABEL_PENDING".equals(carton.getStatus())) throw conflict("退货换标来源库存已变化,请刷新后重试"); - long move = value(item.getNotifiedPackageCount()); + long move = "RETURN_BULK_SKU_LABEL".equals(item.getOperationType()) + ? value(item.getNotifiedPackageCount()) : value(carton.getTotalQuantity()); if (move <= 0 || move > value(carton.getTotalQuantity())) throw conflict("来源托盘可拆数量不足"); if (move < value(carton.getTotalQuantity())) { splitReturnRelabelCarton(application, item, carton, target, move); @@ -367,6 +371,11 @@ public class PdaPalletOperationService { operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation); return result; }); + } catch (RuntimeException failure) { + try { physical.compensate(identity.overseasWarehouseId(), transition); } + catch (RuntimeException compensationFailure) { failure.addSuppressed(compensationFailure); } + throw failure; + } } /** Keep the unlabelled remainder on its original pallet; move only the confirmed SKU pieces. */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 796f0daf1..845654c94 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -100,6 +100,7 @@ public class RelabelApplicationService implements WalletResultHandler { @Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundPalletItemMapper outboundItems; @Resource private OutboundPdaTaskMapper outboundTasks; + @Resource private com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper deliveryOrders; @Resource private OverseasFeeSourceService feeSources; @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private com.baiyue.lastleg.transfer.service.WarehouseTransferRelabelCompletionService transferRelabelCompletion; @@ -264,6 +265,7 @@ public class RelabelApplicationService implements WalletResultHandler { "INBOUND", null, null, List.of(), 0); }).filter(candidate -> candidate.availablePackages() > 0).toList()); for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, operationWarehouseId.trim())) { + if ("PARCEL".equals(carton.getSourceUnitType())) continue; String keyword = trim(inboundCode); if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getCartonMark(), keyword) @@ -390,6 +392,11 @@ public class RelabelApplicationService implements WalletResultHandler { if (!"AVAILABLE".equals(carton.getStatus()) || carton.getRelabelApplicationId() != null) { throw conflict("仓库唛头 " + carton.getCartonMark() + " 已不可用于换标"); } + if (returnCartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) + throw conflict("退货箱SKU已被合箱计划锁定"); + if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType()) + && (item.totalPackageCount() != 1 || item.notifiedPackageCount() != 1)) + throw conflict("退货箱唛换标按真实箱计数,每个来源箱只能填写一箱"); long previouslyRelabelled = returnCartons.relabelledQuantity(carton.getId(), item.skuCode()); long requestedPackages = selectedReturnPackages.merge(carton.getId(), (long) item.notifiedPackageCount(), Math::addExact); @@ -910,8 +917,13 @@ public class RelabelApplicationService implements WalletResultHandler { carton.setRelabelStatus("COMPLETED"); carton.setRelabelCompletedAt(now); // 换标已完成后释放“当前申请占用”关系,但保留 COMPLETED 作为历史状态。 // 否则退货箱虽显示 AVAILABLE,下一次贴标申请仍会被旧 relabel_application_id 拦截。 - carton.setRelabelApplicationId(null); carton.setRelabelNo(null); - carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + boolean awaitingTruck = requiresOutbound && "TRUCK".equals(application.getDeliveryMethod()); + if (!StringUtils.hasText(application.getOutboundOrderNo()) && !awaitingTruck) { + carton.setRelabelApplicationId(null); carton.setRelabelNo(null); + } + carton.setStatus(StringUtils.hasText(application.getOutboundOrderNo()) ? "OUTBOUND_PENDING" + : awaitingTruck ? "RELABEL_PENDING" : "AVAILABLE"); + carton.setUpdatedAt(now); returnCartons.updateById(carton); } insertHistory(customerId, application.getId(), before, application.getStatus(), operatorId, request.remark(), now); @@ -992,11 +1004,20 @@ public class RelabelApplicationService implements WalletResultHandler { return; } List relabelItems = itemList(application.getId()); + for (RelabelItem source : relabelItems) { + if (source.getReturnCartonId() == null) continue; + ReturnInventoryCarton carton = returnCartons.lockId(source.getReturnCartonId()); + if (carton == null || !application.getCustomerId().equals(carton.getCustomerId()) + || !application.getId().equals(carton.getRelabelApplicationId()) + || !"RELABEL_PENDING".equals(carton.getStatus()) + || intValue(source.getNotifiedPackageCount()) != 1) + throw conflict("退货整箱换标出库必须覆盖该箱全部 SKU 数量:" + source.getCartonMark()); + } OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); - outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> intValue(item.getNotifiedPackageCount())).sum()); + outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> item.getReturnCartonId() == null ? intValue(item.getNotifiedPackageCount()) : 1).sum()); outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count()); outbound.setRecipientInfo(application.getDeliveryMethod()); outbound.setIdempotencyKey(key); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); outboundOrders.insert(outbound); @@ -1012,9 +1033,13 @@ public class RelabelApplicationService implements WalletResultHandler { item.setCustomerId(application.getCustomerId()); item.setOutboundOrderId(outbound.getId()); item.setWarehousePalletId(position == null ? null : position.getPalletId()); item.setPalletNo(position == null ? null : position.getPalletNo()); - item.setPackageCount(group.stream().mapToInt(value -> intValue(value.getNotifiedPackageCount())).sum()); + item.setPackageCount(group.stream().mapToInt(value -> value.getReturnCartonId() == null ? intValue(value.getNotifiedPackageCount()) : 1).sum()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); - item.setCartonMarks(group.stream().map(RelabelItem::getCartonMark).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); + // Return-carton relabel picking must scan the original warehouse carton mark; + // the requested/new carton mark is retained in the relabel snapshot only. + item.setCartonMarks(group.stream() + .map(value -> value.getReturnCartonId() == null ? value.getCartonMark() : value.getSourceCartonMark()) + .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setDestinationWarehouseCode(group.stream().map(RelabelItem::getDestinationFbaWarehouseCode) .filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); item.setInboundCode(group.stream().map(RelabelItem::getSourceInboundCode).filter(StringUtils::hasText).distinct().collect(Collectors.joining(","))); @@ -1024,6 +1049,27 @@ public class RelabelApplicationService implements WalletResultHandler { OutboundPdaTask task = new OutboundPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(application.getCustomerId()); task.setOutboundOrderId(outbound.getId()); task.setTaskNo("PDA" + now.format(DateTimeFormatter.BASIC_ISO_DATE) + task.getId()); task.setStatus("PENDING"); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); outboundTasks.insert(task); + if (relabelItems.stream().allMatch(item -> item.getReturnCartonId() != null)) { + Map> destinations = relabelItems.stream().collect(Collectors.groupingBy( + item -> Objects.toString(item.getDestinationFbaWarehouseCode(), ""), LinkedHashMap::new, Collectors.toList())); + int index = 0; + for (var destination : destinations.entrySet()) { + var delivery = new com.baiyue.lastleg.delivery.entity.DeliveryOrder(); + delivery.setId(IdWorker.getId()); delivery.setCustomerId(application.getCustomerId()); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", application.getOverseasWarehouseId(), delivery.getId())); + delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); + delivery.setOverseasWarehouseId(application.getOverseasWarehouseId()); + delivery.setDeliveryGroupKey("RETURN_RELABEL:" + destination.getKey()); + delivery.setDestinationWarehouseCode(destination.getKey()); + delivery.setDestinationType(StringUtils.hasText(destination.getKey()) ? "FBA" : null); + delivery.setContactName(application.getContactName()); delivery.setContactPhone(application.getContactPhone()); + delivery.setDeliveryMethod(application.getDeliveryMethod()); delivery.setStatus("WAIT_DELIVERY"); + delivery.setPackageCount(destination.getValue().size()); delivery.setPalletCount(0); + delivery.setIdempotencyKey(key + ":DELIVERY:" + index++); delivery.setVersion(0L); + delivery.setCreatedBy(operatorId); delivery.setCreatedAt(now); delivery.setUpdatedAt(now); + deliveryOrders.insert(delivery); + } + } application.setOutboundOrderNo(outbound.getOutboundNo()); } @@ -1071,6 +1117,7 @@ public class RelabelApplicationService implements WalletResultHandler { ReturnInventoryCarton carton = sourceInventory.returnCarton(); item.setReturnCartonId(carton.getId()); item.setSourceCartonMark(carton.getCartonMark()); item.setSourceInboundOrderNo(carton.getSourceReturnNo()); + item.setSourceInboundCode(carton.getSourceReturnNo()); item.setSourceWarehouseId(carton.getOverseasWarehouseId()); } item.setCreatedAt(now); items.insert(item); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java index be761f0a4..88cb5602f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java @@ -44,6 +44,14 @@ public class RelabelBindingGuard { } catch (Exception e) { throw conflict("组托记录无法读取,请核对后重试"); } } for (RelabelItem item : cartons) { + if (item.getReturnCartonId() != null && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + var carton = returnCartons.selectById(item.getReturnCartonId()); + if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失"); + if (!"TRUCK".equals(app.getDeliveryMethod())) continue; + if (!bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())) + throw conflict("退货箱尚未完成排车托盘绑定"); + continue; + } if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnPackingPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnPackingPlanController.java new file mode 100644 index 000000000..c0475a619 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnPackingPlanController.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.returnreceipt.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +@RestController +@RequestMapping("/api/merchant/v1/last-leg/return-packing-plans") +public class MerchantReturnPackingPlanController { + @Resource private ReturnInventoryService service; + + @GetMapping + @PreAuthorize("hasAuthority('merchant:overseas-warehouse:return-inventory:list')") + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, @Valid ReturnInventoryRequests.PackingPlanQuery request) { + return ApiResponse.success(service.customerPackingPlans(customer(user), request)); + } + + @PostMapping + @PreAuthorize("hasAuthority('merchant:overseas-warehouse:return-inventory:list')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReturnInventoryRequests.PackingPlanCreate request) { + return ApiResponse.success(service.createPackingPlan(user == null ? "merchant" : user.username(), customer(user), request)); + } + + private String customer(AuthenticatedUser user) { + if (user == null || user.tenantId() == null || "PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); + return String.valueOf(user.tenantId()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java index 98a8da122..9702c17b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java @@ -35,9 +35,47 @@ public class PdaReturnInventoryController { return ApiResponse.success(service.merge(PdaWarehouseIdentity.require(user), request)); } + @PostMapping("/cartons/pack") + public ApiResponse pack(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReturnInventoryRequests.PackingExecution request) { + return ApiResponse.success(service.pack(PdaWarehouseIdentity.require(user), request)); + } + + @GetMapping("/cartons/{cartonMark}") + public ApiResponse scanCarton(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String cartonMark) { + return ApiResponse.success(service.scanCarton(PdaWarehouseIdentity.require(user), cartonMark)); + } + + @PostMapping("/cartons/merge-boxes") + public ApiResponse mergeBoxes(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody ReturnInventoryRequests.BoxMerge request) { + return ApiResponse.success(service.mergeBoxes(PdaWarehouseIdentity.require(user), request)); + } + @GetMapping("/cartons/{cartonMark}/label") public ApiResponse label(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String cartonMark) { return ApiResponse.success(service.label(PdaWarehouseIdentity.require(user), cartonMark)); } + + @PostMapping("/cartons/{cartonMark}/putaway") + public ApiResponse putaway(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String cartonMark, @Valid @RequestBody ReturnInventoryRequests.Putaway request) { + return ApiResponse.success(service.putaway(PdaWarehouseIdentity.require(user), cartonMark, request)); + } + + @GetMapping("/packing/plans") + public ApiResponse> plans(@AuthenticationPrincipal AuthenticatedUser user) { + return ApiResponse.success(service.listPackingPlans(PdaWarehouseIdentity.require(user))); + } + + @GetMapping("/packing/plans/{planNo}") + public ApiResponse plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo) { + return ApiResponse.success(service.packingPlan(PdaWarehouseIdentity.require(user), planNo)); + } + + @PostMapping("/packing/plans/{planNo}/complete") + public ApiResponse completePlan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String planNo, @Valid @RequestBody ReturnInventoryRequests.PackingPlanComplete request) { + return ApiResponse.success(service.completePackingPlan(PdaWarehouseIdentity.require(user), planNo, request)); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java index c3547af94..a12f8cebd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java @@ -77,7 +77,7 @@ public class PdaReturnReceiptController { String code = org.springframework.web.util.HtmlUtils.htmlEscape(pallet.palletCode()); String returnNo = org.springframework.web.util.HtmlUtils.htmlEscape(request.returnNo()); String sku = org.springframework.web.util.HtmlUtils.htmlEscape(request.skuCode()); - String html = "" + code + "

退货托盘标签

" + code + "
退货单:" + returnNo + "
SKU:" + sku + "
"; + String html = "" + code + "
" + code + "
" + returnNo + "
" + sku + "
"; return ApiResponse.success(new Pallet(pallet.palletCode(), pallet.palletId(), pallet.status(), html)); } @PostMapping("/pallets/check") public ApiResponse checkPallet(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnInventoryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnInventoryController.java index 58e99a99e..22e8be5e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnInventoryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnInventoryController.java @@ -36,6 +36,12 @@ public class PlatformReturnInventoryController { return ApiResponse.success(service.platformMovementPage(request)); } + @GetMapping("/packing-sources") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('platform:overseas-warehouse:return-cartons:list')") + public ApiResponse> packingSources(@Valid ReturnInventoryRequests.PlatformQuery request) { + return ApiResponse.success(service.platformPackingSourcePage(request)); + } + @GetMapping("/cartons/{cartonMark}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-cartons:list')") public ApiResponse carton(@PathVariable String cartonMark) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java index ef7009596..4d2578fcc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java @@ -14,6 +14,20 @@ import org.springframework.web.bind.annotation.*; @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')") public class PlatformReturnOrderController { @Resource private ReturnReceiptService service; + @Resource private com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService inventory; + @PostMapping("/{returnNo}/receipts/{receiptNo}/repair-cartons") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('lastleg:inventory:manage')") + public ApiResponse> repairCartons( + @org.springframework.security.core.annotation.AuthenticationPrincipal com.baiyue.common.security.AuthenticatedUser user, + @RequestHeader("X-Customer-Id") String customerId, @PathVariable String returnNo, @PathVariable String receiptNo, + @Valid @RequestBody com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests.ReceiptCartonRepair request) { + return ApiResponse.success(inventory.repairReceiptCartons("platform:" + user.username(), customerId, returnNo, receiptNo, request)); + } + @GetMapping("/{returnNo}") + public ApiResponse detail(@RequestHeader("X-Customer-Id") String customerId, + @PathVariable String returnNo) { + return ApiResponse.success(service.detail(customerId, returnNo)); + } @PostMapping("/{returnNo}/review") public ApiResponse review(@RequestHeader("X-Customer-Id") String customerId, @PathVariable String returnNo, @Valid @RequestBody ReturnOrderRequests.Review request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnPackingPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnPackingPlanController.java new file mode 100644 index 000000000..14c3ff1c4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnPackingPlanController.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.returnreceipt.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryRequests; +import com.baiyue.lastleg.returnreceipt.dto.ReturnInventoryResponses; +import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/return-packing-plans") +public class PlatformReturnPackingPlanController { + @Resource private ReturnInventoryService service; + + @PostMapping + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('lastleg:inventory:manage')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody ReturnInventoryRequests.PackingPlanCreate request) { + return ApiResponse.success(service.createPackingPlan(user == null ? "platform" : user.username(), request)); + } + + @GetMapping + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list')") + public ApiResponse> list(@Valid ReturnInventoryRequests.PackingPlanQuery request) { + return ApiResponse.success(service.platformPackingPlans(request)); + } + + @PostMapping("/{planNo}/review") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('lastleg:inventory:manage')") + public ApiResponse review(@PathVariable String planNo) { + return ApiResponse.success(service.reviewPackingPlanPlatform(planNo)); + } + + @PostMapping("/{planNo}/cancel") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:return-inventory:list') and hasAuthority('lastleg:inventory:manage')") + public ApiResponse cancel(@PathVariable String planNo) { + return ApiResponse.success(service.cancelPackingPlan(planNo)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java index d5d6a6d62..2d16026cd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.util.List; +import java.math.BigDecimal; public final class PdaReturnReceiptRequests { private PdaReturnReceiptRequests() { } @@ -18,10 +19,25 @@ public final class PdaReturnReceiptRequests { public record Confirm(@NotBlank @Size(max = 128) String returnNo, @Size(max = 128) String trackingNo, @NotBlank @Size(max = 16) String startedBy, - @NotBlank @Size(max = 128) String palletCode, + @Size(max = 128) String palletCode, @NotEmpty @Size(max = 500) List<@Valid Item> items, - @NotBlank @Size(max = 255) String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 16) String unitType, + @Size(max = 128) String unitCode, + BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, + Long totalBoxCount) { + public Confirm(String returnNo, String trackingNo, String startedBy, String palletCode, + List<@Valid Item> items, String idempotencyKey) { + this(returnNo, trackingNo, startedBy, palletCode, items, idempotencyKey, + null, null, null, null, null, null, null); + } + } public record Item(@NotBlank @Size(max = 128) String skuCode, @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class) - @Positive long receivedQuantity) { } + @Positive long receivedQuantity, + Long boxCount) { + public Item(String skuCode, long receivedQuantity) { + this(skuCode, receivedQuantity, null); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java index d60e7bb1b..e06373664 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java @@ -16,5 +16,7 @@ public final class PdaReturnReceiptResponses { public record Item(String skuCode, String productNameZh, long expectedQuantity, long receivedQuantity) { } public record Confirmed(String returnNo, String receiptNo, String receiptBatchNo, String palletCode, String orderStatus, boolean fullyMatched, long expectedQuantity, long receivedQuantity, - LocalDateTime signedAt) { } + LocalDateTime signedAt, String unitType, String unitCode, + String cartonMark, String printableHtml, long totalBoxCount, + List labels) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java index 693bf18f7..e3f4d37d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java @@ -14,6 +14,8 @@ import java.util.List; public final class ReturnInventoryRequests { private ReturnInventoryRequests() { } + public record ReceiptCartonRepair(@Positive long confirmedBoxCount, + @NotBlank @Size(max = 255) String idempotencyKey) { } public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String overseasWarehouseId, String skuCode, String cartonMark, String status, String returnNo) { @@ -31,7 +33,46 @@ public final class ReturnInventoryRequests { public PlatformQuery { if (page == 0) page = 1; if (size == 0) size = 20; } } - public record PackingQuery(@Size(max = 128) String returnNo) { } + public record PackingQuery(@Size(max = 128) String returnNo, @Size(max = 128) String skuCode) { } + + public record PackingPlanQuery(@Min(1) long page, @Min(1) @Max(200) long size, String status, @Size(max = 128) String planNo, + @Size(max = 64) String customerId, @Size(max = 64) String overseasWarehouseId, @Size(max = 128) String skuCode) { + public PackingPlanQuery { if (page == 0) page = 1; if (size == 0) size = 20; } + } + + public record PackingPlanCreate(@NotEmpty @Size(max = 100) List<@NotBlank @Size(max = 128) String> returnNos, + @NotBlank @Size(max = 40) String mode, + @NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes, + @Size(max = 1000) String remark, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 500) List<@NotBlank String> sourceCartonMarks) { + public PackingPlanCreate(List returnNos, String mode, List boxes, String remark, String idempotencyKey) { + this(returnNos, mode, boxes, remark, idempotencyKey, List.of()); + } + } + + public record PlanBox(@Size(max = 100) List<@Valid PlanItem> items, + @Positive BigDecimal length, @Positive BigDecimal width, + @Positive BigDecimal height, @Positive BigDecimal weight, + @Size(max = 128) String targetCartonMark) { + public PlanBox(List items, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) { + this(items, length, width, height, weight, null); + } + } + + public record PlanItem(@NotBlank @Size(max = 128) String skuCode, + @Positive long quantity) { } + + public record PackingPlanComplete(@NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions) { + public PackingPlanComplete(List boxes, String idempotencyKey) { this(boxes, idempotencyKey, null); } + } + public record SourceDeduction(@NotBlank String cartonMark, @NotBlank String skuCode, @Positive long quantity) { } + + public record PackingExecution(@NotEmpty @Size(max = 100) List<@NotBlank String> returnNos, + @NotEmpty @Size(max = 100) List<@Valid PlanBox> boxes, + @NotBlank @Size(max = 180) String idempotencyKey) { } public record Merge(@NotEmpty @Size(min = 1, max = 100) List<@NotBlank @Size(max = 128) String> returnNos, @NotEmpty @Size(max = 500) List<@Valid ValidItem> items, @@ -44,4 +85,19 @@ public final class ReturnInventoryRequests { public record ValidItem(@NotBlank @Size(max = 128) String skuCode, @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class) @Positive long quantity) { } + + public record Putaway(@NotBlank @Size(max = 128) String locationCode, + @Size(max = 128) String palletCode, + @NotBlank @Size(max = 255) String idempotencyKey) { } + + public record BoxMerge(@NotEmpty @Size(max = 100) List<@NotBlank String> sourceCartonMarks, + String targetCartonMark, + @NotBlank @Size(max = 255) String idempotencyKey, + @Size(max = 1000) List<@Valid SourceDeduction> sourceDeductions, + @Positive BigDecimal length, @Positive BigDecimal width, + @Positive BigDecimal height, @Positive BigDecimal weight) { + public BoxMerge(List sourceCartonMarks, String targetCartonMark, String idempotencyKey) { + this(sourceCartonMarks, targetCartonMark, idempotencyKey, null, null, null, null, null); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java index 5130349d8..9da92ed40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java @@ -6,6 +6,8 @@ import java.util.List; public final class ReturnInventoryResponses { private ReturnInventoryResponses() { } + public record PackingSource(String cartonMark, String sourceReturnNo, String sourceUnitType, + List items) { } public record CartonSummary(String cartonMark, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status, int skuTypes, long totalQuantity, @@ -20,22 +22,42 @@ public final class ReturnInventoryResponses { public record CartonDetail(String cartonMark, String customerId, String overseasWarehouseId, String sourceBatchNo, String sourceReturnNo, String status, String fbaCartonMark, String destinationWarehouseCode, String destinationWarehouseName, String relabelNo, String relabelStatus, int skuTypes, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, - BigDecimal weight, LocalDateTime receivedAt, List items) { } - public record CartonItem(String skuCode, String productNameZh, long quantity) { } + BigDecimal weight, LocalDateTime receivedAt, List items, + String locationCode, String palletCode, LocalDateTime putawayAt, List mergeHistory) { } + public record MergeHistory(String planNo, String sourceCartonMark, String targetCartonMark, String returnNo, + String skuCode, long quantity, LocalDateTime createdAt) { } + public record CartonItem(String skuCode, String productNameZh, long quantity, long availableQuantity) { + public CartonItem(String skuCode, String productNameZh, long quantity) { this(skuCode, productNameZh, quantity, quantity); } + } public record Label(String cartonMark, String overseasWarehouseId, String customerId, String sourceBatchNo, String sourceReturnNo, long totalQuantity, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight, List items, LocalDateTime generatedAt, String printableHtml) { } - public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { } + public record MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label, String planNo) { + public MergeResult(String cartonMark, String skuCode, long totalQuantity, Label label) { this(cartonMark, skuCode, totalQuantity, label, null); } + } + public record PutawayResult(String cartonMark, String locationCode, String palletCode, LocalDateTime putawayAt) { } public record PackingOrder(String returnNo, String customerId, String customerReferenceNo, String status) { } public record PackingSku(String skuCode, String productNameZh, long availableQuantity) { } - public record PackingPreview(List orders, String customerId, List skus) { } + public record PackingCustomer(String customerId, List orders, List skus) { } + public record PackingPreview(List orders, String customerId, List skus, List customers) { + public PackingPreview(List orders, String customerId, List skus) { this(orders, customerId, skus, List.of()); } + } + public record PackingPlan(String planNo, String customerId, String overseasWarehouseId, List returnNos, + String mode, String status, List boxes, String remark, LocalDateTime pushedAt, + List sourceCartonMarks, List sources, + LocalDateTime createdAt, List resultCartons, LocalDateTime completedAt) { } + public record PlanSource(String cartonMark, List items) { } + public record PlanBox(int boxNo, List items, BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal weight) { } + public record PlanItem(String skuCode, long quantity) { } + public record PackingPlanCompletion(String planNo, List