package com.baiyue.lastleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.event.EventEnvelope; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dto.LastlegOrderDetailResponse; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.dto.LastlegOrderCommandDtos; import com.baiyue.lastleg.dto.ContainerPlanProgressDetail; import com.baiyue.lastleg.dto.ContainerPlanProgressSummary; import com.baiyue.lastleg.entity.LastlegOrderFee; import com.baiyue.lastleg.security.InternalServiceTokenGuard; import com.baiyue.lastleg.security.OverseasPermissions; import com.baiyue.lastleg.service.LastlegOrderService; import com.baiyue.lastleg.service.LastlegOrderCommandService; import com.baiyue.lastleg.service.LoadingCompletedConsumer; import com.baiyue.lastleg.shipping.service.ContainerShippingNodeConsumer; import com.baiyue.common.event.EventTypes; import com.baiyue.lastleg.wallet.WalletCommandCompletedConsumer; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.annotation.Resource; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; 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.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @RestController @Tag(name = "Tail-leg orders") public class LastlegOrderController { @Resource private LoadingCompletedConsumer consumer; @Resource private ContainerShippingNodeConsumer shippingNodeConsumer; @Resource private LastlegOrderService orderService; @Resource private LastlegOrderCommandService commandService; @Resource private InternalServiceTokenGuard tokenGuard; @Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer; @PostMapping("/internal/lastleg/events") @Operation(summary = "Receive a first-leg loading or actual shipping-node event", description = "Accepts loading-completed V1/V2 and FirstLegContainerShippingNodeRecorded V1 through the internal token boundary; only monotonic actual PICKUP_READY-or-later facts open overseas PDA receiving.") public ApiResponse receive( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { tokenGuard.verify(token); int consumed; if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { consumed = shippingNodeConsumer.consume(event); } else if (EventTypes.WALLET_COMMAND_COMPLETED.equals(event.eventType())) { consumed = walletCommandCompletedConsumer.consume(event); } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) { consumed = consumer.consume(event); } else { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType()); } return ApiResponse.success(consumed); } @GetMapping("/api/platform/v1/last-leg/orders") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "Page tail-leg order rows", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse> list( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid LastlegOrderRequests.Query query) { return ApiResponse.success(orderService.page(platformScope(user, customerId), query)); } @GetMapping("/api/platform/v1/last-leg/orders/container-summaries") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "分页查询待分配柜级摘要", description = "按稳定 sourceContainerBookingId 聚合待分配尾程订单;平台跨客户查询。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse> containerSummaries( @AuthenticationPrincipal AuthenticatedUser user, @Valid LastlegOrderRequests.ContainerProgressQuery query) { platformScope(user, null); return ApiResponse.success(orderService.containerProgressPage(query)); } @GetMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "查询柜级入库计划进度详情", description = "按稳定 sourceContainerBookingId 返回柜内全部尾程订单、入仓码及各自计划状态,不受订单分页影响。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse containerSummaryDetail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String sourceContainerBookingId) { platformScope(user, null); return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId)); } @PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/receiving-warehouse") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "修改整柜海外接柜仓", description = "仅在海外PDA建立接柜会话前允许修改;按 expectedVersion 乐观锁原子同步柜内订单与入库计划。") public ApiResponse changeReceivingWarehouse( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String sourceContainerBookingId, @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody LastlegOrderRequests.ChangeReceivingWarehouse request) { platformScope(user, null); return ApiResponse.success(orderService.changeReceivingWarehouse(sourceContainerBookingId, idempotencyKey, request, operatorName(user))); } @GetMapping("/api/platform/v1/last-leg/orders/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "Get one inbound-code-level tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id) { return ApiResponse.success(orderService.detail(platformScope(user, customerId), id)); } @PostMapping("/api/platform/v1/last-leg/orders/{id}/fees") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "Add a fee to one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse createFee( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, @Valid @RequestBody LastlegOrderRequests.CreateFee request) { return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request, operatorName(user))); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/hold") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "暂扣或解除单票尾程订单", description = "要求版本与幂等键;暂扣只作用于该票并阻断其出库推进。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse hold( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody LastlegOrderCommandDtos.HoldRequest request) { return ApiResponse.success(commandService.hold(platformScope(user, customerId), id, idempotencyKey, request, operatorId(user), operatorName(user))); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "保存单票人工节点覆盖", description = "首次保存后该票永久使用 MANUAL 节点,不再回退柜级节点;当前无清除或恢复继承接口。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse saveManualNodes( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, @RequestHeader("Idempotency-Key") String idempotencyKey, @Valid @RequestBody LastlegOrderCommandDtos.ManualNodesRequest request) { return ApiResponse.success(commandService.saveManualNodes(platformScope(user, customerId), id, idempotencyKey, request, operatorId(user), operatorName(user))); } @GetMapping("/api/platform/v1/last-leg/orders/{id}/manual-nodes") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") @Operation(summary = "查询单票人工节点覆盖", description = "只返回人工填写节点及 MANUAL 来源。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_LIST) public ApiResponse manualNodes( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id) { return ApiResponse.success(commandService.manualNodes(platformScope(user, customerId), id)); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/pallet-merge-required") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "Set the PDA pallet-merge reminder for one tail-leg order", description = "Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse setPalletMergeRequired( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, @Valid @RequestBody LastlegOrderRequests.SetPalletMergeRequired request) { orderService.setPalletMergeRequired(platformScope(user, customerId), id, request, operatorName(user)); return ApiResponse.success(null); } @PutMapping("/api/platform/v1/last-leg/orders/pallet-merge-required/batch") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "Mark a selected tail-leg order group for PDA pallet merge") public ApiResponse setPalletMergeBatch( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody LastlegOrderRequests.SetPalletMergeBatch request) { orderService.setPalletMergeBatch(platformScope(user, customerId), request, operatorName(user)); return ApiResponse.success(null); } @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( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid LastlegOrderRequests.Query query) { byte[] body = orderService.exportCsv(platformScope(user, customerId), query); return ResponseEntity.ok() .contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=lastleg-orders.csv") .body(body); } @PutMapping("/api/platform/v1/last-leg/orders/{id}/destination") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "将单票尾程订单转入 FBA 目的仓", description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse transferOne( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable long id, @Valid @RequestBody LastlegOrderRequests.SingleTransferDestination request) { return ApiResponse.success(orderService.transferDestination(platformScope(user, customerId), request.withOrderId(id))); } @PutMapping("/api/platform/v1/last-leg/orders/destination") @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')") @Operation(summary = "批量将尾程订单转入 FBA 目的仓", description = "目标仓必须来自国内 Warehouse Service 的平台公共 ACTIVE FBA 主档,ID/编码/名称必须一致。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT) public ApiResponse transferBatch( @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @Valid @RequestBody LastlegOrderRequests.TransferDestination request) { return ApiResponse.success(orderService.transferDestination(platformScope(user, customerId), request)); } private String platformScope(AuthenticatedUser user, String requestedCustomer) { if (user == null || user.tenantId() == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform identity is required"); } return requestedCustomer == null || requestedCustomer.isBlank() || "0".equals(requestedCustomer) ? null : requestedCustomer.trim(); } private String operatorName(AuthenticatedUser user) { return user.username() == null || user.username().isBlank() ? String.valueOf(user.userId()) : user.username(); } private String operatorId(AuthenticatedUser user) { return user.userId() == null ? operatorName(user) : String.valueOf(user.userId()); } }