This commit is contained in:
wujinliang
2026-09-18 11:41:13 +08:00
68 changed files with 819 additions and 383 deletions
+4 -4
View File
@@ -27,7 +27,7 @@
|---|---|
| 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 |
| 事件接收、平台订单列表/详情/导出、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`,已拣货、已发运或已有出库单时禁止新增暂扣;暂扣期间不得进入排车或出库。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 |
| 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` |
| 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 |
| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` |
@@ -61,11 +61,11 @@
| 客户拜访、派送状态与签收附件 | `customervisit/controller/CustomerVisitController.java`、`customervisit/service/CustomerVisitService.java`、`delivery/model/DeliveryStatusPolicy.java`;派送单返回服务端 `allowedActions/version`,状态只允许单调迁移,同状态幂等;人工动作不再提供完成派送,签收资料保存后自动完成当前目的FBA仓派送单 |
| 派送三类资料、摘要与历史证据 | `delivery/controller/DeliveryDocumentController.java`、`delivery/service/DeliveryDocumentService.java`、`delivery/gateway/DeliveryDocumentFileGateway.java`、`src/main/resources/openapi/delivery-documents-v1.yaml`;固定装车照片 `LOADING_PHOTO`、BOL文件(含签名)`BOL`、签收文件(POD签收/快递签收)`RECEIPT`,按已保存类别计数 `x/3`。每类0至20文件,文件精确绑定客户、按目的FBA仓拆分的派送单和类别;全量保存共用派送版本并记录不可变历史,替换删除仅解绑,未保存上传不计数、不授权下载。`RECEIPT` 成功保存进权威快照时事务内自动完成当前派送单,单纯上传不触发完成 |
| 异常订单与派送退回入仓 | `exceptionorder/controller/ExceptionOrderController.java`、`exceptionorder/service/ExceptionOrderService.java`、`exceptionorder/gateway/ExceptionEvidenceFileGateway.java`、`inventory/command/service/InventoryCommandService.java`;证据固定按客户、异常单 ID 和 `OVERSEAS_EXCEPTION_EVIDENCE` 校验。退回候选按原出库拣货事实冻结,不接受自由 SKU 或数量;异常按 `PENDING→PROCESSING→COMPLETED`(待处理可取消)、版本与幂等键流转。退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位并写 `RETURN_STOCK_IN` 流水,库存恢复、异常完成与派送 `RETURNED` 在同一事务完成 |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口要求 `merchant:cross-border:procurement` 且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外采购、客户下单、采购审核、客户/采购仓候选与操作流水 | `procurement/controller/`、`procurement/service/OverseasProcurementService.java`;客户入口按 `merchant:cross-border:procurement:list/create` 拆分且只取 Token 客户范围;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外业务资金指令与结果 | `wallet/WalletCommandOutboxService.java`、`WalletCommandCompletedConsumer.java`、`WalletResultHandler.java`;采购和换标通过 `WALLET_COMMAND_REQUESTED` 请求国内 Finance 冻结、结算或释放,并以 `WALLET_COMMAND_COMPLETED` 幂等回写业务资金状态;不得跨区同步调用 Finance 钱包或在 Lastleg 自建余额账本 |
| 海外业务费用来源 | `financefee/controller/PlatformOverseasFeeSourceController.java`、`financefee/service/OverseasFeeSourceService.java`、`OverseasFeeBusinessGuard.java`、`OverseasFeeOutboxService.java`、`OverseasFeeOutboxRelay.java`;海外入仓、采购、美国卡车、本土散板和海外仓服务费必须匹配当前客户的真实业务,入仓记录和入仓码按事实选填;审核通过、调整或作废后以版本化完整快照经 Outbox/Event Bridge 进入独立 Finance,OpenAPI `src/main/resources/openapi/overseas-finance-fees-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口要求 `merchant:cross-border:domestic-trucking` |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口要求 `merchant:cross-border:pallet-shipping`;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 美国/本土卡车订单 | `trucking/controller/PlatformUsTruckOrderController.java`、`MerchantUsTruckOrderController.java` 及同子域 Service/DTO;客户入口按 `merchant:cross-border:domestic-trucking:list/create/edit` 拆分 |
| 本土散板订单 | `looseboard/controller/PlatformLocalLooseboardController.java`、`MerchantLocalLooseboardController.java` 及同子域 Service/DTO/Entity/Mapper;客户入口按 `merchant:cross-border:pallet-shipping:list/create/edit` 拆分;OpenAPI `src/main/resources/openapi/overseas-business-v1.yaml` |
| 海外仓报价、月度汇率、车辆主数据 | 报价 `masterdata/quote/`、汇率 `masterdata/exchange/`;车辆入口 `masterdata/vehicle/adapter/in/web/PlatformVehicleController.java`,应用服务 `application/VehicleApplicationService.java`,持久化端口与适配器 `application/port/out/VehicleRepository.java`、`adapter/out/persistence/`;OpenAPI `src/main/resources/openapi/overseas-master-data-v1.yaml`,车辆档案不含派送履约历史 |
| 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java`、`config/LastlegRestClientProperties.java`;目标地址与内部服务令牌只来自配置,不在业务子域手工创建客户端 |
| 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql`、`src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java` |
@@ -179,7 +179,8 @@ public class LastlegOrderController {
@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)
description = "要求版本与幂等键;新增暂扣只允许完成入库且尚未进入出库流程的订单,"
+ "暂扣只作用于该票并仅阻断排车和出库。Required authority: " + OverseasPermissions.LAST_MILE_ORDER_EDIT)
public ApiResponse<LastlegOrderCommandDtos.HoldResponse> hold(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String customerId,
@@ -7,9 +7,11 @@ import com.baiyue.lastleg.dto.LastlegOrderDetailResponse;
import com.baiyue.lastleg.dto.LastlegOrderRequests;
import com.baiyue.lastleg.dto.LastlegOrderSummary;
import com.baiyue.lastleg.service.LastlegOrderService;
import com.baiyue.lastleg.security.OverseasPermissions;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -19,6 +21,7 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LAST_MILE_ORDER_LIST + "')")
public class MerchantLastlegOrderController {
@Resource private LastlegOrderService orderService;
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.dto.CargoDiscardResponses;
import com.baiyue.lastleg.discard.service.CargoDiscardService;
import com.baiyue.lastleg.security.OverseasPermissions;
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;
@@ -19,18 +21,23 @@ import java.util.List;
public class MerchantCargoDiscardController {
@Resource private CargoDiscardService service;
@GetMapping("/inventory-candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<List<CargoDiscardResponses.Candidate>> candidates(@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) String inboundCode) { return ApiResponse.success(service.candidates(customer(user), inboundCode)); }
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CREATE + "')")
public ApiResponse<CargoDiscardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody CargoDiscardRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<PageResult<CargoDiscardResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{discardNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')")
public ApiResponse<CargoDiscardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.detail(customer(user), discardNo)); }
@PostMapping("/{discardNo}/cancel")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_CANCEL + "')")
public ApiResponse<CargoDiscardResponses.Detail> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String discardNo) { return ApiResponse.success(service.cancel(customer(user), discardNo, operator(user), false)); }
private String customer(AuthenticatedUser user) {
@@ -114,12 +114,6 @@ public final class LastlegOrderRequests {
) {
}
public record SetDetained(
@NotNull Boolean detained,
@NotBlank @Size(max = 500) String remark
) {
}
public record SetPalletMergeRequired(
@NotNull Boolean palletMergeRequired
) {
@@ -126,7 +126,7 @@ public interface InboundPlanOrderMapper {
@Update("UPDATE ll_order SET inbound_order_no=#{planNo},current_status='INBOUND_PLANNED'," +
"pallet_merge_flag=CASE WHEN #{mergeRequired}=1 THEN 'MERGE_REQUIRED' ELSE NULL END," +
"pallet_count=#{palletCount},version=version+1,updated_at=#{updatedAt} " +
"WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} AND detained=0 " +
"WHERE customer_id=#{customerId} AND id=#{orderId} AND version=#{expectedVersion} " +
"AND current_status IN ('PENDING_ASSIGNMENT','DESTINATION_ASSIGNED') AND inbound_order_no IS NULL")
int markPlannedManual(@Param("customerId") String customerId, @Param("orderId") Long orderId,
@Param("planNo") String planNo, @Param("palletCount") int palletCount,
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.pda.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
@@ -23,7 +24,8 @@ public final class ReceivingContainerRequests {
public record PalletResolve(@NotBlank String palletCode, @NotBlank String inboundCode) { }
/** packageCount 是该托当前累计实收箱数,不是本次增量。 */
public record BulkReceive(@NotBlank String inboundCode, @NotBlank String palletCode,
@Positive int packageCount, Integer expectedPalletVersion,
@Positive @Max(value = 66, message = "一个托盘最多只能收66箱,请拆分到其他托盘") int packageCount,
Integer expectedPalletVersion,
@NotBlank @Size(max = 255) String idempotencyKey) {
public BulkReceive(String inboundCode, String palletCode, int packageCount, String idempotencyKey) {
this(inboundCode, palletCode, packageCount, null, idempotencyKey);
@@ -73,6 +73,9 @@ public class ReceivingContainerService {
"/api/pda/v1/last-leg/receiving-containers/current/domestic-loading-photos/{type}/content";
private static final String PALLET_MERGE_HINT =
"该入库计划可选择合托:合托时扫描同目的仓已使用的托盘码,独立收货时扫描新托盘码;不强制合托";
private static final int MAX_PACKAGES_PER_PALLET = 66;
private static final String PALLET_CAPACITY_MESSAGE =
"一个托盘最多只能收66箱,请拆分到其他托盘";
@Resource private InboundPlanMapper plans;
@Resource private InboundPlanLineMapper lines;
@@ -364,6 +367,7 @@ public class ReceivingContainerService {
ReceivingContainerRequests.BulkReceive request) {
ReceivingContainerOperation replay = replay(operatorKey, "BULK_RECEIVE", request.idempotencyKey(), request);
if (replay != null) return read(replay, ReceivingContainerResponses.Receipt.class);
requirePalletCapacity(request.packageCount());
ReceivingContainerSession session = requireSigned(operatorKey);
InboundRef ref = requireInbound(session.getContainerNo(), request.inboundCode());
if ("ONE_CLICK_DROPSHIP".equals(ref.order().getOrderType())) {
@@ -463,6 +467,10 @@ public class ReceivingContainerService {
PalletSelection selection = resolveReceivingPallet(ref, request.palletCode(), true);
WarehouseResourceGateway.WarehousePalletReference resource = selection.resource();
InboundPlanPallet pallet = selection.pallet();
long packagesOnPallet = cartons.selectCount(new LambdaQueryWrapper<InboundPlanCarton>()
.eq(InboundPlanCarton::getWarehousePalletId, resource.palletId())
.eq(InboundPlanCarton::getStatus, "ON_PALLET"));
requirePalletCapacity(packagesOnPallet + 1);
workflow.validateCartonResolved(customerId, ref.plan(), ref.line(), pallet, request.cartonMark());
Long resourceLockId = workflow.assignResolvedPallet(customerId, ref.plan(), pallet, resource);
try {
@@ -492,6 +500,8 @@ public class ReceivingContainerService {
if (sourcePalletNo == null) throw notFound("DP or carton is not part of the current container");
requireCompleteManifest(session.getContainerNo(), sourcePalletNo);
List<DropshipPalletRef> matches = dropshipPalletRefs(session.getContainerNo(), sourcePalletNo);
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
requirePalletCapacity(total);
boolean alreadyReceived = matches.stream().allMatch(match ->
value(match.allocation().getPickedPackageCount()) == value(match.allocation().getPlannedPackageCount()));
int accepted = 0;
@@ -511,7 +521,6 @@ public class ReceivingContainerService {
}
List<InboundRef> group = matches.stream().map(DropshipPalletRef::ref).distinct().toList();
List<String> inboundCodes = group.stream().map(value -> value.line().getInboundCode()).distinct().sorted().toList();
int total = matches.stream().mapToInt(value -> value(value.allocation().getPlannedPackageCount())).sum();
int received = matches.stream().mapToInt(value -> value(value.allocation().getPickedPackageCount())).sum();
InboundRef first = group.get(0);
ReceivingContainerResponses.Inbound inbound = inbound(first);
@@ -885,6 +894,10 @@ public class ReceivingContainerService {
|| "LOCKED".equals(pallet.getAssignmentStatus()));
}
private void requirePalletCapacity(long packageCount) {
if (packageCount > MAX_PACKAGES_PER_PALLET) throw conflict(PALLET_CAPACITY_MESSAGE);
}
private void requireMaterializedPlans(List<InboundPlan> containerPlans) {
for (InboundPlan plan : containerPlans) {
int expected = plan.getPalletCount() == null ? 0 : plan.getPalletCount();
@@ -126,7 +126,6 @@ public class InboundPlanService {
if (order.version() != request.expectedOrderVersion()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "尾程订单版本已变化,请刷新后重试");
}
if (order.detained()) throw new ResponseStatusException(HttpStatus.CONFLICT, "暂扣尾程订单不能生成入库计划");
if (!Objects.equals(containerWarehouseId, trimToNull(order.overseasWarehouseId()))) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "来源柜接柜仓冻结事实已变化,请刷新后重试");
}
@@ -280,7 +279,6 @@ public class InboundPlanService {
boolean planned = trimToNull(row.planNo()) != null;
if (!planned) {
if (trimToNull(row.inboundCode()) == null) blockers.add("MISSING_INBOUND_CODE");
if (row.detained()) blockers.add("ORDER_DETAINED");
if (!("PENDING_ASSIGNMENT".equals(row.currentStatus())
|| "DESTINATION_ASSIGNED".equals(row.currentStatus()))) blockers.add("ORDER_STATUS_NOT_READY");
if (containerNo == null || !Objects.equals(containerNo, trimToNull(row.containerNo()))) {
@@ -60,6 +60,12 @@ public interface InventoryCommandMapper {
@Param("sourceIdentity") String sourceIdentity,
@Param("skuCode") String skuCode);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.available_packages>0 ORDER BY p.id FOR UPDATE")
List<InventoryPosition> lockInboundAvailablePositions(@Param("customerId") String customerId,
@Param("inboundOrderId") Long inboundOrderId);
@Select("SELECT p.* FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id " +
"WHERE p.customer_id=#{customerId} AND b.inbound_order_id=#{inboundOrderId} " +
"AND p.hold_packages>0 ORDER BY p.id FOR UPDATE")
@@ -142,6 +142,17 @@ public class InventoryCommandService {
});
}
@Transactional
public void holdInboundOrderInventory(String customerId, String operatorId, long inboundOrderId,
String businessOrderNo, String remark) {
for (InventoryPosition position : commands.lockInboundAvailablePositions(customerId, inboundOrderId)) {
long packages = value(position.getAvailablePackages());
if (packages == 0) continue;
hold(customerId, operatorId, new InventoryCommandRequests.Move(position.getId(), packages,
businessOrderNo, null, null, "ORDER_HOLD:" + inboundOrderId + ":" + position.getId(), remark));
}
}
@Transactional
public void releaseInboundOrderHold(String customerId, String operatorId, long inboundOrderId,
String businessOrderNo, String remark) {
@@ -23,29 +23,32 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/local-looseboard-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD + "')")
public class MerchantLocalLooseboardController {
@Resource private LocalLooseboardService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_CREATE + "')")
public ApiResponse<LocalLooseboardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody LocalLooseboardRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ApiResponse<PageResult<LocalLooseboardResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_LIST + "')")
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
}
@PutMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_LOOSEBOARD_EDIT + "')")
public ApiResponse<LocalLooseboardResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody LocalLooseboardRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
@@ -22,11 +22,11 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/overseas-procurements")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT + "')")
public class MerchantOverseasProcurementController {
@Resource private OverseasProcurementService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_CREATE + "')")
public ApiResponse<OverseasProcurementResponses.Detail> create(
@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody OverseasProcurementRequests.Create request) {
@@ -34,12 +34,14 @@ public class MerchantOverseasProcurementController {
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')")
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query.scoped()));
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_PROCUREMENT_LIST + "')")
public ApiResponse<OverseasProcurementResponses.Detail> detail(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
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.security.OverseasPermissions;
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;
@@ -24,6 +26,7 @@ import java.util.List;
public class MerchantRelabelApplicationController {
@Resource private RelabelApplicationService service;
@GetMapping("/inventory-candidates")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<List<RelabelResponses.InventoryCandidate>> candidates(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String operationWarehouseId,
@@ -31,6 +34,7 @@ public class MerchantRelabelApplicationController {
return ApiResponse.success(service.candidates(customer(user), operationWarehouseId, inboundCode));
}
@GetMapping("/historical-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<List<RelabelResponses.HistoricalOrder>> historicalOrders(
@AuthenticationPrincipal AuthenticatedUser user,
@RequestParam String operationWarehouseId,
@@ -38,20 +42,25 @@ public class MerchantRelabelApplicationController {
return ApiResponse.success(service.historicalOrders(customer(user), operationWarehouseId, keyword));
}
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CREATE + "')")
public ApiResponse<RelabelResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody RelabelRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<PageResult<RelabelResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid RelabelRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{relabelNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ApiResponse<RelabelResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo) { return ApiResponse.success(service.detail(customer(user), relabelNo)); }
@PostMapping(value = "/{relabelNo}/shipping-label", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')")
public ApiResponse<RelabelResponses.Detail> uploadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestPart("file") MultipartFile file) {
return ApiResponse.success(service.uploadShippingLabel(customer(user), relabelNo, operatorLong(user), file));
}
@PostMapping(value = "/{relabelNo}/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_FILES + "')")
public ApiResponse<RelabelResponses.Detail> uploadFile(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @RequestParam String scope, @RequestParam(required = false) Long itemId,
@RequestPart("file") MultipartFile file) {
@@ -60,18 +69,21 @@ public class MerchantRelabelApplicationController {
return ApiResponse.success(service.uploadTotalFile(customer(user), relabelNo, operatorLong(user), file, kind));
}
@GetMapping("/{relabelNo}/shipping-label/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ResponseEntity<byte[]> downloadShippingLabel(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
var file = service.downloadShippingLabel(customer(user), relabelNo, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@GetMapping("/{relabelNo}/completion-photos/{fileId}/download")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')")
public ResponseEntity<byte[]> downloadCompletionPhoto(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @PathVariable long fileId) {
var file = service.downloadCompletionPhoto(customer(user), relabelNo, fileId);
return download(file.content(), file.fileName(), file.contentType());
}
@PostMapping("/{relabelNo}/confirm-completion")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_CONFIRM + "')")
public ApiResponse<RelabelResponses.Detail> confirmCompletion(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String relabelNo, @Valid @RequestBody RelabelRequests.CustomerConfirm request) {
return ApiResponse.success(service.confirmCompletion(customer(user), relabelNo, operator(user), request));
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests;
import com.baiyue.lastleg.returnreceipt.dto.ReturnOrderResponses;
import com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService;
import com.baiyue.lastleg.security.OverseasPermissions;
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;
@@ -17,17 +19,22 @@ import org.springframework.web.server.ResponseStatusException;
@RequestMapping("/api/merchant/v1/last-leg/return-orders")
public class MerchantReturnOrderController {
@Resource private ReturnReceiptService service;
@PostMapping public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE + "')")
public ApiResponse<ReturnOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody ReturnOrderRequests.Create request) { return ApiResponse.success(service.create(customer(user), operator(user), request)); }
@GetMapping public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<PageResult<ReturnOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid ReturnOrderRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); }
@GetMapping("/{returnNo}") public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@GetMapping("/{returnNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_LIST + "')")
public ApiResponse<ReturnOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String returnNo) { return ApiResponse.success(service.detail(customer(user), returnNo)); }
@GetMapping("/{returnNo}/relabel-candidates") public ApiResponse<java.util.List<ReturnOrderResponses.RelabelCandidate>> relabelCandidates(
@GetMapping("/{returnNo}/relabel-candidates") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')")
public ApiResponse<java.util.List<ReturnOrderResponses.RelabelCandidate>> relabelCandidates(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo) {
return ApiResponse.success(service.relabelCandidates(customer(user), returnNo));
}
@PostMapping("/{returnNo}/relabel") public ApiResponse<ReturnOrderResponses.RelabelCreated> createRelabel(
@PostMapping("/{returnNo}/relabel") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL + "')")
public ApiResponse<ReturnOrderResponses.RelabelCreated> createRelabel(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String returnNo,
@Valid @RequestBody ReturnOrderRequests.RelabelCreate request) {
return ApiResponse.success(service.createRelabel(customer(user), operator(user), returnNo, request));
@@ -45,9 +45,29 @@ public final class OverseasPermissions {
public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound:list";
public static final String MERCHANT_RETURN_INVENTORY_LIST =
"merchant:overseas-warehouse:return-inventory:list";
public static final String MERCHANT_PROCUREMENT = "merchant:cross-border:procurement";
public static final String MERCHANT_TRUCKING = "merchant:cross-border:domestic-trucking";
public static final String MERCHANT_LOOSEBOARD = "merchant:cross-border:pallet-shipping";
public static final String MERCHANT_LAST_MILE_ORDER_LIST =
"merchant:overseas-warehouse:last-mile-order:list";
public static final String MERCHANT_RELABEL_LIST = "merchant:overseas-warehouse:relabel:list";
public static final String MERCHANT_RELABEL_CREATE = "merchant:overseas-warehouse:relabel:create";
public static final String MERCHANT_RELABEL_FILES = "merchant:overseas-warehouse:relabel:files";
public static final String MERCHANT_RELABEL_CONFIRM = "merchant:overseas-warehouse:relabel:confirm";
public static final String MERCHANT_TRANSFER_LIST = "merchant:overseas-warehouse:transfer:list";
public static final String MERCHANT_TRANSFER_CREATE = "merchant:overseas-warehouse:transfer:create";
public static final String MERCHANT_TRANSFER_CONFIRM = "merchant:overseas-warehouse:transfer:confirm";
public static final String MERCHANT_DISCARD_LIST = "merchant:overseas-warehouse:discard:list";
public static final String MERCHANT_DISCARD_CREATE = "merchant:overseas-warehouse:discard:create";
public static final String MERCHANT_DISCARD_CANCEL = "merchant:overseas-warehouse:discard:cancel";
public static final String MERCHANT_RETURN_ORDER_LIST = "merchant:overseas-warehouse:return-order:list";
public static final String MERCHANT_RETURN_ORDER_CREATE = "merchant:overseas-warehouse:return-order:create";
public static final String MERCHANT_RETURN_ORDER_RELABEL = "merchant:overseas-warehouse:return-order:relabel";
public static final String MERCHANT_PROCUREMENT_LIST = "merchant:cross-border:procurement:list";
public static final String MERCHANT_PROCUREMENT_CREATE = "merchant:cross-border:procurement:create";
public static final String MERCHANT_TRUCKING_LIST = "merchant:cross-border:domestic-trucking:list";
public static final String MERCHANT_TRUCKING_CREATE = "merchant:cross-border:domestic-trucking:create";
public static final String MERCHANT_TRUCKING_EDIT = "merchant:cross-border:domestic-trucking:edit";
public static final String MERCHANT_LOOSEBOARD_LIST = "merchant:cross-border:pallet-shipping:list";
public static final String MERCHANT_LOOSEBOARD_CREATE = "merchant:cross-border:pallet-shipping:create";
public static final String MERCHANT_LOOSEBOARD_EDIT = "merchant:cross-border:pallet-shipping:edit";
public static final String PURCHASE_LIST = "platform:overseas-business:purchase:list";
public static final String PURCHASE_EDIT = "platform:overseas-business:purchase:edit";
public static final String PURCHASE_REVIEW = "platform:overseas-business:purchase:review";
@@ -15,6 +15,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.Resource;
@@ -51,6 +52,7 @@ public class LastlegOrderCommandService {
@Resource private LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Resource private LastlegOrderOperationLogMapper operationLogs;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private InventoryCommandService inventory;
@Resource private ObjectMapper objectMapper;
@Transactional
@@ -69,6 +71,9 @@ public class LastlegOrderCommandService {
if (Boolean.TRUE.equals(order.getDetained()) == held) {
throw conflict(held ? "尾程订单已暂扣" : "尾程订单未处于暂扣状态");
}
if (held && !"INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
throw conflict("货物完成入库后才能暂扣");
}
if (held && (positive(order.getPickedPackageCount()) || positive(order.getShippedPackageCount())
|| text(order.getOutboundOrderNo()) != null)) {
throw conflict("尾程订单已进入出库流程,不能新增暂扣");
@@ -79,6 +84,13 @@ public class LastlegOrderCommandService {
if (orders.updateHold(order.getCustomerId(), orderId, held, chargeable, reason, version, now) != 1) {
throw conflict("尾程订单版本已变化,请刷新后重试");
}
if (held) {
inventory.holdInboundOrderInventory(order.getCustomerId(), operatorId, orderId,
order.getOrderNo(), reason);
} else {
inventory.releaseInboundOrderHold(order.getCustomerId(), operatorId, orderId,
order.getOrderNo(), reason);
}
int nextVersion = version + 1;
LastlegOrderCommandDtos.HoldResponse response = new LastlegOrderCommandDtos.HoldResponse(
orderId, held, chargeable, held ? reason : null, nextVersion);
@@ -49,7 +49,6 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -80,7 +79,6 @@ public class LastlegOrderService {
@Resource private InboundPlanCartonMapper cartonMapper;
@Resource private FbaWarehouseValidator fbaWarehouseValidator;
@Resource private InventoryCommandService inventory;
@Resource private InboundOrderDetainedOutboxService detainedOutbox;
@Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Resource private WarehouseResourceGateway warehouseResourceGateway;
@@ -388,47 +386,6 @@ public class LastlegOrderService {
return fee;
}
@Transactional
public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request,
String operatorName) {
setDetained(customerId, orderId, request, null, operatorName);
}
@Transactional
public void setDetained(String customerId, long orderId, LastlegOrderRequests.SetDetained request,
String operatorId, String operatorName) {
LastlegOrder current = findOwned(customerId, orderId);
LastlegOrder order = orderMapper.lockById(current.getCustomerId(), orderId);
if (order == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Tail-leg order not found");
boolean detained = request.detained();
if (Boolean.TRUE.equals(order.getDetained()) == detained) {
throw new ResponseStatusException(HttpStatus.CONFLICT, detained ? "Order is already held" : "Order is not held");
}
if (detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Completed inbound orders cannot be newly held");
}
LocalDateTime now = LocalDateTime.now();
order.setDetained(detained);
order.setUpdatedAt(now);
orderMapper.updateById(order);
if (!detained && "INBOUND_COMPLETED".equals(order.getCurrentStatus())) {
inventory.releaseInboundOrderHold(order.getCustomerId(), operatorName, orderId, order.getOrderNo(), request.remark().trim());
}
LastlegOrderOperationLog operation = new LastlegOrderOperationLog();
operation.setId(IdWorker.getId());
operation.setCustomerId(order.getCustomerId());
operation.setOrderId(orderId);
operation.setOperationNode(detained ? "暂扣" : "解除暂扣");
operation.setOperatorName(operatorName);
operation.setResult(request.remark().trim());
operation.setCreatedAt(now);
operationLogMapper.insert(operation);
if (StringUtils.hasText(order.getSourceInboundId())) {
detainedOutbox.publish(order, detained, request.remark().trim(), operatorId, operatorName,
now.toInstant(ZoneOffset.UTC), operation.getId());
}
}
@Transactional
public void setPalletMergeRequired(String customerId, long orderId,
LastlegOrderRequests.SetPalletMergeRequired request,
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferRequests;
import com.baiyue.lastleg.transfer.dto.WarehouseTransferResponses;
import com.baiyue.lastleg.transfer.service.WarehouseTransferService;
import com.baiyue.lastleg.security.OverseasPermissions;
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;
@@ -27,6 +29,7 @@ public class MerchantWarehouseTransferController {
@Resource private WarehouseTransferService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CREATE + "')")
public ApiResponse<WarehouseTransferResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody WarehouseTransferRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
@@ -57,12 +60,14 @@ public class MerchantWarehouseTransferController {
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')")
public ApiResponse<PageResult<WarehouseTransferResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid WarehouseTransferRequests.Query query) {
return ApiResponse.success(service.page(customer(user), query));
}
@GetMapping("/{transferNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')")
public ApiResponse<WarehouseTransferResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String transferNo) {
return ApiResponse.success(service.detail(customer(user), transferNo));
@@ -92,6 +97,7 @@ public class MerchantWarehouseTransferController {
}
@PostMapping("/{transferNo}/confirm-measurement")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_CONFIRM + "')")
public ApiResponse<WarehouseTransferResponses.Detail> confirmMeasurement(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String transferNo,
@Valid @RequestBody WarehouseTransferRequests.CustomerConfirm request) {
@@ -23,29 +23,32 @@ import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/api/merchant/v1/last-leg/us-truck-orders")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING + "')")
public class MerchantUsTruckOrderController {
@Resource private UsTruckOrderService service;
@PostMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_CREATE + "')")
public ApiResponse<UsTruckOrderResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
@Valid @RequestBody UsTruckOrderRequests.Create request) {
return ApiResponse.success(service.create(customer(user), operator(user), request));
}
@GetMapping
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')")
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
@Valid UsTruckOrderRequests.MerchantQuery query) {
return ApiResponse.success(service.page(customer(user), query.scoped()));
}
@GetMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_LIST + "')")
public ApiResponse<UsTruckOrderResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo) {
return ApiResponse.success(service.detail(customer(user), orderNo));
}
@PatchMapping("/{orderNo}")
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRUCKING_EDIT + "')")
public ApiResponse<UsTruckOrderResponses.Detail> update(@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable String orderNo, @Valid @RequestBody UsTruckOrderRequests.Update request) {
return ApiResponse.success(service.update(customer(user), orderNo, operator(user), request));
@@ -126,21 +126,21 @@ paths:
/api/pda/v1/last-leg/receiving-containers/current/receipts/bulk:
post:
summary: FBA 入仓码按箱数收货
description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划部分只记差异,不生成可用库存。
description: 权限 lastleg:pda:operate;只允许普通 FBA。packageCount 是该托现场累计箱数,单托最多 66 箱;服务端按当前柜+FBA目的仓稳定分摊到全部关联入仓码;超出计划但未超出单托上限的部分只记差异,不生成可用库存。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingBulk' } } } }
responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、版本变化或托盘不匹配 } }
responses: { '200': { description: 本次与累计实收进度,超收返回 DIFFERENCE_RECORDED, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: 非普通FBA、累计数回退、单托超过66箱、版本变化或托盘不匹配 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/carton:
post:
summary: 海外仓入仓码逐箱收货
description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,拒绝跨柜、重复箱和不匹配托盘。
description: 权限 lastleg:pda:operate;只允许 destinationType=OVERSEAS,单托最多 66 箱,拒绝跨柜、重复箱和不匹配托盘。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCarton' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '409': { description: FBA逐箱、跨柜、重复箱或托盘不匹配 } }
/api/pda/v1/last-leg/receiving-containers/current/receipts/pallet:
post:
summary: 一件代发按完整来源托盘收货
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;所有客户事件分片清单一致且齐全后才允许收货。
description: 权限 lastleg:pda:operate;只允许 ONE_CLICK_DROPSHIP,扫描 DP 或该托任一箱码,一次接收跨客户完整 DP;单托最多 66 箱,所有客户事件分片清单一致且齐全后才允许收货。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWholePallet' } } } }
responses: { '200': { description: 本次与累计实收进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingReceiptApiResponse' } } } }, '404': { description: 来源托盘不属于当前柜 }, '409': { description: 非一件代发、来源托盘重复或已操作 } }
@@ -232,7 +232,7 @@ paths:
/api/platform/v1/last-leg/orders/{id}/hold:
put:
summary: 设置或解除尾程订单暂扣
description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。已进入出库流程的订单不得新增暂扣;暂扣只作用于当前票,不影响同柜其他订单。
description: 权限 platform:overseas-warehouse:last-mile-order:edit;使用订单版本和 Idempotency-Key 原子写入计费标记、原因及不可变历史。仅完成入库且尚未进入出库流程的订单允许新增暂扣;解除暂扣不受该状态限制。暂扣只作用于当前票并仅阻断排车和出库,不影响入库计划或同柜其他订单。
security: [{ bearerAuth: [] }]
parameters:
- { name: id, in: path, required: true, schema: { type: integer, format: int64 } }
@@ -245,7 +245,7 @@ paths:
schema: { $ref: '#/components/schemas/LastlegOrderHoldRequest' }
responses:
'200': { description: 暂扣状态已更新, content: { application/json: { schema: { type: object, properties: { code: { type: string }, data: { $ref: '#/components/schemas/LastlegOrderHoldResponse' } } } } } }
'409': { description: 版本冲突、幂等键冲突、重复状态或订单已进入出库流程 }
'409': { description: 版本冲突、幂等键冲突、重复状态、尚未完成入库或订单已进入出库流程 }
/api/platform/v1/last-leg/orders/{id}/manual-nodes:
get:
summary: 查询尾程订单人工展示节点
@@ -496,7 +496,7 @@ paths:
/api/platform/v1/last-leg/inbound-plans/manual:
post:
summary: 平台按单票手工生成入库计划
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、暂扣状态、真实入仓码、客户归属及有效公共接柜仓。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
description: 权限 platform:overseas-warehouse:inbound-order:create;按 customerId 与 orderId 锁定单票,校验订单版本、状态、真实入仓码、客户归属及有效公共接柜仓。暂扣只限制排车和出库,不阻断入库计划。预计到仓日期由服务端锁定读取来源柜全部订单的 estimatedWarehouseAt,缺失或同柜不一致时拒绝生成,不接受调用方提交。一次只生成当前票的独立计划,不改变同柜其他订单;命令原子且幂等。
security: [{ bearerAuth: [] }]
parameters:
- { name: Idempotency-Key, in: header, required: true, schema: { type: string, minLength: 1, maxLength: 128 } }
@@ -508,7 +508,7 @@ paths:
responses:
'200': { description: 统一 ApiResponse 包装的入库计划详情 }
'400': { description: 客户、入仓码、仓库主数据或请求字段无效 }
'409': { description: 订单版本、状态、暂扣状态、柜级预计到仓时间、重复计划或幂等键冲突 }
'409': { description: 订单版本、状态、柜级预计到仓时间、重复计划或幂等键冲突 }
/api/platform/v1/last-leg/inbound-plans/convert-container:
post:
summary: 按整柜批量生成剩余入库计划
@@ -959,16 +959,85 @@ paths:
security: [{ bearerAuth: [] }]
parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户派送单详情 }, '403': { description: 缺少客户派送查看权限 }, '404': { description: 当前客户下派送单不存在 } }
/api/merchant/v1/last-leg/orders:
get:
summary: 客户分页查询自己的海外尾程订单
description: 权限 merchant:overseas-warehouse:last-mile-order:list;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户海外尾程订单分页 }, '403': { description: 缺少海外尾程订单查看权限 } }
/api/merchant/v1/last-leg/orders/{id}:
get:
summary: 客户查询自己的海外尾程订单详情
description: 权限 merchant:overseas-warehouse:last-mile-order:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
parameters: [{ name: id, in: path, required: true, schema: { type: integer, format: int64 } }]
responses: { '200': { description: 当前客户海外尾程订单详情 }, '403': { description: 缺少海外尾程订单查看权限 }, '404': { description: 当前客户下订单不存在 } }
/api/merchant/v1/last-leg/warehouse-transfers:
get:
summary: 客户分页查询自己的仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:list;列表权限包含详情只读。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户仓储转运单分页 }, '403': { description: 缺少仓储转运查看权限 } }
post:
summary: 客户创建仓储转运单
description: 权限 merchant:overseas-warehouse:transfer:create;客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的仓储转运单 }, '403': { description: 缺少仓储转运创建权限 }, '409': { description: 幂等、库存或状态冲突 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:
get:
summary: 客户查询自己的仓储转运单详情
description: 权限 merchant:overseas-warehouse:transfer:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户仓储转运单详情 }, '403': { description: 缺少仓储转运查看权限 }, '404': { description: 当前客户下转运单不存在 } }
/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement:
post:
summary: 客户确认仓储转运实测数据
description: 权限 merchant:overseas-warehouse:transfer:confirm;仅当前客户满足状态门禁的转运单可确认。
security: [{ bearerAuth: [] }]
parameters: [{ name: transferNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 确认后的仓储转运单 }, '403': { description: 缺少实测确认权限 }, '409': { description: 状态或版本冲突 } }
/api/merchant/v1/last-leg/cargo-discards:
get:
summary: 客户分页查询自己的货物废弃申请
description: 权限 merchant:overseas-warehouse:discard:list;列表权限包含详情和库存候选只读。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户货物废弃申请分页 }, '403': { description: 缺少货物废弃查看权限 } }
post:
summary: 客户创建货物废弃申请
description: 权限 merchant:overseas-warehouse:discard:create;客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 创建后的货物废弃申请 }, '403': { description: 缺少货物废弃创建权限 }, '409': { description: 库存或状态冲突 } }
/api/merchant/v1/last-leg/cargo-discards/inventory-candidates:
get:
summary: 查询当前客户可废弃库存候选
description: 权限 merchant:overseas-warehouse:discard:list;候选按认证客户隔离。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 可废弃库存候选 }, '403': { description: 缺少货物废弃查看权限 } }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}:
get:
summary: 客户查询自己的货物废弃申请详情
description: 权限 merchant:overseas-warehouse:discard:list;不泄露其他客户申请。
security: [{ bearerAuth: [] }]
parameters: [{ name: discardNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户货物废弃申请详情 }, '403': { description: 缺少货物废弃查看权限 }, '404': { description: 当前客户下申请不存在 } }
/api/merchant/v1/last-leg/cargo-discards/{discardNo}/cancel:
post:
summary: 客户取消自己的货物废弃申请
description: 权限 merchant:overseas-warehouse:discard:cancel;仅满足状态门禁的申请可取消。
security: [{ bearerAuth: [] }]
parameters: [{ name: discardNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 取消后的货物废弃申请 }, '403': { description: 缺少货物废弃取消权限 }, '409': { description: 状态冲突 } }
/api/merchant/v1/last-leg/overseas-procurements:
post:
summary: 客户创建海外采购订单
description: 权限 merchant:cross-border:procurement;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
description: 权限 merchant:cross-border:procurement:create;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } }
responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } }
get:
summary: 客户分页查询自己的海外采购订单
description: 权限 merchant:cross-border:procurement;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:cross-border:procurement:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -984,14 +1053,14 @@ paths:
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:
get:
summary: 客户查询自己的海外采购订单详情及流水
description: 权限 merchant:cross-border:procurement;列表权限包含详情只读,客户范围只取认证 Token。
description: 权限 merchant:cross-border:procurement:list;列表权限包含详情只读,客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
/api/merchant/v1/last-leg/us-truck-orders:
get:
summary: 客户分页查询自己的美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:cross-border:domestic-trucking:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -1005,7 +1074,7 @@ paths:
responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } }
post:
summary: 客户创建美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。
description: 权限 merchant:cross-border:domestic-trucking:create;新单固定进入 PENDING_REVIEW,客户不能调用平台审核或状态推进端点。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckCreate' } } } }
responses: { '200': { description: 本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -1013,19 +1082,19 @@ paths:
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get:
summary: 客户查询自己的美国本土卡订单详情
description: 权限 merchant:cross-border:domestic-trucking;不泄露其他客户订单。
description: 权限 merchant:cross-border:domestic-trucking:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土卡订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
patch:
summary: 客户编辑待审核的美国本土卡订单
description: 权限 merchant:cross-border:domestic-trucking;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。
description: 权限 merchant:cross-border:domestic-trucking:edit;仅 PENDING_REVIEW 可编辑,version 必填;不能修改状态。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/TruckUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/TruckDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
/api/merchant/v1/last-leg/local-looseboard-orders:
get:
summary: 客户分页查询自己的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:cross-border:pallet-shipping:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -1039,7 +1108,7 @@ paths:
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
post:
summary: 客户创建本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。
description: 权限 merchant:cross-border:pallet-shipping:create;新单固定进入 PENDING_ACCEPTANCE,客户不能调用平台状态推进端点。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardCreate' } } } }
responses: { '200': { description: 本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 幂等冲突 } }
@@ -1047,12 +1116,12 @@ paths:
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
get:
summary: 客户查询自己的本土散板订单详情
description: 权限 merchant:cross-border:pallet-shipping;不泄露其他客户订单。
description: 权限 merchant:cross-border:pallet-shipping:list;不泄露其他客户订单。
security: [{ bearerAuth: [] }]
responses: { '200': { description: 当前客户本土散板订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '404': { description: 当前客户下订单不存在 } }
put:
summary: 客户编辑待接单的本土散板订单
description: 权限 merchant:cross-border:pallet-shipping;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。
description: 权限 merchant:cross-border:pallet-shipping:edit;仅 PENDING_ACCEPTANCE 可编辑,version 必填;不能修改状态。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardUpdate' } } } }
responses: { '200': { description: 更新后的订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardDetailResponse' } } } }, '409': { description: 版本或状态冲突 } }
@@ -1310,7 +1379,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/inventory-candidates:
get:
summary: 查询客户可换标库存候选
description: 权限 merchant:warehouse:relabel;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。
description: 权限 merchant:overseas-warehouse:relabel:list;返回当前客户在指定海外中转仓仍有可用箱数的历史入仓库存,以及仓库合箱后生成的可用退货箱库存。候选按客户 Token 隔离,不接收 customerId;inboundCode 可按入仓码、退货单号或仓库唛头模糊筛选。
security: [{ bearerAuth: [] }]
parameters:
- { name: operationWarehouseId, in: query, required: true, schema: { type: string, maxLength: 128 } }
@@ -1321,7 +1390,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications:
get:
summary: 分页查询客户换标申请
description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token,不接收 customerId。
description: 权限 merchant:overseas-warehouse:relabel:list;客户范围只取认证 Token,不接收 customerId。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -1337,7 +1406,7 @@ paths:
'200': { description: 客户换标申请分页, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantPageResponse' } } } }
post:
summary: 创建客户换标申请
description: 权限 merchant:warehouse:relabel;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
description: 权限 merchant:overseas-warehouse:relabel:create;申请创建即冻结对应库存。SKU_LABEL 不要求派送方式;CARTON_LABEL/CARTON_AND_SKU 必须指定 TRUCK、PICKUP 或 EXPRESS。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCreate' } } } }
responses:
@@ -1357,7 +1426,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}:
get:
summary: 查询客户换标申请详情
description: 权限 merchant:warehouse:relabel;客户范围只取认证 Token。
description: 权限 merchant:overseas-warehouse:relabel:list;客户范围只取认证 Token。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
responses:
@@ -1366,7 +1435,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label:
post:
summary: 上传换标快递面单
description: 权限 merchant:warehouse:relabel;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。
description: 权限 merchant:overseas-warehouse:relabel:files;仅 EXPRESS 且申请尚未客户确认时允许上传,文件由服务端绑定到申请单。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
@@ -1374,10 +1443,21 @@ paths:
'200': { description: 更新后的换标详情, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelMerchantDetailResponse' } } } }
'400': { description: 文件类型或大小不合法 }
'409': { description: 当前状态不允许上传 }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/files:
post:
summary: 上传客户换标总文件或明细文件
description: 权限 merchant:overseas-warehouse:relabel:files;文件只能绑定到当前客户的换标申请。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
- { name: scope, in: query, required: true, schema: { type: string, enum: [CARTON_TOTAL, SKU_TOTAL, ITEM] } }
- { name: itemId, in: query, required: false, schema: { type: integer, format: int64 } }
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
responses: { '200': { description: 更新后的换标详情 }, '403': { description: 缺少换标文件维护权限 }, '409': { description: 状态或归属冲突 } }
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/shipping-label/{fileId}/download:
get:
summary: 下载换标快递面单
description: 权限 merchant:warehouse:relabel;仅允许下载当前申请已绑定的面单文件。
description: 权限 merchant:overseas-warehouse:relabel:list;仅允许下载当前申请已绑定的面单文件。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
@@ -1388,7 +1468,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/completion-photos/{fileId}/download:
get:
summary: 下载换标完成照片
description: 权限 merchant:warehouse:relabel;仅允许下载该申请 PDA 完工证据中的照片。
description: 权限 merchant:overseas-warehouse:relabel:list;仅允许下载该申请 PDA 完工证据中的照片。
security: [{ bearerAuth: [] }]
parameters:
- { name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }
@@ -1399,7 +1479,7 @@ paths:
/api/merchant/v1/last-leg/relabel-applications/{relabelNo}/confirm-completion:
post:
summary: 客户确认换标完成
description: 权限 merchant:warehouse:relabel;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。
description: 权限 merchant:overseas-warehouse:relabel:confirm;仅 CUSTOMER_CONFIRM_PENDING 可确认。TRUCK 进入 WAIT_PRE_DISPATCH,PICKUP/EXPRESS 幂等生成 RELABEL_OUTBOUND 出库单;EXPRESS 且要求打印面单时必须先上传面单。
security: [{ bearerAuth: [] }]
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string, maxLength: 64 } }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCustomerConfirm' } } } }
@@ -1409,7 +1489,7 @@ paths:
/api/merchant/v1/last-leg/return-orders:
get:
summary: 客户分页查询退货签收订单
description: 客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。
description: 权限 merchant:overseas-warehouse:return-order:list;客户范围只取认证 Token。可按退货单号、快递单号、SKU 和签收状态过滤;实收数量以仓库 PDA 签收为准。
security: [{ bearerAuth: [] }]
parameters:
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
@@ -1421,17 +1501,31 @@ paths:
responses: { '200': { description: 当前客户退货订单分页 } }
post:
summary: 客户创建退货预报订单
description: 客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。
description: 权限 merchant:overseas-warehouse:return-order:create;客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。
security: [{ bearerAuth: [] }]
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } }
responses: { '200': { description: 创建后的退货订单详情 }, '409': { description: 幂等键冲突 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}:
get:
summary: 客户查询退货订单与签收批次
description: 返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。
description: 权限 merchant:overseas-warehouse:return-order:list;返回各 SKU 的预报和实收数量,以及每次 PDA 签收产生的批次号和可选快递单号。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 当前客户退货订单详情 }, '404': { description: 当前客户下退货订单不存在 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}/relabel-candidates:
get:
summary: 查询退货单可转换标候选
description: 权限 merchant:overseas-warehouse:return-order:relabel;只返回当前客户退货单的可转换标明细。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 可转换标候选 }, '403': { description: 缺少退货转换标权限 }, '404': { description: 当前客户下退货单不存在 } }
/api/merchant/v1/last-leg/return-orders/{returnNo}/relabel:
post:
summary: 将退货库存转换为换标申请
description: 权限 merchant:overseas-warehouse:return-order:relabel;只能处理当前客户且满足状态门禁的退货库存。
security: [{ bearerAuth: [] }]
parameters: [{ name: returnNo, in: path, required: true, schema: { type: string } }]
responses: { '200': { description: 创建后的换标申请 }, '403': { description: 缺少退货转换标权限 }, '409': { description: 状态、库存或幂等冲突 } }
/api/pda/v1/last-leg/return-receipts/start:
post:
summary: PDA 以退件单号开始签收
@@ -1950,7 +2044,7 @@ components:
properties:
inboundCode: { type: string, minLength: 1 }
palletCode: { type: string, minLength: 1 }
packageCount: { type: integer, minimum: 1 }
packageCount: { type: integer, minimum: 1, maximum: 66 }
expectedPalletVersion: { type: integer, minimum: 0, nullable: true }
idempotencyKey: { type: string, minLength: 1, maxLength: 255 }
ReceivingCarton:
@@ -153,6 +153,8 @@ class ReceivingContainerContractTest {
assertTrue(yaml.contains("enum: [LICENSE_PLATE, CONTAINER_SEAL, DOOR_OPEN]"));
assertTrue(yaml.contains("photos: { type: array, minItems: 3, maxItems: 3"));
assertTrue(yaml.contains("required: [role, fileId, downloadPath]"));
assertTrue(yaml.contains("packageCount: { type: integer, minimum: 1, maximum: 66 }"));
assertTrue(receivingPaths.contains("单托最多 66 箱"));
assertTrue(yaml.contains("overseasWarehouseId: { type: string"));
assertTrue(yaml.contains("overseasWarehouseName: { type: string"));
assertFalse(receivingPaths.contains("photoFileIds"));
@@ -49,6 +49,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -422,6 +423,55 @@ class ReceivingContainerServiceBehaviorTest {
verifyNoInteractions(workflow);
}
@Test
void fbaPalletAccepts66PackagesAndRejects67BeforeReceivingStarts() {
stubCurrent("FBA", "CONT-A");
InboundPlanLine fbaLine = line("FBA");
fbaLine.setRequiredPackageCount(66);
when(lines.selectList(any())).thenReturn(List.of(fbaLine));
when(orderDetails.selectList(any())).thenReturn(List.of(detail("SKU-1", 66)));
when(skuReceipts.listLineSku("100", 10L, "SKU-1")).thenReturn(List.of());
stubReservedFbaPallet();
when(workflow.receiveBulkResolved(any(), any(), any(), any(), anyInt(), any(), any()))
.thenAnswer(invocation -> invocation.getArgument(4));
when(fbaPalletCounts.insert(any(ReceivingFbaPalletCount.class))).thenReturn(1);
when(sessions.updateById(any(ReceivingContainerSession.class))).thenReturn(1);
when(operations.insert(any(ReceivingContainerOperation.class))).thenReturn(1);
var accepted = service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 66, "capacity-66"));
assertThat(accepted.acceptedPackages()).isEqualTo(66);
org.mockito.Mockito.reset(sessions, workflow, warehouse);
assertThatThrownBy(() -> service.receiveBulk("7", "receiver",
new ReceivingContainerRequests.BulkReceive("IN-1", "PAL-1", 67, "capacity-67")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("66箱");
verifyNoInteractions(sessions, workflow, warehouse);
}
@Test
void overseasCartonIsRejectedWhenPhysicalPalletAlreadyContains66Packages() {
stubCurrent("OVERSEAS", "CONT-A");
when(cartons.selectOne(any())).thenReturn(carton(1L, "IN-1#67"));
InboundPlanPallet reserved = planPallet();
reserved.setPalletNo("PAL-1"); reserved.setWarehousePalletId(30L); reserved.setWarehouseLocationId(40L);
reserved.setWarehouseResourceLockId(50L); reserved.setAssignmentStatus("RESERVED");
when(pallets.selectList(any())).thenReturn(List.of(reserved));
when(warehouse.resolveReservedPallet("PAL-1", "100", 100L, 30L, 40L, 50L))
.thenReturn(resource(100L, null));
when(cartons.selectCount(any())).thenReturn(66L);
assertThatThrownBy(() -> service.receiveCarton("7", "receiver",
new ReceivingContainerRequests.CartonReceive("IN-1#67", "PAL-1", "carton-capacity")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("66箱");
verify(workflow, never()).validateCartonResolved(any(), any(), any(), any(), any());
verify(workflow, never()).assignResolvedPallet(any(), any(), any(), any());
}
@Test
void crossContainerCartonAndMismatchedPalletAreRejectedBeforeReservation() {
stubCurrent("OVERSEAS", "CONT-A");
@@ -950,6 +1000,29 @@ class ReceivingContainerServiceBehaviorTest {
verifyNoInteractions(warehouse);
}
@Test
void dropshipWholePalletOver66PackagesIsRejectedBeforePicking() {
stubCurrent("OVERSEAS", "CONT-A");
LastlegOrder dropship = order("OVERSEAS"); dropship.setOrderType("ONE_CLICK_DROPSHIP");
when(orders.selectById(1L)).thenReturn(dropship);
InboundPlanPallet pallet = planPallet();
pallet.setPalletNo("DP-260909-0067"); pallet.setSourcePalletNo("DP-260909-0067");
pallet.setWarehousePalletId(30L);
InboundPlanPalletAllocation pending = new InboundPlanPalletAllocation();
pending.setId(67L); pending.setCustomerId("100"); pending.setPlanId(2L);
pending.setLineId(10L); pending.setPalletId(20L); pending.setPlannedPackageCount(67);
when(pallets.selectList(any())).thenReturn(List.of(pallet));
when(allocations.selectList(any())).thenReturn(List.of(pending));
stubCompleteManifest("DP-260909-0067", 67);
assertThatThrownBy(() -> service.receiveWholePallet("7", "receiver",
new ReceivingContainerRequests.WholePalletReceive("DP-260909-0067", "whole-capacity")))
.isInstanceOf(ResponseStatusException.class)
.hasMessageContaining("66箱");
verify(workflow, never()).pickWholePallet(any(), any(), anyLong(), any());
}
@Test
void domesticLoadingPhotoDownloadIsScopedToTheCurrentPhysicalContainer() {
ReceivingContainerSession current = session("CONT-A", "SIGNED");
@@ -85,6 +85,22 @@ class ContainerInboundPlanPreviewTest {
assertFalse(result.ready());
}
@Test
void heldHistoricalOrderRemainsReadyBecauseHoldOnlyBlocksOutbound() {
when(orderMapper.containerPreviewOrders("BOOKING-HELD")).thenReturn(List.of(
new InboundPlanOrderMapper.ContainerPreviewOrderRow("100", 1L, "LM-HELD", "FO-1",
"测试客户", "FIRST_LEG", "FBA", "IN-1", "PENDING_ASSIGNMENT", null,
"MSCU6639870", 1, 10, new BigDecimal("1.0000"), "193000000000200003", "US-LAX-01",
"洛杉矶仓", "ACY2", null, null, "Amazon ACY2", 0, true, 1,
null, null, null, null, 1)));
var result = service.previewContainer("BOOKING-HELD");
assertEquals(true, result.ready());
assertEquals("READY", result.groups().get(0).orders().get(0).readiness());
assertEquals(List.of(), result.groups().get(0).orders().get(0).blockers());
}
@Test
void dropshipPreviewKeepsUnderlyingFbaFactSeparateFromItsDisplayCategory() {
when(orderMapper.containerPreviewOrders("BOOKING-DROPSHIP")).thenReturn(List.of(
@@ -352,21 +352,23 @@ class InboundPlanServiceTest {
}
@Test
void refusesManualPlanForHeldOrderBeforeWriting() {
void createsManualPlanForHeldHistoricalOrderBecauseHoldOnlyBlocksOutbound() {
SourceOrderSnapshot held = new SourceOrderSnapshot(2L, "100", "MSCU6639870", "RK-2",
"PENDING_ASSIGNMENT", null, new BigDecimal("1.0000"), 10, 0,
"193000000000200003", "US-LAX-01", "洛杉矶仓", null,
null, "WH-A", "仓A", 1, 0, true, 5);
"WH-A", "WH-A", "仓A", 1, 0, true, 5);
when(orderMapper.lockOrder("100", 2L)).thenReturn(List.of(held));
when(orderMapper.markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(5), any()))
.thenReturn(1);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.convertManual(
var result = service.convertManual(
new ManualInboundPlanRequest("100", 2L, 5,
1, "STANDARD", false, false, false, null),
"manual-held", "900"));
"manual-held", "900");
assertEquals(409, error.getStatusCode().value());
verify(planMapper, never()).insert(any(InboundPlan.class));
verify(commandMapper, never()).insert(any(com.baiyue.lastleg.entity.LastlegOrderCommandRecord.class));
assertEquals(2L, result.sourceOrderId());
verify(planMapper).insert(any(InboundPlan.class));
verify(orderMapper).markPlannedManual(eq("100"), eq(2L), any(), eq(1), eq(false), eq(5), any());
}
@Test
@@ -280,6 +280,23 @@ class InventoryCommandServiceTest {
verify(balances, never()).updateById(any(InventoryBalance.class));
}
@Test
void holdsAllAvailablePositionsForOneInboundOrder() {
InventoryBalance balance = balance(200L, 5);
InventoryPosition position = position(201L, 200L, 5);
when(commands.lockInboundAvailablePositions("100", 101L)).thenReturn(List.of(position));
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
service.holdInboundOrderInventory("100", "7", 101L, "ORD-1", "质检暂扣");
assertEquals(0L, position.getAvailablePackages());
assertEquals(5L, position.getHoldPackages());
assertEquals(0L, balance.getAvailablePackages());
assertEquals(5L, balance.getHoldPackages());
verify(ledgers).insert(any(InventoryLedger.class));
}
@Test
void replayUsesPersistedResultWithoutMutatingInventoryAgain() throws Exception {
var request = stockIn("IN-1", "stock-in-1", 12, 24);
@@ -1,14 +1,19 @@
package com.baiyue.lastleg.merchant;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.controller.MerchantLastlegOrderController;
import com.baiyue.lastleg.inboundplan.controller.MerchantInboundPlanController;
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
import com.baiyue.lastleg.discard.controller.MerchantCargoDiscardController;
import com.baiyue.lastleg.looseboard.controller.MerchantLocalLooseboardController;
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
import com.baiyue.lastleg.procurement.controller.MerchantOverseasProcurementController;
import com.baiyue.lastleg.relabel.controller.MerchantRelabelApplicationController;
import com.baiyue.lastleg.returnreceipt.controller.MerchantReturnOrderController;
import com.baiyue.lastleg.security.OverseasPermissions;
import com.baiyue.lastleg.transfer.controller.MerchantWarehouseTransferController;
import com.baiyue.lastleg.trucking.controller.MerchantUsTruckOrderController;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -21,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import java.util.Arrays;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
@@ -43,15 +49,57 @@ class MerchantOverseasCustomerApiContractTest {
}
@Test
void merchantControllersUseFrozenPathsAndExistingClientPermissionCodes() {
assertController(MerchantOverseasProcurementController.class,
"/api/merchant/v1/last-leg/overseas-procurements", OverseasPermissions.MERCHANT_PROCUREMENT);
assertController(MerchantUsTruckOrderController.class,
"/api/merchant/v1/last-leg/us-truck-orders", OverseasPermissions.MERCHANT_TRUCKING);
assertController(MerchantLocalLooseboardController.class,
"/api/merchant/v1/last-leg/local-looseboard-orders", OverseasPermissions.MERCHANT_LOOSEBOARD);
void merchantControllersUseFrozenPathsAndOperationPermissions() {
assertPath(MerchantOverseasProcurementController.class, "/api/merchant/v1/last-leg/overseas-procurements");
assertPermissions(MerchantOverseasProcurementController.class, Map.of(
"create", OverseasPermissions.MERCHANT_PROCUREMENT_CREATE,
"page", OverseasPermissions.MERCHANT_PROCUREMENT_LIST,
"detail", OverseasPermissions.MERCHANT_PROCUREMENT_LIST));
assertPath(MerchantUsTruckOrderController.class, "/api/merchant/v1/last-leg/us-truck-orders");
assertPermissions(MerchantUsTruckOrderController.class, Map.of(
"create", OverseasPermissions.MERCHANT_TRUCKING_CREATE,
"page", OverseasPermissions.MERCHANT_TRUCKING_LIST,
"detail", OverseasPermissions.MERCHANT_TRUCKING_LIST,
"update", OverseasPermissions.MERCHANT_TRUCKING_EDIT));
assertPath(MerchantLocalLooseboardController.class, "/api/merchant/v1/last-leg/local-looseboard-orders");
assertPermissions(MerchantLocalLooseboardController.class, Map.of(
"create", OverseasPermissions.MERCHANT_LOOSEBOARD_CREATE,
"page", OverseasPermissions.MERCHANT_LOOSEBOARD_LIST,
"detail", OverseasPermissions.MERCHANT_LOOSEBOARD_LIST,
"update", OverseasPermissions.MERCHANT_LOOSEBOARD_EDIT));
assertController(MerchantInboundPlanController.class,
"/api/merchant/v1/last-leg/inbound-plans", OverseasPermissions.MERCHANT_INBOUND_LIST);
assertController(MerchantLastlegOrderController.class,
"/api/merchant/v1/last-leg/orders", OverseasPermissions.MERCHANT_LAST_MILE_ORDER_LIST);
assertPermissions(MerchantWarehouseTransferController.class, Map.of(
"create", OverseasPermissions.MERCHANT_TRANSFER_CREATE,
"page", OverseasPermissions.MERCHANT_TRANSFER_LIST,
"detail", OverseasPermissions.MERCHANT_TRANSFER_LIST,
"confirmMeasurement", OverseasPermissions.MERCHANT_TRANSFER_CONFIRM));
assertPermissions(MerchantCargoDiscardController.class, Map.of(
"candidates", OverseasPermissions.MERCHANT_DISCARD_LIST,
"create", OverseasPermissions.MERCHANT_DISCARD_CREATE,
"page", OverseasPermissions.MERCHANT_DISCARD_LIST,
"detail", OverseasPermissions.MERCHANT_DISCARD_LIST,
"cancel", OverseasPermissions.MERCHANT_DISCARD_CANCEL));
assertPermissions(MerchantReturnOrderController.class, Map.of(
"create", OverseasPermissions.MERCHANT_RETURN_ORDER_CREATE,
"page", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"detail", OverseasPermissions.MERCHANT_RETURN_ORDER_LIST,
"relabelCandidates", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL,
"createRelabel", OverseasPermissions.MERCHANT_RETURN_ORDER_RELABEL));
assertPermissions(MerchantRelabelApplicationController.class, Map.of(
"candidates", OverseasPermissions.MERCHANT_RELABEL_LIST,
"historicalOrders", OverseasPermissions.MERCHANT_RELABEL_LIST,
"create", OverseasPermissions.MERCHANT_RELABEL_CREATE,
"page", OverseasPermissions.MERCHANT_RELABEL_LIST,
"detail", OverseasPermissions.MERCHANT_RELABEL_LIST,
"uploadShippingLabel", OverseasPermissions.MERCHANT_RELABEL_FILES,
"uploadFile", OverseasPermissions.MERCHANT_RELABEL_FILES,
"downloadShippingLabel", OverseasPermissions.MERCHANT_RELABEL_LIST,
"downloadCompletionPhoto", OverseasPermissions.MERCHANT_RELABEL_LIST,
"confirmCompletion", OverseasPermissions.MERCHANT_RELABEL_CONFIRM));
}
@Test
@@ -90,11 +138,27 @@ class MerchantOverseasCustomerApiContractTest {
}
private static void assertController(Class<?> type, String path, String permission) {
assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path);
assertPath(type, path);
assertThat(type.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + permission + "')");
}
private static void assertPath(Class<?> type, String path) {
assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path);
}
private static void assertPermissions(Class<?> type, Map<String, String> permissions) {
for (var entry : permissions.entrySet()) {
var method = Arrays.stream(type.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals(entry.getKey()))
.findFirst().orElseThrow();
assertThat(method.getAnnotation(PreAuthorize.class))
.as(type.getSimpleName() + "." + entry.getKey()).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + entry.getValue() + "')");
}
}
private static AuthenticatedUser user(long customerId, String appCode) {
return new AuthenticatedUser(7L, customerId, customerId, 8L, "operator", appCode, "WEB", Set.of());
}
@@ -16,6 +16,15 @@ class MerchantOverseasOpenApiContractTest {
}
for (String path : new String[] {
"/api/merchant/v1/last-leg/orders:",
"/api/merchant/v1/last-leg/orders/{id}:",
"/api/merchant/v1/last-leg/warehouse-transfers:",
"/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}:",
"/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/confirm-measurement:",
"/api/merchant/v1/last-leg/cargo-discards:",
"/api/merchant/v1/last-leg/cargo-discards/inventory-candidates:",
"/api/merchant/v1/last-leg/cargo-discards/{discardNo}:",
"/api/merchant/v1/last-leg/cargo-discards/{discardNo}/cancel:",
"/api/merchant/v1/last-leg/overseas-procurements:",
"/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:",
"/api/merchant/v1/last-leg/us-truck-orders:",
@@ -27,9 +36,28 @@ class MerchantOverseasOpenApiContractTest {
assertThat(yaml).contains(path);
assertThat(pathBlock(yaml, path)).doesNotContain("name: customerId");
}
assertThat(yaml).contains("merchant:cross-border:procurement")
.contains("merchant:cross-border:domestic-trucking")
.contains("merchant:cross-border:pallet-shipping")
assertThat(yaml).contains("merchant:cross-border:procurement:list")
.contains("merchant:cross-border:procurement:create")
.contains("merchant:cross-border:domestic-trucking:list")
.contains("merchant:cross-border:domestic-trucking:create")
.contains("merchant:cross-border:domestic-trucking:edit")
.contains("merchant:cross-border:pallet-shipping:list")
.contains("merchant:cross-border:pallet-shipping:create")
.contains("merchant:cross-border:pallet-shipping:edit")
.contains("merchant:overseas-warehouse:last-mile-order:list")
.contains("merchant:overseas-warehouse:transfer:list")
.contains("merchant:overseas-warehouse:transfer:create")
.contains("merchant:overseas-warehouse:transfer:confirm")
.contains("merchant:overseas-warehouse:discard:list")
.contains("merchant:overseas-warehouse:discard:create")
.contains("merchant:overseas-warehouse:discard:cancel")
.contains("merchant:overseas-warehouse:relabel:list")
.contains("merchant:overseas-warehouse:relabel:create")
.contains("merchant:overseas-warehouse:relabel:files")
.contains("merchant:overseas-warehouse:relabel:confirm")
.contains("merchant:overseas-warehouse:return-order:list")
.contains("merchant:overseas-warehouse:return-order:create")
.contains("merchant:overseas-warehouse:return-order:relabel")
.contains("merchant:warehouse:inbound:list")
.contains("#/components/schemas/ProcurementDetailResponse")
.contains("#/components/schemas/TruckDetailResponse")
@@ -96,7 +96,10 @@ class RelabelContractTest {
}
assertTrue(openApi.contains("RelabelMerchantPageResponse"));
assertTrue(openApi.contains("RelabelCustomerConfirm"));
assertTrue(openApi.contains("merchant:warehouse:relabel"));
assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:list"));
assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:create"));
assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:files"));
assertTrue(openApi.contains("merchant:overseas-warehouse:relabel:confirm"));
String relabelPaths = openApi.substring(openApi.indexOf("/api/platform/v1/last-leg/relabel-applications:"),
openApi.indexOf("components:"));
assertTrue(relabelPaths.contains("name: customerId, in: query, required: true"));
@@ -12,6 +12,7 @@ import com.baiyue.lastleg.mapper.LastlegOrderManualNodeHistoryMapper;
import com.baiyue.lastleg.mapper.LastlegOrderManualNodeMapper;
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
import com.baiyue.lastleg.mapper.LastlegOrderOperationLogMapper;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.fasterxml.jackson.databind.json.JsonMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -48,6 +49,7 @@ class LastlegOrderCommandServiceTest {
@Mock LastlegOrderManualNodeHistoryMapper manualNodeHistory;
@Mock LastlegOrderOperationLogMapper operationLogs;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
@Mock InventoryCommandService inventory;
LastlegOrderCommandService service;
@BeforeEach
@@ -60,12 +62,14 @@ class LastlegOrderCommandServiceTest {
ReflectionTestUtils.setField(service, "manualNodeHistory", manualNodeHistory);
ReflectionTestUtils.setField(service, "operationLogs", operationLogs);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build());
}
@Test
void holdsOnlySelectedOrderWithVersionAndImmutableHistory() {
LastlegOrder order = order(11L, 3);
order.setCurrentStatus("INBOUND_COMPLETED");
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
when(orders.updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3),
@@ -80,9 +84,42 @@ class LastlegOrderCommandServiceTest {
verify(orders).updateHold(eq("100"), eq(11L), eq(true), eq(true), eq("海关查验"), eq(3), any());
verify(holdHistory).insert(any(LastlegOrderHoldHistory.class));
verify(commands).insert(any(LastlegOrderCommandRecord.class));
verify(inventory).holdInboundOrderInventory("100", "900", 11L, "LL-11", "海关查验");
verify(orders, never()).lockContainerOrders(any());
}
@Test
void releasingHoldMovesOrderInventoryBackToAvailable() {
LastlegOrder order = order(11L, 3);
order.setCurrentStatus("INBOUND_COMPLETED");
order.setDetained(true);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
when(orders.updateHold(eq("100"), eq(11L), eq(false), eq(false), eq("质检通过"), eq(3),
any(LocalDateTime.class))).thenReturn(1);
service.hold("100", 11L, "release-11",
new LastlegOrderCommandDtos.HoldRequest(false, false, "质检通过", 3), "900", "管理员");
verify(inventory).releaseInboundOrderHold("100", "900", 11L, "LL-11", "质检通过");
}
@Test
void rejectsNewHoldBeforeInboundCompletion() {
LastlegOrder order = order(11L, 3);
when(orders.selectById(11L)).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> service.hold("100", 11L,
"hold-before-inbound", new LastlegOrderCommandDtos.HoldRequest(true, false, "待入库", 3),
"900", "管理员"));
assertEquals(409, error.getStatusCode().value());
assertEquals("货物完成入库后才能暂扣", error.getReason());
verify(orders, never()).updateHold(anyString(), anyLong(), anyBoolean(), anyBoolean(),
nullable(String.class), anyInt(), any(LocalDateTime.class));
}
@Test
void rejectsStaleHoldWithoutAnyWrite() {
LastlegOrder order = order(11L, 4);
@@ -51,7 +51,6 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -71,7 +70,6 @@ class LastlegOrderServiceTest {
@Mock InboundPlanCartonMapper cartons;
@Mock FbaWarehouseValidator fbaWarehouses;
@Mock InventoryCommandService inventory;
@Mock InboundOrderDetainedOutboxService detainedOutbox;
@Mock ReceivingWarehouseOperationMapper receivingWarehouseOperations;
@Mock ContainerArrivalTimeOperationMapper containerArrivalTimeOperations;
@Mock WarehouseResourceGateway warehouseResourceGateway;
@@ -118,7 +116,6 @@ class LastlegOrderServiceTest {
Long.parseLong(invocation.getArgument(0)), invocation.getArgument(1), invocation.getArgument(2),
"US", "CA"));
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "detainedOutbox", detainedOutbox);
ReflectionTestUtils.setField(service, "receivingWarehouseOperations", receivingWarehouseOperations);
ReflectionTestUtils.setField(service, "containerArrivalTimeOperations", containerArrivalTimeOperations);
ReflectionTestUtils.setField(service, "warehouseResourceGateway", warehouseResourceGateway);
@@ -586,40 +583,6 @@ class LastlegOrderServiceTest {
assertEquals("NYEXX2609060063", result.records().get(2).inboundCode());
}
@Test
void holdingBeforeInboundPersistsTheOrderMarkerAndOperationRemark() {
LastlegOrder order = order(11L, "100", "PENDING_INBOUND", null);
when(orders.selectOne(any())).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(true, "等待质检"), "operator");
assertTrue(order.getDetained());
verify(orders).updateById(order);
ArgumentCaptor<com.baiyue.lastleg.entity.LastlegOrderOperationLog> operation =
ArgumentCaptor.forClass(com.baiyue.lastleg.entity.LastlegOrderOperationLog.class);
verify(operations).insert(operation.capture());
assertEquals("暂扣", operation.getValue().getOperationNode());
assertEquals("等待质检", operation.getValue().getResult());
verify(detainedOutbox).publish(eq(order), eq(true), eq("等待质检"), isNull(), eq("operator"), any(), anyLong());
verify(inventory, never()).releaseInboundOrderHold(any(), any(), anyLong(), any(), any());
}
@Test
void releasingAfterInboundMovesAllOrderHeldInventoryToAvailable() {
LastlegOrder order = order(11L, "100", "INBOUND_COMPLETED", "IP-1");
order.setOrderNo("LM-1");
order.setDetained(true);
when(orders.selectOne(any())).thenReturn(order);
when(orders.lockById("100", 11L)).thenReturn(order);
service.setDetained("100", 11L, new LastlegOrderRequests.SetDetained(false, "质检通过"), "operator");
verify(inventory).releaseInboundOrderHold("100", "operator", 11L, "LM-1", "质检通过");
assertEquals(false, order.getDetained());
verify(detainedOutbox).publish(eq(order), eq(false), eq("质检通过"), isNull(), eq("operator"), any(), anyLong());
}
private static LastlegOrder order(long id, String customerId, String status, String inboundOrderNo) {
LastlegOrder order = new LastlegOrder();
order.setId(id);