308 lines
18 KiB
Java
308 lines
18 KiB
Java
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.event.DeliveryAck;
|
|
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.dto.ContainerArrivalTimeView;
|
|
import com.baiyue.lastleg.financefee.entity.OverseasFeeSource;
|
|
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.returnreceipt.service.ReturnReceiptService;
|
|
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
|
|
import com.baiyue.lastleg.relabel.dto.RelabelResponses;
|
|
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
|
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;
|
|
import java.util.List;
|
|
|
|
@RestController
|
|
@Tag(name = "Tail-leg orders")
|
|
public class LastlegOrderController {
|
|
@Resource private ReturnReceiptService returnReceiptService;
|
|
@Resource private LoadingCompletedConsumer consumer;
|
|
@Resource private ContainerShippingNodeConsumer shippingNodeConsumer;
|
|
@Resource private LastlegOrderService orderService;
|
|
@Resource private LastlegOrderCommandService commandService;
|
|
@Resource private InternalServiceTokenGuard tokenGuard;
|
|
@Resource private WalletCommandCompletedConsumer walletCommandCompletedConsumer;
|
|
@Resource private RelabelApplicationService relabelApplicationService;
|
|
|
|
@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<DeliveryAck> receive(
|
|
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
|
@RequestBody EventEnvelope event) {
|
|
tokenGuard.verify(token);
|
|
DeliveryAck 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 com.baiyue.common.event.EventProtocolException("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<PageResult<LastlegOrderSummary>> list(
|
|
@AuthenticationPrincipal AuthenticatedUser user,
|
|
@RequestParam(required = false) String customerId,
|
|
@RequestParam(required = false) String relabelNo,
|
|
@Valid LastlegOrderRequests.Query query) {
|
|
return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo));
|
|
}
|
|
|
|
/** Compatibility overload used by non-Spring callers and existing contract tests. */
|
|
public ApiResponse<PageResult<LastlegOrderSummary>> list(
|
|
AuthenticatedUser user, String customerId, 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<PageResult<ContainerPlanProgressSummary>> 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<ContainerPlanProgressDetail> containerSummaryDetail(
|
|
@AuthenticationPrincipal AuthenticatedUser user,
|
|
@PathVariable String sourceContainerBookingId) {
|
|
platformScope(user, null);
|
|
return ApiResponse.success(orderService.containerProgressDetail(sourceContainerBookingId));
|
|
}
|
|
|
|
@PostMapping("/api/platform/v1/last-leg/orders/container-arrival-times/query")
|
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')")
|
|
@Operation(summary = "批量查询整柜预计与实际到仓时间",
|
|
description = "预计到仓取平台人工维护的整柜冻结值;实际到仓取海外PDA首次开始收货时间。")
|
|
public ApiResponse<List<ContainerArrivalTimeView>> containerArrivalTimes(
|
|
@AuthenticationPrincipal AuthenticatedUser user,
|
|
@Valid @RequestBody LastlegOrderRequests.ContainerArrivalTimeQuery request) {
|
|
platformScope(user, null);
|
|
return ApiResponse.success(orderService.containerArrivalTimes(request));
|
|
}
|
|
|
|
@PutMapping("/api/platform/v1/last-leg/orders/container-summaries/{sourceContainerBookingId}/expected-arrival")
|
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
|
@Operation(summary = "修改整柜预计到仓时间",
|
|
description = "在入库计划生成前原子更新来源柜全部尾程订单;入库计划统一读取该柜时间。")
|
|
public ApiResponse<ContainerArrivalTimeView> changeExpectedWarehouseArrival(
|
|
@AuthenticationPrincipal AuthenticatedUser user,
|
|
@PathVariable String sourceContainerBookingId,
|
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
|
@Valid @RequestBody LastlegOrderRequests.ChangeExpectedWarehouseArrival request) {
|
|
platformScope(user, null);
|
|
return ApiResponse.success(orderService.changeExpectedWarehouseArrival(sourceContainerBookingId,
|
|
idempotencyKey, request, operatorName(user)));
|
|
}
|
|
|
|
@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<ContainerPlanProgressSummary> 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<LastlegOrderDetailResponse> detail(
|
|
@AuthenticationPrincipal AuthenticatedUser user,
|
|
@RequestParam(required = false) String customerId,
|
|
@PathVariable long id) {
|
|
String scopedCustomerId = platformScope(user, customerId);
|
|
returnReceiptService.ensureTailCargo(scopedCustomerId, id);
|
|
return ApiResponse.success(orderService.detail(scopedCustomerId, id));
|
|
}
|
|
|
|
@PutMapping("/api/platform/v1/last-leg/orders/{id}/relabel-review")
|
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_EDIT + "')")
|
|
@Operation(summary = "Review a directly-created labeling order in the tail-leg order workbench")
|
|
public ApiResponse<RelabelResponses.Detail> reviewDirectRelabel(
|
|
@AuthenticationPrincipal AuthenticatedUser user,
|
|
@RequestParam(required = false) String customerId,
|
|
@PathVariable long id,
|
|
@Valid @RequestBody RelabelRequests.ChangeStatus request) {
|
|
return ApiResponse.success(relabelApplicationService.reviewDirectTailOrder(
|
|
platformScope(user, customerId), id, operatorId(user), request));
|
|
}
|
|
|
|
@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<OverseasFeeSource> createFee(
|
|
@AuthenticationPrincipal AuthenticatedUser user,
|
|
@RequestParam(required = false) String customerId,
|
|
@PathVariable long id,
|
|
@RequestHeader("Idempotency-Key") String idempotencyKey,
|
|
@Valid @RequestBody LastlegOrderRequests.CreateFee request) {
|
|
return ApiResponse.success(orderService.createFee(platformScope(user, customerId), id, request,
|
|
operatorName(user), idempotencyKey));
|
|
}
|
|
|
|
@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<LastlegOrderCommandDtos.HoldResponse> 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<LastlegOrderCommandDtos.ManualNodesResponse> 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<LastlegOrderCommandDtos.ManualNodesResponse> 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<Void> 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<Void> 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);
|
|
}
|
|
|
|
@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<Integer> 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<Integer> 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());
|
|
}
|
|
}
|