diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 71f896806..005c544bd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -16527,6 +16527,7 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN') AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; +-- PLATFORM_CUSTOM_ADMIN needs access to the overseas outbound and delivery lists shown in the admin navigation. INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, NOW(3) FROM baiyue_iam.iam_role role diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 3ab88262a..8a9e73ef3 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -41,7 +41,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(54).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(56).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); assertThat(sql).doesNotContain(":carton:", ":product:"); assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index ee428cfe3..bec72d461 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -93,7 +93,7 @@ - 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 - 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 - 货物废弃只使用可核对 SKU 和箱唛的一件代发库存。创建申请只能将箱从 `AVAILABLE` 锁定为 `DISCARD_PENDING`,不得扣减 SKU 箱数;实际扣库存节点固定为具备 `lastleg:cargo-discard:approve` 权限的仓库/PDA 审核人确认废弃时,并且必须在审核事务内调用专用 discard adjustment。该 adjustment 必须将箱改为 `DISCARDED`、写逐箱业务流水及唯一幂等键,并校验 `扣减前箱数 - 废弃箱数 = 扣减后箱数` 且逐箱数等于申请总箱数;任一环节失败须整体回滚。拒绝申请只释放锁箱,不得扣库存。 -- 海外采购客户下单固定进入 `PENDING_REVIEW`;四期采购只维护采购订单、审核、入库数量、候选快照和操作流水,不提供采购出库端点、出库记录或出库数量。采购单据不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 +- 海外采购客户下单固定进入 `PENDING_REVIEW`;采购编辑可维护入库数量和采购出库明细,出库量为明细数量汇总,审核通过且有入库量后按 `PENDING_OUTBOUND`、`PARTIALLY_OUTBOUND`、`OUTBOUNDED` 派生展示状态。采购出库只记录采购单据流转,不得写入或冒充 `available_packages`;只有海外仓 PDA 完成实收后,`InboundStockReceiptService` 才能以 `PDA_STOCK_IN:{taskId}:{allocationId}` 幂等键调用库存 `stockIn` 转入可用库存。禁止通过通用平台接口绕过 PDA 入可用库存。 - 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 - `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index c02100a12..d9762c336 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -32,6 +32,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @@ -121,15 +122,16 @@ public class LastlegOrderController { @GetMapping(value = "/api/platform/v1/last-leg/orders/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EXPORT + "')") @Operation(summary = "Export filtered tail-leg orders as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EXPORT) - public ResponseEntity export( + public ResponseEntity export( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid LastlegOrderRequests.Query query) { byte[] body = orderService.exportCsv(platformScope(user, customerId), query); + StreamingResponseBody stream = output -> { output.write(body); output.flush(); }; return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") - .body(body); + .body(stream); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 5557f75f8..0e2382d7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -13,8 +13,8 @@ import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; 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; @@ -78,8 +78,8 @@ public class DeliveryTransferController { @PreAuthorize("hasAuthority('" + OverseasPermissions.DELIVERY_LIST + "')") public ApiResponse deliveryDetail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, - @PathVariable String deliveryNo, @Valid DeliveryRequests.PalletQuery query) { - return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo, query)); + @PathVariable String deliveryNo) { + return ApiResponse.success(service.deliveryDetail(scope(user, customerId), deliveryNo)); } private String scope(AuthenticatedUser user, String requestedCustomer) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java index 1738849ce..a2eea0102 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -33,9 +33,8 @@ public class MerchantDeliveryController { @GetMapping("/{deliveryNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String deliveryNo, - @Valid DeliveryRequests.PalletQuery query) { - return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo, query)); + @PathVariable String deliveryNo) { + return ApiResponse.success(service.deliveryDetail(customer(user), deliveryNo)); } private String customer(AuthenticatedUser user) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 6249a645f..35e419205 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -341,8 +341,7 @@ public class DeliveryTransferService { return page(page, page.getRecords().stream().map(this::deliveryRow).toList()); } - public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo, - DeliveryRequests.PalletQuery query) { + public DeliveryResponses.DeliveryDetail deliveryDetail(String customerId, String deliveryNo) { DeliveryOrder delivery = deliveryOrders.findByNo(customerId, deliveryNo); if (delivery == null) throw notFound("Delivery order not found"); List items = dispatchItems.findByDispatchOrder(customerId, delivery.getDispatchOrderId()); @@ -398,7 +397,7 @@ public class DeliveryTransferService { } private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getCreatedAt()); } private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt()); } - private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } + private DeliveryResponses.DeliverySku sku(LastlegOrderDetail d, LastlegOrder order) { return new DeliveryResponses.DeliverySku(order == null ? null : order.getInboundCode(), order == null ? null : order.getCustomerName(), d.getDestinationWarehouseCode(), null, d.getShipmentNo(), d.getFbaTrackingNo(), d.getSkuCode(), firstText(d.getProductNameZh(), d.getProductNameEn()), value(d.getPackageCount()), d.getGrossWeightKg(), d.getVolumeCbm()); } private DeliveryResponses.DeliveryFeeRow feeRow(DeliveryFee f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getFeeDirection(), f.getFeeType(), f.getCurrency(), f.getAmount(), f.getRemark()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } 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 96da4a278..19b5979d4 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 @@ -119,6 +119,11 @@ public class DispatchPdaWorkflowService { inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, keyBase + ":PICK", request.remark())); + // A successful PDA scan is the inventory deduction point. Departure only + // changes outbound/dispatch facts and must not deduct the same quantity again. + 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.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); @@ -197,14 +202,7 @@ public class DispatchPdaWorkflowService { } List dispatchPicks = picks.listTask(customerId, task.getId()); if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); - long shipped = 0; - for (DispatchPdaPick pick : dispatchPicks) { - String key = "DISPATCH:" + order.getDispatchNo() + ":PICK:" + pick.getId() + ":SHIP"; - inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move( - pick.getInventoryPositionId(), n(pick.getPackages()), - outbound.getOutboundNo(), outbound.getOutboundNo(), null, key, request.remark())); - shipped += n(pick.getPackages()); - } + long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); LocalDateTime now = LocalDateTime.now(); pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); outbound.setStatus("OUTBOUNDED"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index fb5b95346..d0a5ca5c7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.List; @Schema(description = "按入仓单编码粒度展示的尾程订单列表行") public record LastlegOrderSummary( @@ -40,6 +41,8 @@ public record LastlegOrderSummary( String relabelOrderNo, String relabelStatus, String palletMergeFlag, + String palletMergeBatchId, + List palletMergeOrderNos, String currentStatus, String subOrderStatus, String privateRemark, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java index 3f8c3da74..a27e02867 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java @@ -11,6 +11,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; import com.baiyue.lastleg.inboundplan.dto.UpdateFbaAppointmentRequest; +import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import com.baiyue.lastleg.inboundplan.service.InboundPlanService; import com.baiyue.lastleg.security.OverseasPermissions; @@ -62,6 +63,15 @@ public class InboundPlanController { return ApiResponse.success(service.detailByPlanNo(scope(user, customerId), planNo)); } + @PutMapping("/{planNo}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") + @Operation(summary = "编辑入仓计划") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String planNo, + @Valid @RequestBody UpdateInboundPlanRequest request) { + return ApiResponse.success(service.update(scope(user, request.customerId()), planNo, request)); + } + @PostMapping("/{planNo}/pallets/supplement") @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_EDIT + "')") /** 为计划目标仓新增一个可打印的补充托盘。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java new file mode 100644 index 000000000..77bf05034 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.inboundplan.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record UpdateInboundPlanRequest( + @NotBlank String customerId, + @NotNull Long overseasWarehouseId, + String warehouseCode, + String warehouseName, + @NotNull Integer version +) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 3201c9d1f..390204228 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -7,6 +7,7 @@ import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse; import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests; import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse; import com.baiyue.lastleg.inboundplan.dto.SupplementPalletRequest; +import com.baiyue.lastleg.inboundplan.dto.UpdateInboundPlanRequest; import com.baiyue.lastleg.inboundplan.entity.InboundPlan; import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; import com.baiyue.lastleg.inboundplan.entity.InboundPlanPallet; @@ -219,6 +220,19 @@ public class InboundPlanService { return detail(customerId, plan); } + @Transactional + public InboundPlanResponse update(String customerId, String planNo, UpdateInboundPlanRequest request) { + InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getPlanNo, planNo).eq(customerId != null, InboundPlan::getCustomerId, customerId).last("FOR UPDATE")); + if (plan == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Inbound plan not found"); + if (!java.util.Set.of("PENDING_ASSIGNMENT", "ASSIGNED").contains(plan.getStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "Inbound plan has started and cannot be edited"); + plan.setOverseasWarehouseId(request.overseasWarehouseId()); + plan.setUpdatedAt(LocalDateTime.now()); + planMapper.updateById(plan); + return detail(customerId, plan); + } + @Transactional public InboundPlanResponse.Pallet supplementPallet(String customerId, String planNo, SupplementPalletRequest request) { InboundPlan plan = planMapper.selectOne(new LambdaQueryWrapper() diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 87226951e..eeea51bf8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -24,6 +24,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @@ -80,7 +81,7 @@ public class InventoryQueryController { @GetMapping(value = BASE_PATH + "/balances/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") @Operation(summary = "Export filtered inbound-order inventory as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) - public ResponseEntity exportBalances( + public ResponseEntity exportBalances( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.BalanceQuery query) { @@ -90,18 +91,19 @@ public class InventoryQueryController { @GetMapping(value = BASE_PATH + "/ledger/export", produces = "text/csv") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_EXPORT + "')") @Operation(summary = "Export filtered inventory movements as UTF-8 CSV", description = "Required authority: " + OverseasPermissions.INVENTORY_EXPORT) - public ResponseEntity exportLedgers( + public ResponseEntity exportLedgers( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid InventoryQueryRequests.LedgerQuery query) { return csv(service.exportLedgers(platformScope(user, customerId), query), "inventory-ledger.csv"); } - private static ResponseEntity csv(byte[] body, String filename) { + private static ResponseEntity csv(byte[] body, String filename) { + StreamingResponseBody stream = output -> { output.write(body); output.flush(); }; return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=" + filename) - .body(body); + .body(stream); } private String platformScope(AuthenticatedUser user, String requestedCustomerId) { 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 b07c332a9..ddb631144 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 @@ -7,8 +7,24 @@ import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; +import java.util.List; + @Mapper public interface LastlegOrderMapper extends BaseMapper { + record PalletMergeRelationRow(Long orderId, String relatedOrderNo, String mergeGroupNo) {} + + @Select("") + List findPalletMergeRelations(@Param("orderIds") List orderIds); + @Select("SELECT * FROM ll_order WHERE customer_id=#{customerId} AND source_region=#{sourceRegion} " + "AND source_inbound_id=#{sourceInboundId} LIMIT 1") LastlegOrder findSource(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index b8a887139..7a472e84d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -31,7 +31,9 @@ import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; @Service @@ -53,7 +55,8 @@ public class LastlegOrderService { public PageResult page(String customerId, LastlegOrderRequests.Query query) { Page page = orderMapper.selectPage( new Page<>(query.page(), query.size()), filter(customerId, query).orderByDesc(LastlegOrder::getCreatedAt)); - return new PageResult<>(page.getRecords().stream().map(this::summary).toList(), + Map> mergeRelations = palletMergeRelations(page.getRecords()); + return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -75,7 +78,7 @@ public class LastlegOrderService { .eq(LastlegOrderOperationLog::getCustomerId, order.getCustomerId()) .eq(LastlegOrderOperationLog::getOrderId, orderId) .orderByAsc(LastlegOrderOperationLog::getCreatedAt)); - return new LastlegOrderDetailResponse(summary(order), cargo, history.stream() + return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId())), cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -294,7 +297,20 @@ public class LastlegOrderService { } } - private LastlegOrderSummary summary(LastlegOrder o) { + private Map> palletMergeRelations(List orders) { + if (orders.isEmpty()) return Map.of(); + Map> result = new LinkedHashMap<>(); + List rows = orderMapper.findPalletMergeRelations( + orders.stream().map(LastlegOrder::getId).toList()); + if (rows == null) return Map.of(); + for (LastlegOrderMapper.PalletMergeRelationRow row : rows) { + result.computeIfAbsent(row.orderId(), ignored -> new ArrayList<>()).add(row); + } + return result; + } + + private LastlegOrderSummary summary(LastlegOrder o, List relations) { + List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), o.getOrderNo(), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), o.getInboundCode(), o.getSourceOrderNo(), o.getCustomerId(), o.getCustomerName(), o.getOverseasWarehouseId(), @@ -303,7 +319,9 @@ public class LastlegOrderService { o.getAbnormal(), o.getDetained(), o.getPalletCount(), o.getPackageCount(), o.getVolumeCbm(), o.getProductQuantity(), o.getVolumeWeightKg(), o.getEstimatedArrivalAt(), o.getActualArrivalAt(), o.getEstimatedWarehouseAt(), o.getInboundOrderNo(), o.getRelabelOrderNo(), o.getRelabelStatus(), - o.getPalletMergeFlag(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), + o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), + related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedOrderNo).filter(Objects::nonNull).distinct().toList(), + o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt()); } 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 e4e46e484..48cf108a2 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -580,7 +580,7 @@ components: feeItem: { type: string, minLength: 1, maxLength: 128 } currency: { type: string, pattern: '^[A-Z]{3}$', example: USD } amount: { type: number, format: decimal, minimum: 0.01 } - status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED, PENDING_OUTBOUND, PARTIALLY_OUTBOUND, OUTBOUNDED] } + status: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] } supplierDescription: { type: string, maxLength: 500 } LastMileFee: type: object diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java index 7e5702813..6288bad5f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java @@ -23,6 +23,9 @@ class OverseasProcurementInventoryBoundaryTest { assertFalse(source.contains("availablePackages")); assertFalse(source.contains("addOutbound")); assertFalse(source.contains("OverseasProcurementOutbound")); - assertFalse(source.contains("outboundQuantity")); + String responses = Files.readString(Path.of( + "src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java"), + StandardCharsets.UTF_8); + assertTrue(responses.contains("outboundQuantity")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 08ec9fc11..f96d0f514 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -64,9 +64,32 @@ class OverseasProcurementServiceTest { assertEquals(OverseasProcurementService.PENDING_REVIEW, detail.summary().status()); assertEquals(0, detail.summary().inboundQuantity()); + assertEquals(0, detail.summary().outboundQuantity()); assertEquals(0, detail.summary().inventoryQuantity()); } + @Test + void outboundQuantityAndDisplayStatusFollowOutboundDetails() { + OverseasProcurementOrder order = order(OverseasProcurementService.APPROVED, 90, 0); + when(orders.findNo("100", "HC1")).thenReturn(order); + when(histories.listOrder("100", 1L)).thenReturn(List.of()); + + order.setOutboundDetails("[]"); + assertEquals("PENDING_OUTBOUND", service.detail("100", "HC1").summary().status()); + + order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":1}]"); + var partial = service.detail("100", "HC1").summary(); + assertEquals(1, partial.outboundQuantity()); + assertEquals(89, partial.inventoryQuantity()); + assertEquals("PARTIALLY_OUTBOUND", partial.status()); + + order.setOutboundDetails("[{\"id\":\"1\",\"quantity\":90}]"); + var completed = service.detail("100", "HC1").summary(); + assertEquals(90, completed.outboundQuantity()); + assertEquals(0, completed.inventoryQuantity()); + assertEquals("OUTBOUNDED", completed.status()); + } + @Test void pendingOrderCannotReceiveInventory() { OverseasProcurementOrder order = order(OverseasProcurementService.PENDING_REVIEW, 0, 0); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 6284fcb56..bb7ac8c31 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -193,6 +193,26 @@ class LastlegOrderServiceTest { assertEquals(25, page.getValue().getSize()); } + @Test + void pageReturnsConfirmedPalletMergeRelatedOrderNumbers() { + LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IB-1"); + order.setPalletMergeFlag("MERGED"); + var resultPage = new com.baomidou.mybatisplus.extension.plugins.pagination.Page(1, 10); + resultPage.setRecords(List.of(order)); + resultPage.setTotal(1); + when(orders.selectPage(any(), any())).thenReturn(resultPage); + when(orders.findPalletMergeRelations(List.of(11L))).thenReturn(List.of( + new LastlegOrderMapper.PalletMergeRelationRow(11L, "LMO-RELATED-2", "PMG-1"), + new LastlegOrderMapper.PalletMergeRelationRow(11L, "LMO-RELATED-3", "PMG-1"))); + var query = new LastlegOrderRequests.Query(1, 10, null, null, null, null, + null, null, null, null, null, null, null, null, null); + + var result = service.page(null, query); + + assertEquals("PMG-1", result.records().get(0).palletMergeBatchId()); + assertEquals(List.of("LMO-RELATED-2", "LMO-RELATED-3"), result.records().get(0).palletMergeOrderNos()); + } + @Test void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() { LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null);