feat: complete overseas customer and PDA workflows
This commit is contained in:
+49
@@ -0,0 +1,49 @@
|
||||
package com.baiyue.lastleg.inboundplan.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanListResponse;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanRequests;
|
||||
import com.baiyue.lastleg.inboundplan.dto.InboundPlanResponse;
|
||||
import com.baiyue.lastleg.inboundplan.service.InboundPlanService;
|
||||
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;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/merchant/v1/last-leg/inbound-plans")
|
||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_INBOUND_LIST + "')")
|
||||
public class MerchantInboundPlanController {
|
||||
@Resource private InboundPlanService service;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<InboundPlanListResponse>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid InboundPlanRequests.Query query) {
|
||||
return ApiResponse.success(service.page(customer(user), query));
|
||||
}
|
||||
|
||||
@GetMapping("/{planNo}")
|
||||
public ApiResponse<InboundPlanResponse> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String planNo) {
|
||||
return ApiResponse.success(service.detailByPlanNo(customer(user), planNo));
|
||||
}
|
||||
|
||||
private String customer(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
package com.baiyue.lastleg.looseboard.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests;
|
||||
import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses;
|
||||
import com.baiyue.lastleg.looseboard.service.LocalLooseboardService;
|
||||
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;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
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
|
||||
public ApiResponse<LocalLooseboardResponses.Detail> create(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid @RequestBody LocalLooseboardRequests.Create request) {
|
||||
return ApiResponse.success(service.create(customer(user), operator(user), request));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<LocalLooseboardResponses.Summary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid LocalLooseboardRequests.MerchantQuery query) {
|
||||
return ApiResponse.success(service.page(customer(user), query));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
public ApiResponse<LocalLooseboardResponses.Detail> detail(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@PathVariable String orderNo) {
|
||||
return ApiResponse.success(service.detail(customer(user), orderNo));
|
||||
}
|
||||
|
||||
@PutMapping("/{orderNo}")
|
||||
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));
|
||||
}
|
||||
|
||||
private String customer(AuthenticatedUser user) {
|
||||
if (user == null || user.tenantId() == null) {
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
if ("PLATFORM".equals(user.appCode()) || user.tenantId() == 0L) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required");
|
||||
}
|
||||
return String.valueOf(user.tenantId());
|
||||
}
|
||||
|
||||
private String operator(AuthenticatedUser user) {
|
||||
return user.userId() == null ? user.username() : String.valueOf(user.userId());
|
||||
}
|
||||
}
|
||||
+10
@@ -65,6 +65,16 @@ public final class LocalLooseboardRequests {
|
||||
LocalDate requestedFrom,
|
||||
LocalDate requestedTo) { }
|
||||
|
||||
public record MerchantQuery(
|
||||
@Min(1) long page,
|
||||
@Min(1) @Max(200) long size,
|
||||
String orderNo,
|
||||
String routeKeyword,
|
||||
String status,
|
||||
String transportMode,
|
||||
LocalDate requestedFrom,
|
||||
LocalDate requestedTo) { }
|
||||
|
||||
public record ChangeStatus(
|
||||
@NotNull @Min(0) Long version,
|
||||
@NotBlank @Size(max = 32) String status,
|
||||
|
||||
+8
@@ -118,6 +118,14 @@ public class LocalLooseboardService {
|
||||
result.getSize(), result.getTotal(), result.getPages());
|
||||
}
|
||||
|
||||
/** Customer-scoped query that ignores any client-supplied customer filter. */
|
||||
public PageResult<LocalLooseboardResponses.Summary> page(String customerId,
|
||||
LocalLooseboardRequests.MerchantQuery query) {
|
||||
return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId,
|
||||
query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(),
|
||||
query.requestedFrom(), query.requestedTo()));
|
||||
}
|
||||
|
||||
public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) {
|
||||
LocalLooseboardOrder order = orders.findNo(customerId, orderNo);
|
||||
if (order == null) throw notFound();
|
||||
|
||||
+5
-2
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests;
|
||||
import com.baiyue.lastleg.procurement.dto.OverseasProcurementResponses;
|
||||
import com.baiyue.lastleg.procurement.service.OverseasProcurementService;
|
||||
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;
|
||||
@@ -20,6 +22,7 @@ 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;
|
||||
|
||||
@@ -32,8 +35,8 @@ public class MerchantOverseasProcurementController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<OverseasProcurementResponses.Summary>> page(
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.Query query) {
|
||||
return ApiResponse.success(service.page(customer(user), query));
|
||||
@AuthenticationPrincipal AuthenticatedUser user, @Valid OverseasProcurementRequests.MerchantQuery query) {
|
||||
return ApiResponse.success(service.page(customer(user), query.scoped()));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
|
||||
+11
@@ -36,6 +36,17 @@ public final class OverseasProcurementRequests {
|
||||
String productName, String status, String purchaseWarehouseId,
|
||||
LocalDateTime createdFrom, LocalDateTime createdTo) {}
|
||||
|
||||
public record MerchantQuery(
|
||||
@Min(1) long page, @Min(1) @Max(200) long size,
|
||||
String orderNo, String purchaseCategory, String productName,
|
||||
String status, String purchaseWarehouseId,
|
||||
LocalDateTime createdFrom, LocalDateTime createdTo) {
|
||||
public Query scoped() {
|
||||
return new Query(page, size, orderNo, null, purchaseCategory, productName,
|
||||
status, purchaseWarehouseId, createdFrom, createdTo);
|
||||
}
|
||||
}
|
||||
|
||||
public record Review(
|
||||
@NotNull @Min(0) Long version,
|
||||
@NotBlank String decision,
|
||||
|
||||
@@ -36,6 +36,10 @@ public final class OverseasPermissions {
|
||||
public static final String CUSTOMER_VISIT_EDIT = "platform:overseas-warehouse:customer-visit:edit";
|
||||
public static final String MERCHANT_DELIVERY_LIST = "merchant:warehouse:delivery:list";
|
||||
public static final String MERCHANT_DELIVERY_CREATE = "merchant:warehouse:delivery:create";
|
||||
public static final String MERCHANT_INBOUND_LIST = "merchant:warehouse:inbound: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 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";
|
||||
|
||||
+5
-2
@@ -6,9 +6,11 @@ import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests;
|
||||
import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses;
|
||||
import com.baiyue.lastleg.trucking.service.UsTruckOrderService;
|
||||
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.PatchMapping;
|
||||
@@ -21,6 +23,7 @@ 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;
|
||||
|
||||
@@ -32,8 +35,8 @@ public class MerchantUsTruckOrderController {
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<PageResult<UsTruckOrderResponses.Summary>> page(@AuthenticationPrincipal AuthenticatedUser user,
|
||||
@Valid UsTruckOrderRequests.Query query) {
|
||||
return ApiResponse.success(service.page(customer(user), query));
|
||||
@Valid UsTruckOrderRequests.MerchantQuery query) {
|
||||
return ApiResponse.success(service.page(customer(user), query.scoped()));
|
||||
}
|
||||
|
||||
@GetMapping("/{orderNo}")
|
||||
|
||||
+10
@@ -53,6 +53,16 @@ public final class UsTruckOrderRequests {
|
||||
String orderNo, String routeKeyword, String status, String transportType,
|
||||
String customerId, LocalDate requestedFrom, LocalDate requestedTo) {}
|
||||
|
||||
public record MerchantQuery(
|
||||
@Min(1) long page, @Min(1) @Max(200) long size,
|
||||
String orderNo, String routeKeyword, String status, String transportType,
|
||||
LocalDate requestedFrom, LocalDate requestedTo) {
|
||||
public Query scoped() {
|
||||
return new Query(page, size, orderNo, routeKeyword, status, transportType,
|
||||
null, requestedFrom, requestedTo);
|
||||
}
|
||||
}
|
||||
|
||||
public record Update(
|
||||
@NotNull @Min(0) Long version,
|
||||
@Size(max = 32) String transportType,
|
||||
|
||||
@@ -86,7 +86,7 @@ paths:
|
||||
description: 权限 lastleg:pda:operate;FBA 从完整 cartonMark 的第一个 # 前提取固定入仓码,并精确匹配当前锁定柜唯一 FBA line;不要求逐箱 manifest,不校验 SKU 或箱序号。OVERSEAS 必须命中真实 manifest。两者均不返回 SKU,也不登记收货。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { type: object, required: [cartonMark], properties: { cartonMark: { type: string } } } } } }
|
||||
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: 缺少 #、空入仓码前缀、跨柜或当前柜多重命中 } }
|
||||
responses: { '200': { description: 箱唛与入仓码信息, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCartonResolvedApiResponse' } } } }, '404': { description: 入仓码未命中当前柜,或 OVERSEAS 箱码未命中真实 manifest }, '409': { description: '缺少 #、空入仓码前缀、跨柜或当前柜多重命中' } }
|
||||
/api/pda/v1/last-leg/receiving-containers/current/pallets/resolve:
|
||||
post:
|
||||
summary: 解析当前柜实物托盘码
|
||||
@@ -714,20 +714,125 @@ paths:
|
||||
/api/merchant/v1/last-leg/overseas-procurements:
|
||||
post:
|
||||
summary: 客户创建海外采购订单
|
||||
description: 客户 ID 只取认证身份;新单固定进入 PENDING_REVIEW。
|
||||
description: 权限 merchant:cross-border:procurement;客户 ID 只取认证身份且不接收 customerId;新单固定进入 PENDING_REVIEW。同幂等键同请求返回既有订单,异请求返回 A0409。
|
||||
security: [{ bearerAuth: [] }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementCreate' } } } }
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
|
||||
responses: { '200': { description: 采购订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementDetailResponse' } } } }, '409': { description: 幂等冲突 } }
|
||||
get:
|
||||
summary: 客户分页查询自己的海外采购订单
|
||||
description: 权限 merchant:cross-border:procurement;客户范围只取认证 Token,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的采购订单分页 } }
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: orderNo, in: query, schema: { type: string } }
|
||||
- { name: purchaseCategory, in: query, schema: { type: string } }
|
||||
- { name: productName, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { type: string } }
|
||||
- { name: purchaseWarehouseId, in: query, schema: { type: string } }
|
||||
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
||||
responses: { '200': { description: 当前客户采购订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/ProcurementPageResponse' } } } } }
|
||||
/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:
|
||||
get:
|
||||
summary: 客户查询自己的海外采购订单详情及流水
|
||||
description: 权限 merchant:cross-border:procurement;列表权限包含详情只读,客户范围只取认证 Token。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 统一 ApiResponse 包装的采购订单详情 } }
|
||||
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。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: orderNo, in: query, schema: { type: string } }
|
||||
- { name: routeKeyword, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { $ref: '#/components/schemas/TruckStatus' } }
|
||||
- { name: transportType, in: query, schema: { type: string } }
|
||||
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
|
||||
- { name: requestedTo, in: query, schema: { type: string, format: date } }
|
||||
responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } }
|
||||
post:
|
||||
summary: 客户创建美国本土卡订单
|
||||
description: 权限 merchant:cross-border:domestic-trucking;新单固定进入 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: 幂等冲突 } }
|
||||
/api/merchant/v1/last-leg/us-truck-orders/{orderNo}:
|
||||
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
|
||||
get:
|
||||
summary: 客户查询自己的美国本土卡订单详情
|
||||
description: 权限 merchant:cross-border:domestic-trucking;不泄露其他客户订单。
|
||||
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 必填;不能修改状态。
|
||||
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。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: orderNo, in: query, schema: { type: string } }
|
||||
- { name: routeKeyword, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } }
|
||||
- { name: transportMode, in: query, schema: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } }
|
||||
- { name: requestedFrom, in: query, schema: { type: string, format: date } }
|
||||
- { name: requestedTo, in: query, schema: { type: string, format: date } }
|
||||
responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } }
|
||||
post:
|
||||
summary: 客户创建本土散板订单
|
||||
description: 权限 merchant:cross-border:pallet-shipping;新单固定进入 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: 幂等冲突 } }
|
||||
/api/merchant/v1/last-leg/local-looseboard-orders/{orderNo}:
|
||||
parameters: [{ name: orderNo, in: path, required: true, schema: { type: string } }]
|
||||
get:
|
||||
summary: 客户查询自己的本土散板订单详情
|
||||
description: 权限 merchant:cross-border:pallet-shipping;不泄露其他客户订单。
|
||||
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 必填;不能修改状态。
|
||||
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: 版本或状态冲突 } }
|
||||
/api/merchant/v1/last-leg/inbound-plans:
|
||||
get:
|
||||
summary: 客户分页查询自己的海外入库订单
|
||||
description: 权限 merchant:warehouse:inbound:list;客户范围只取认证 Token,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: planNo, in: query, schema: { type: string } }
|
||||
- { name: inboundCode, in: query, schema: { type: string } }
|
||||
- { name: status, in: query, schema: { type: string } }
|
||||
- { name: overseasWarehouseId, in: query, schema: { type: string } }
|
||||
- { name: destinationWarehouseCode, in: query, schema: { type: string } }
|
||||
- { name: appointmentStatus, in: query, schema: { type: string, enum: [UNAPPOINTED, PENDING, SUCCESS, FAILED, CANCELLED] } }
|
||||
- { name: dispatchStatus, in: query, schema: { type: string, enum: [UNSCHEDULED, SCHEDULED] } }
|
||||
- { name: createdFrom, in: query, schema: { type: string, format: date-time } }
|
||||
- { name: createdTo, in: query, schema: { type: string, format: date-time } }
|
||||
responses: { '200': { description: 当前客户入库订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanPageResponse' } } } } }
|
||||
/api/merchant/v1/last-leg/inbound-plans/{planNo}:
|
||||
get:
|
||||
summary: 客户查询自己的海外入库订单详情
|
||||
description: 权限 merchant:warehouse:inbound:list;列表权限包含详情只读,不接收 customerId。
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters: [{ name: planNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 当前客户入库订单详情, content: { application/json: { schema: { $ref: '#/components/schemas/InboundPlanDetailResponse' } } } }, '404': { description: 当前客户下入库订单不存在 } }
|
||||
/api/platform/v1/last-leg/us-truck-orders:
|
||||
get:
|
||||
summary: 分页查询本土卡车订单
|
||||
@@ -1570,6 +1675,325 @@ components:
|
||||
properties:
|
||||
status: { $ref: '#/components/schemas/RelabelStatus' }
|
||||
remark: { type: string, nullable: true, maxLength: 1000 }
|
||||
MerchantApiResponse:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: { type: string, example: '200' }
|
||||
message: { type: string, nullable: true }
|
||||
data: { nullable: true }
|
||||
requestId: { type: string, nullable: true }
|
||||
traceId: { type: string, nullable: true }
|
||||
timestamp: { type: string, format: date-time, nullable: true }
|
||||
ProcurementPageResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/ProcurementPage' } }
|
||||
ProcurementDetailResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/ProcurementDetail' } }
|
||||
TruckPageResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/TruckPage' } }
|
||||
TruckDetailResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/TruckDetail' } }
|
||||
LooseboardPageResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/LooseboardPage' } }
|
||||
LooseboardDetailResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/LooseboardDetail' } }
|
||||
InboundPlanPageResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/InboundPlanPage' } }
|
||||
InboundPlanDetailResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/MerchantApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/InboundPlanDetail' } }
|
||||
PageMetadata:
|
||||
type: object
|
||||
required: [page, size, total, pages]
|
||||
properties:
|
||||
page: { type: integer, format: int64 }
|
||||
size: { type: integer, format: int64 }
|
||||
total: { type: integer, format: int64 }
|
||||
pages: { type: integer, format: int64 }
|
||||
ProcurementPage:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/PageMetadata' }
|
||||
- type: object
|
||||
required: [records]
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/ProcurementSummary' } } }
|
||||
ProcurementSummary:
|
||||
type: object
|
||||
required: [id, orderNo, customerId, purchaseCategory, productName, purchaseQuantity, inboundQuantity, outboundQuantity, inventoryQuantity, status, version, createdAt, updatedAt]
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
orderNo: { type: string }
|
||||
customerId: { type: string }
|
||||
customerName: { type: string, nullable: true }
|
||||
purchaseCategory: { type: string }
|
||||
productName: { type: string }
|
||||
productSpecification: { type: string, nullable: true }
|
||||
purchaseQuantity: { type: integer, format: int64 }
|
||||
budgetUnitPrice: { type: number, nullable: true }
|
||||
currency: { type: string, nullable: true }
|
||||
purchaseWarehouseId: { type: string, nullable: true }
|
||||
purchaseWarehouseName: { type: string, nullable: true }
|
||||
inboundQuantity: { type: integer, format: int64 }
|
||||
outboundQuantity: { type: integer, format: int64 }
|
||||
inventoryQuantity: { type: integer, format: int64 }
|
||||
status: { type: string, description: PENDING_REVIEW/APPROVED/REJECTED 或按审核后出库数量派生的 PENDING_OUTBOUND/PARTIALLY_OUTBOUND/OUTBOUNDED }
|
||||
version: { type: integer, format: int64 }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
ProcurementDetail:
|
||||
type: object
|
||||
required: [summary, histories, outboundDetails]
|
||||
properties:
|
||||
summary: { $ref: '#/components/schemas/ProcurementSummary' }
|
||||
productUrl: { type: string, nullable: true }
|
||||
receivingAddress: { type: string, nullable: true }
|
||||
contactName: { type: string, nullable: true }
|
||||
contactPhone: { type: string, nullable: true }
|
||||
domesticCarrier: { type: string, nullable: true }
|
||||
domesticTrackingNo: { type: string, nullable: true }
|
||||
overseasCarrier: { type: string, nullable: true }
|
||||
overseasTrackingNo: { type: string, nullable: true }
|
||||
estimatedArrivalAt: { type: string, format: date-time, nullable: true }
|
||||
remark: { type: string, nullable: true }
|
||||
createdBy: { type: string, nullable: true }
|
||||
reviewedBy: { type: string, nullable: true }
|
||||
reviewRemark: { type: string, nullable: true }
|
||||
reviewedAt: { type: string, format: date-time, nullable: true }
|
||||
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
|
||||
outboundDetails: { type: array, items: { $ref: '#/components/schemas/ProcurementOutboundDetail' } }
|
||||
ProcurementOutboundDetail:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
quantity: { type: integer, format: int64 }
|
||||
carrier: { type: string, nullable: true }
|
||||
trackingNo: { type: string, nullable: true }
|
||||
shippedAt: { type: string, format: date-time, nullable: true }
|
||||
remark: { type: string, nullable: true }
|
||||
TruckPage:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/PageMetadata' }
|
||||
- type: object
|
||||
required: [records]
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/TruckSummary' } } }
|
||||
TruckSummary:
|
||||
type: object
|
||||
required: [id, customerId, orderNo, origin, destination, palletCount, packageCount, receivedPalletCount, allocatedPalletCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
customerId: { type: string }
|
||||
orderNo: { type: string }
|
||||
transportType: { type: string, nullable: true }
|
||||
origin: { type: string }
|
||||
destination: { type: string }
|
||||
containerType: { type: string, nullable: true }
|
||||
palletCount: { type: integer }
|
||||
packageCount: { type: integer }
|
||||
receivedPalletCount: { type: integer }
|
||||
allocatedPalletCount: { type: integer }
|
||||
totalWeightKg: { type: number }
|
||||
totalVolumeCbm: { type: number }
|
||||
budgetAmount: { type: number, nullable: true }
|
||||
currency: { type: string, nullable: true }
|
||||
contactName: { type: string, nullable: true }
|
||||
contactInfo: { type: string, nullable: true }
|
||||
requestedDate: { type: string, format: date, nullable: true }
|
||||
status: { $ref: '#/components/schemas/TruckStatus' }
|
||||
version: { type: integer, format: int64 }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
TruckDetail:
|
||||
type: object
|
||||
required: [summary, pallets, histories]
|
||||
properties:
|
||||
summary: { $ref: '#/components/schemas/TruckSummary' }
|
||||
originAddress: { type: string, nullable: true }
|
||||
destinationAddress: { type: string, nullable: true }
|
||||
remark: { type: string, nullable: true }
|
||||
createdBy: { type: string, nullable: true }
|
||||
pallets: { type: array, items: { $ref: '#/components/schemas/TruckPallet' } }
|
||||
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
|
||||
TruckPallet:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
palletNo: { type: string }
|
||||
sequenceNo: { type: integer }
|
||||
source: { type: string }
|
||||
status: { type: string }
|
||||
warehousePalletId: { type: string, pattern: '^[0-9]+$', nullable: true }
|
||||
packageCount: { type: integer, nullable: true }
|
||||
receivedAt: { type: string, format: date-time, nullable: true }
|
||||
LooseboardPage:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/PageMetadata' }
|
||||
- type: object
|
||||
required: [records]
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/LooseboardSummary' } } }
|
||||
LooseboardSummary:
|
||||
type: object
|
||||
required: [id, customerId, orderNo, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt]
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
customerId: { type: string }
|
||||
orderNo: { type: string }
|
||||
transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] }
|
||||
palletRequired: { type: string, enum: [YES, NO] }
|
||||
transportType: { type: string }
|
||||
origin: { type: string }
|
||||
destination: { type: string }
|
||||
containerType: { type: string, nullable: true }
|
||||
packageCount: { type: integer }
|
||||
totalWeightKg: { type: number }
|
||||
totalVolumeCbm: { type: number }
|
||||
budgetAmount: { type: number, nullable: true }
|
||||
currency: { type: string, nullable: true }
|
||||
contactName: { type: string, nullable: true }
|
||||
contactInfo: { type: string, nullable: true }
|
||||
requestedDate: { type: string, format: date, nullable: true }
|
||||
status: { $ref: '#/components/schemas/LooseboardStatus' }
|
||||
version: { type: integer, format: int64 }
|
||||
createdAt: { type: string, format: date-time }
|
||||
updatedAt: { type: string, format: date-time }
|
||||
LooseboardDetail:
|
||||
type: object
|
||||
required: [summary, histories]
|
||||
properties:
|
||||
summary: { $ref: '#/components/schemas/LooseboardSummary' }
|
||||
originAddress: { type: string, nullable: true }
|
||||
destinationAddress: { type: string, nullable: true }
|
||||
remark: { type: string, nullable: true }
|
||||
createdBy: { type: string, nullable: true }
|
||||
histories: { type: array, items: { $ref: '#/components/schemas/BusinessHistory' } }
|
||||
BusinessHistory:
|
||||
type: object
|
||||
properties:
|
||||
operationType: { type: string }
|
||||
fromStatus: { type: string, nullable: true }
|
||||
toStatus: { type: string, nullable: true }
|
||||
operatorId: { type: string, nullable: true }
|
||||
idempotencyKey: { type: string, nullable: true }
|
||||
changeDetail: { type: string, nullable: true }
|
||||
remark: { type: string, nullable: true }
|
||||
createdAt: { type: string, format: date-time }
|
||||
InboundPlanPage:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/PageMetadata' }
|
||||
- type: object
|
||||
required: [records]
|
||||
properties: { records: { type: array, items: { $ref: '#/components/schemas/InboundPlanSummary' } } }
|
||||
InboundPlanSummary:
|
||||
type: object
|
||||
required: [lineId, planId, planNo, customerId, orderId, inboundCode, packageCount, pickedPackageCount, palletCount, status, createdAt, version]
|
||||
properties:
|
||||
lineId: { type: string, pattern: '^[0-9]+$' }
|
||||
planId: { type: string, pattern: '^[0-9]+$' }
|
||||
planNo: { type: string }
|
||||
customerId: { type: string }
|
||||
orderId: { type: string, pattern: '^[0-9]+$' }
|
||||
inboundCode: { type: string }
|
||||
orderType: { type: string, nullable: true }
|
||||
warehouseId: { type: string, nullable: true }
|
||||
warehouseCode: { type: string, nullable: true }
|
||||
warehouseName: { type: string, nullable: true }
|
||||
fbaWarehouseCode: { type: string, nullable: true }
|
||||
customerName: { type: string, nullable: true }
|
||||
packageCount: { type: integer }
|
||||
pickedPackageCount: { type: integer }
|
||||
volumeCbm: { type: number, nullable: true }
|
||||
palletCount: { type: integer }
|
||||
status: { type: string }
|
||||
appointmentNo: { type: string, nullable: true }
|
||||
appointmentStatus: { type: string, nullable: true }
|
||||
appointmentAt: { type: string, format: date-time, nullable: true }
|
||||
expectedLoadingAt: { type: string, format: date-time, nullable: true }
|
||||
dispatchStatus: { type: string, nullable: true }
|
||||
createdAt: { type: string, format: date-time }
|
||||
version: { type: integer }
|
||||
InboundPlanDetail:
|
||||
type: object
|
||||
required: [id, planNo, customerId, status, lineCount, palletCount, totalVolumeCbm, expectedPackageCount, pickedPackageCount, lines, pallets, allocations, version]
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
planNo: { type: string }
|
||||
customerId: { type: string }
|
||||
sourceOrderId: { type: string, pattern: '^[0-9]+$', nullable: true }
|
||||
containerNo: { type: string, nullable: true }
|
||||
status: { type: string }
|
||||
lineCount: { type: integer }
|
||||
palletCount: { type: integer }
|
||||
totalVolumeCbm: { type: number }
|
||||
expectedPackageCount: { type: integer }
|
||||
pickedPackageCount: { type: integer }
|
||||
lines: { type: array, items: { $ref: '#/components/schemas/InboundPlanLine' } }
|
||||
pallets: { type: array, items: { $ref: '#/components/schemas/InboundPlanPallet' } }
|
||||
allocations: { type: array, items: { $ref: '#/components/schemas/InboundPlanAllocation' } }
|
||||
version: { type: integer }
|
||||
InboundPlanLine:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
orderId: { type: string, pattern: '^[0-9]+$' }
|
||||
inboundCode: { type: string }
|
||||
fbaWarehouseCode: { type: string, nullable: true }
|
||||
destinationWarehouseId: { type: string, nullable: true }
|
||||
destinationWarehouseCode: { type: string, nullable: true }
|
||||
destinationWarehouseName: { type: string, nullable: true }
|
||||
volumeCbm: { type: number }
|
||||
requiredPackageCount: { type: integer }
|
||||
plannedPalletCount: { type: integer }
|
||||
InboundPlanPallet:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string, pattern: '^[0-9]+$' }
|
||||
palletNo: { type: string }
|
||||
destinationWarehouseId: { type: string, nullable: true }
|
||||
destinationWarehouseCode: { type: string, nullable: true }
|
||||
destinationWarehouseName: { type: string, nullable: true }
|
||||
capacityCbm: { type: number }
|
||||
allocatedVolumeCbm: { type: number }
|
||||
sequenceNo: { type: integer }
|
||||
warehousePalletId: { type: string, pattern: '^[0-9]+$', nullable: true }
|
||||
warehouseLocationId: { type: string, pattern: '^[0-9]+$', nullable: true }
|
||||
warehouseResourceLockId: { type: string, pattern: '^[0-9]+$', nullable: true }
|
||||
assignmentStatus: { type: string, nullable: true }
|
||||
lockedAt: { type: string, format: date-time, nullable: true }
|
||||
actualVolumeCbm: { type: number, nullable: true }
|
||||
actualPackageCount: { type: integer }
|
||||
InboundPlanAllocation:
|
||||
type: object
|
||||
properties:
|
||||
lineId: { type: string, pattern: '^[0-9]+$' }
|
||||
palletId: { type: string, pattern: '^[0-9]+$' }
|
||||
allocatedVolumeCbm: { type: number }
|
||||
plannedPackageCount: { type: integer }
|
||||
countedPackageCount: { type: integer }
|
||||
pickedPackageCount: { type: integer }
|
||||
actualVolumeCbm: { type: number, nullable: true }
|
||||
status: { type: string }
|
||||
version: { type: integer }
|
||||
ProcurementStatus: { type: string, enum: [PENDING_REVIEW, APPROVED, REJECTED] }
|
||||
ProcurementReview:
|
||||
type: object
|
||||
|
||||
@@ -75,33 +75,70 @@ paths:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
|
||||
- { name: status, in: query, schema: { type: string, enum: [IN_PROGRESS, SUBMITTED] } }
|
||||
responses: { '200': { description: 盘点任务分页 } }
|
||||
responses:
|
||||
'200': { description: 盘点任务分页, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSummaryPageResponse' } } } }
|
||||
post:
|
||||
summary: 按库位或托盘创建盘点任务
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCreate' } } } }
|
||||
responses: { '200': { description: 创建后的完整盘点任务 } }
|
||||
responses:
|
||||
'200': { description: 创建后的完整盘点任务, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } }
|
||||
/api/pda/v1/last-leg/stocktakes/{taskNo}:
|
||||
get:
|
||||
summary: 查询盘点任务详情
|
||||
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 盘点任务及冻结库存位置 } }
|
||||
responses:
|
||||
'200': { description: 盘点任务及冻结库存位置, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } }
|
||||
/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:
|
||||
post:
|
||||
summary: 提交一个库存位置的实盘箱数
|
||||
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeCount' } } } }
|
||||
responses: { '200': { description: 已计数明细 }, '409': { description: 版本或幂等冲突 } }
|
||||
responses:
|
||||
'200': { description: 已计数明细, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeItemResponse' } } } }
|
||||
'409': { description: 版本或幂等冲突 }
|
||||
/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:
|
||||
post:
|
||||
summary: 提交盘点并按差异调整库存
|
||||
parameters: [{ name: taskNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeSubmit' } } } }
|
||||
responses: { '200': { description: 已提交盘点任务 }, '409': { description: 未全盘、库存版本变化或负库存风险 } }
|
||||
responses:
|
||||
'200': { description: 已提交盘点任务, content: { application/json: { schema: { $ref: '#/components/schemas/StocktakeDetailResponse' } } } }
|
||||
'409': { description: 未全盘、库存版本变化或负库存风险 }
|
||||
/api/pda/v1/last-leg/inventory-moves:
|
||||
post:
|
||||
summary: 同仓整托移库
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMove' } } } }
|
||||
responses: { '200': { description: 移库结果 }, '409': { description: 跨仓、资源或库存版本冲突 } }
|
||||
responses:
|
||||
'200': { description: 移库结果, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryMoveResponse' } } } }
|
||||
'409': { description: 跨仓、资源或库存版本冲突 }
|
||||
/api/pda/v1/last-leg/inventory/resolve:
|
||||
post:
|
||||
summary: 扫码查询Token绑定仓库存
|
||||
description: 支持箱唛、SKU、入仓码、托盘码或库位码;不接收customerId或warehouseId。
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
|
||||
responses:
|
||||
'200': { description: 匹配的库存位置列表, content: { application/json: { schema: { $ref: '#/components/schemas/InventoryScanResponse' } } } }
|
||||
/api/pda/v1/last-leg/locations/resolve:
|
||||
post:
|
||||
summary: 扫码查询Token绑定仓库位
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
|
||||
responses:
|
||||
'200': { description: 库位及占用摘要, content: { application/json: { schema: { $ref: '#/components/schemas/LocationResponse' } } } }
|
||||
/api/pda/v1/last-leg/pallets/resolve:
|
||||
post:
|
||||
summary: 扫码查询Token绑定仓托盘
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ScanCode' } } } }
|
||||
responses:
|
||||
'200': { description: 托盘及其库存内容, content: { application/json: { schema: { $ref: '#/components/schemas/PalletResponse' } } } }
|
||||
/api/pda/v1/last-leg/operations:
|
||||
get:
|
||||
summary: 查询当前操作人的仓内操作历史
|
||||
parameters:
|
||||
- { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } }
|
||||
- { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||
- { name: operationType, in: query, required: false, schema: { type: string } }
|
||||
responses:
|
||||
'200': { description: 当前Token绑定仓及操作人的历史分页, content: { application/json: { schema: { $ref: '#/components/schemas/OperationPageResponse' } } } }
|
||||
/api/pda/v1/last-leg/pallet-operations/pallets/resolve:
|
||||
post:
|
||||
summary: 扫码查询托盘及库存内容
|
||||
@@ -130,58 +167,355 @@ paths:
|
||||
/api/pda/v1/last-leg/relabel-tasks/candidates:
|
||||
get:
|
||||
summary: 查询Token绑定仓可执行换标任务
|
||||
responses: { '200': { description: 待认领、处理中或待复核换标任务 } }
|
||||
responses:
|
||||
'200': { description: 待认领、处理中或待复核换标任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelCandidatesResponse' } } } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}:
|
||||
get:
|
||||
summary: 查询换标现场任务详情
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
responses: { '200': { description: 申请明细、逐件事实、照片与历史 } }
|
||||
responses:
|
||||
'200': { description: 申请明细、逐件事实、照片与历史, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim:
|
||||
post:
|
||||
summary: 认领换标任务并推进申请至处理中
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/IdempotentAction' } } } }
|
||||
responses: { '200': { description: 已认领任务 }, '409': { description: 仓库、状态或幂等冲突 } }
|
||||
responses: { '200': { description: 已认领任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 仓库、状态或幂等冲突 } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:
|
||||
post:
|
||||
summary: 登记一件正常换标事实
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelUnit' } } } }
|
||||
responses: { '200': { description: 更新后的任务 }, '409': { description: 旧标重复、新标不符或版本冲突 } }
|
||||
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 旧标重复、新标不符或版本冲突 } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:
|
||||
post:
|
||||
summary: 登记一件有原因的跳过事实
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelSkip' } } } }
|
||||
responses: { '200': { description: 更新后的任务 } }
|
||||
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:
|
||||
post:
|
||||
summary: 登记一件换标异常事实
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelException' } } } }
|
||||
responses: { '200': { description: 更新后的任务 } }
|
||||
responses: { '200': { description: 更新后的任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:
|
||||
post:
|
||||
summary: 上传换标完成现场照片
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { multipart/form-data: { schema: { type: object, required: [file], properties: { file: { type: string, format: binary } } } } } }
|
||||
responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据 } }
|
||||
responses: { '200': { description: OVERSEAS_RELABEL_PHOTO文件元数据, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelPhotoResponse' } } } } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:
|
||||
post:
|
||||
summary: 提交现场任务等待复核,不提前完成申请
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelComplete' } } } }
|
||||
responses: { '200': { description: WAIT_REVIEW任务 }, '409': { description: 箱数未核平、照片或版本冲突 } }
|
||||
responses: { '200': { description: WAIT_REVIEW任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 箱数未核平、照片或版本冲突 } }
|
||||
/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:
|
||||
post:
|
||||
summary: 现场复核;APPROVE才完成既有换标申请,REWORK退回处理
|
||||
parameters: [{ name: relabelNo, in: path, required: true, schema: { type: string } }]
|
||||
requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelReview' } } } }
|
||||
responses: { '200': { description: COMPLETED或PROCESSING任务 }, '409': { description: 状态或版本冲突 } }
|
||||
responses: { '200': { description: COMPLETED或PROCESSING任务, content: { application/json: { schema: { $ref: '#/components/schemas/RelabelDetailResponse' } } } }, '409': { description: 状态或版本冲突 } }
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT }
|
||||
schemas:
|
||||
ApiResponse:
|
||||
type: object
|
||||
required: [code, data]
|
||||
properties:
|
||||
code: { type: string, example: '200' }
|
||||
message: { type: string, nullable: true }
|
||||
data: { nullable: true }
|
||||
requestId: { type: string, nullable: true }
|
||||
traceId: { type: string, nullable: true }
|
||||
timestamp: { type: string, format: date-time, nullable: true }
|
||||
StocktakeSummaryPageResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/StocktakeSummaryPage' } }
|
||||
StocktakeDetailResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/StocktakeDetail' } }
|
||||
StocktakeItemResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/StocktakeItem' } }
|
||||
InventoryMoveResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/InventoryMoveResult' } }
|
||||
InventoryScanResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/InventoryScan' } }
|
||||
LocationResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/WarehouseLocation' } }
|
||||
PalletResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/WarehousePallet' } }
|
||||
OperationPageResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/OperationPage' } }
|
||||
RelabelCandidatesResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties:
|
||||
data: { type: array, items: { $ref: '#/components/schemas/RelabelCandidate' } }
|
||||
RelabelDetailResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/RelabelDetail' } }
|
||||
RelabelPhotoResponse:
|
||||
allOf:
|
||||
- { $ref: '#/components/schemas/ApiResponse' }
|
||||
- type: object
|
||||
properties: { data: { $ref: '#/components/schemas/RelabelPhoto' } }
|
||||
StocktakeSummaryPage:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: { type: array, items: { $ref: '#/components/schemas/StocktakeSummary' } }
|
||||
page: { type: integer, format: int64 }
|
||||
size: { type: integer, format: int64 }
|
||||
total: { type: integer, format: int64 }
|
||||
pages: { type: integer, format: int64 }
|
||||
StocktakeSummary:
|
||||
type: object
|
||||
required: [taskNo, scopeType, scopeCode, status, positionCount, countedCount, differenceCount, version, createdAt]
|
||||
properties:
|
||||
taskNo: { type: string }
|
||||
scopeType: { type: string, enum: [LOCATION, PALLET] }
|
||||
scopeCode: { type: string }
|
||||
status: { type: string, enum: [IN_PROGRESS, SUBMITTED] }
|
||||
positionCount: { type: integer }
|
||||
countedCount: { type: integer }
|
||||
differenceCount: { type: integer }
|
||||
version: { type: integer, format: int64 }
|
||||
createdAt: { type: string, format: date-time }
|
||||
submittedAt: { type: string, format: date-time, nullable: true }
|
||||
StocktakeItem:
|
||||
type: object
|
||||
required: [itemId, customerId, balanceId, positionId, skuCode, inboundCode, palletId, palletCode, locationId, locationCode, bookPackages, status, positionVersion, version]
|
||||
properties:
|
||||
itemId: { type: string, pattern: '^[0-9]+$' }
|
||||
customerId: { type: string }
|
||||
balanceId: { type: string, pattern: '^[0-9]+$' }
|
||||
positionId: { type: string, pattern: '^[0-9]+$' }
|
||||
skuCode: { type: string }
|
||||
productName: { type: string, nullable: true }
|
||||
inboundCode: { type: string }
|
||||
palletId: { type: string, pattern: '^[0-9]+$' }
|
||||
palletCode: { type: string }
|
||||
locationId: { type: string, pattern: '^[0-9]+$' }
|
||||
locationCode: { type: string }
|
||||
bookPackages: { type: integer, format: int64 }
|
||||
actualPackages: { type: integer, format: int64, nullable: true }
|
||||
differencePackages: { type: integer, format: int64, nullable: true }
|
||||
status: { type: string }
|
||||
positionVersion: { type: integer, format: int64 }
|
||||
version: { type: integer, format: int64 }
|
||||
StocktakeDetail:
|
||||
type: object
|
||||
required: [task, items]
|
||||
properties:
|
||||
task: { $ref: '#/components/schemas/StocktakeSummary' }
|
||||
items: { type: array, items: { $ref: '#/components/schemas/StocktakeItem' } }
|
||||
InventoryMoveResult:
|
||||
type: object
|
||||
required: [moveNo, palletId, palletCode, sourceLocationId, sourceLocationCode, targetLocationId, targetLocationCode, customerCount, positionCount, packageCount, palletVersion, status, completedAt, replayed]
|
||||
properties:
|
||||
moveNo: { type: string }
|
||||
palletId: { type: string, pattern: '^[0-9]+$' }
|
||||
palletCode: { type: string }
|
||||
sourceLocationId: { type: string, pattern: '^[0-9]+$' }
|
||||
sourceLocationCode: { type: string }
|
||||
targetLocationId: { type: string, pattern: '^[0-9]+$' }
|
||||
targetLocationCode: { type: string }
|
||||
customerCount: { type: integer }
|
||||
positionCount: { type: integer }
|
||||
packageCount: { type: integer, format: int64 }
|
||||
palletVersion: { type: integer }
|
||||
status: { type: string }
|
||||
completedAt: { type: string, format: date-time }
|
||||
replayed: { type: boolean }
|
||||
InventoryScan:
|
||||
type: object
|
||||
required: [queryCode, items]
|
||||
properties:
|
||||
queryCode: { type: string }
|
||||
items: { type: array, items: { $ref: '#/components/schemas/InventoryItem' } }
|
||||
InventoryItem:
|
||||
type: object
|
||||
required: [customerId, balanceId, positionId, skuCode, inboundCode, palletId, palletCode, availablePackages, holdPackages, receivingPackages, status, version]
|
||||
properties:
|
||||
customerId: { type: string }
|
||||
customerName: { type: string, nullable: true }
|
||||
balanceId: { type: string, pattern: '^[0-9]+$' }
|
||||
positionId: { type: string, pattern: '^[0-9]+$' }
|
||||
skuCode: { type: string }
|
||||
productName: { type: string, nullable: true }
|
||||
inboundCode: { type: string }
|
||||
cartonMark: { type: string, nullable: true }
|
||||
palletId: { type: string, pattern: '^[0-9]+$' }
|
||||
palletCode: { type: string }
|
||||
locationId: { type: string, pattern: '^[0-9]+$', nullable: true }
|
||||
locationCode: { type: string, nullable: true }
|
||||
availablePackages: { type: integer, format: int64 }
|
||||
holdPackages: { type: integer, format: int64 }
|
||||
receivingPackages: { type: integer, format: int64 }
|
||||
status: { type: string }
|
||||
version: { type: integer, format: int64 }
|
||||
WarehouseLocation:
|
||||
type: object
|
||||
required: [locationId, locationCode, locationType, status, occupiedPackages, inventoryPositionCount]
|
||||
properties:
|
||||
locationId: { type: string, pattern: '^[0-9]+$' }
|
||||
locationCode: { type: string }
|
||||
locationType: { type: string }
|
||||
maxLoadKg: { type: number, nullable: true }
|
||||
status: { type: string }
|
||||
occupiedPackages: { type: integer, format: int64 }
|
||||
inventoryPositionCount: { type: integer }
|
||||
WarehousePallet:
|
||||
type: object
|
||||
required: [palletId, palletCode, status, version, locationId, locationCode, occupiedPackages, inventoryPositionCount, inventory]
|
||||
properties:
|
||||
palletId: { type: string, pattern: '^[0-9]+$' }
|
||||
palletCode: { type: string }
|
||||
status: { type: string }
|
||||
version: { type: integer }
|
||||
locationId: { type: string, pattern: '^[0-9]+$' }
|
||||
locationCode: { type: string }
|
||||
occupiedPackages: { type: integer, format: int64 }
|
||||
inventoryPositionCount: { type: integer }
|
||||
inventory: { type: array, items: { $ref: '#/components/schemas/InventoryItem' } }
|
||||
OperationPage:
|
||||
type: object
|
||||
required: [records, page, size, total, pages]
|
||||
properties:
|
||||
records: { type: array, items: { $ref: '#/components/schemas/WarehouseOperation' } }
|
||||
page: { type: integer, format: int64 }
|
||||
size: { type: integer, format: int64 }
|
||||
total: { type: integer, format: int64 }
|
||||
pages: { type: integer, format: int64 }
|
||||
WarehouseOperation:
|
||||
type: object
|
||||
required: [operationId, operationType, businessKey, status, occurredAt]
|
||||
properties:
|
||||
operationId: { type: string, pattern: '^[0-9]+$' }
|
||||
operationType: { type: string }
|
||||
businessKey: { type: string }
|
||||
status: { type: string }
|
||||
summary: { type: string, nullable: true }
|
||||
customerId: { type: string, nullable: true }
|
||||
occurredAt: { type: string, format: date-time }
|
||||
RelabelCandidate:
|
||||
type: object
|
||||
required: [relabelNo, applicationStatus, taskStatus, itemCount, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, claimedByCurrentOperator]
|
||||
properties:
|
||||
relabelNo: { type: string }
|
||||
customerOrderNo: { type: string, nullable: true }
|
||||
labelDeadline: { type: string, format: date-time, nullable: true }
|
||||
applicationStatus: { type: string }
|
||||
taskStatus: { type: string }
|
||||
itemCount: { type: integer }
|
||||
expectedPackages: { type: integer }
|
||||
relabelledPackages: { type: integer }
|
||||
skippedPackages: { type: integer }
|
||||
exceptionPackages: { type: integer }
|
||||
version: { type: integer }
|
||||
claimedByCurrentOperator: { type: boolean }
|
||||
RelabelDetail:
|
||||
type: object
|
||||
required: [relabelNo, applicationStatus, taskStatus, expectedPackages, relabelledPackages, skippedPackages, exceptionPackages, version, completionPhotoFileIds, items, unitFacts, history]
|
||||
properties:
|
||||
relabelNo: { type: string }
|
||||
customerOrderNo: { type: string, nullable: true }
|
||||
labelDeadline: { type: string, format: date-time, nullable: true }
|
||||
contactName: { type: string, nullable: true }
|
||||
contactPhone: { type: string, nullable: true }
|
||||
applicationStatus: { type: string }
|
||||
taskStatus: { type: string }
|
||||
expectedPackages: { type: integer }
|
||||
relabelledPackages: { type: integer }
|
||||
skippedPackages: { type: integer }
|
||||
exceptionPackages: { type: integer }
|
||||
version: { type: integer }
|
||||
operatorName: { type: string, nullable: true }
|
||||
reviewerName: { type: string, nullable: true }
|
||||
completionPhotoFileIds: { type: array, items: { type: string, pattern: '^[0-9]+$' } }
|
||||
completionRemark: { type: string, nullable: true }
|
||||
reviewDecision: { type: string, nullable: true }
|
||||
reviewRemark: { type: string, nullable: true }
|
||||
claimedAt: { type: string, format: date-time, nullable: true }
|
||||
submittedAt: { type: string, format: date-time, nullable: true }
|
||||
reviewedAt: { type: string, format: date-time, nullable: true }
|
||||
items: { type: array, items: { $ref: '#/components/schemas/RelabelItem' } }
|
||||
unitFacts: { type: array, items: { $ref: '#/components/schemas/RelabelUnitFact' } }
|
||||
history: { type: array, items: { $ref: '#/components/schemas/RelabelHistory' } }
|
||||
RelabelItem:
|
||||
type: object
|
||||
properties:
|
||||
itemId: { type: string, pattern: '^[0-9]+$' }
|
||||
skuCode: { type: string, nullable: true }
|
||||
productNameZh: { type: string, nullable: true }
|
||||
shipmentNo: { type: string, nullable: true }
|
||||
trackingCode: { type: string, nullable: true }
|
||||
newSkuCode: { type: string, nullable: true }
|
||||
labelRequirement: { type: string, nullable: true }
|
||||
relabelFileId: { type: string, nullable: true }
|
||||
relabelFileName: { type: string, nullable: true }
|
||||
relabelFileUrl: { type: string, nullable: true }
|
||||
expectedPackages: { type: integer }
|
||||
relabelledPackages: { type: integer }
|
||||
skippedPackages: { type: integer }
|
||||
exceptionPackages: { type: integer }
|
||||
RelabelUnitFact:
|
||||
type: object
|
||||
properties:
|
||||
factId: { type: string, pattern: '^[0-9]+$' }
|
||||
itemId: { type: string, pattern: '^[0-9]+$' }
|
||||
oldLabel: { type: string, nullable: true }
|
||||
newLabel: { type: string, nullable: true }
|
||||
result: { type: string }
|
||||
exceptionCode: { type: string, nullable: true }
|
||||
reason: { type: string, nullable: true }
|
||||
operatorName: { type: string, nullable: true }
|
||||
occurredAt: { type: string, format: date-time }
|
||||
RelabelHistory:
|
||||
type: object
|
||||
properties:
|
||||
action: { type: string }
|
||||
fromStatus: { type: string, nullable: true }
|
||||
toStatus: { type: string, nullable: true }
|
||||
operatorName: { type: string, nullable: true }
|
||||
detail: { type: string, nullable: true }
|
||||
createdAt: { type: string, format: date-time }
|
||||
RelabelPhoto:
|
||||
type: object
|
||||
required: [fileId, fileName, contentType, sizeBytes]
|
||||
properties:
|
||||
fileId: { type: string, pattern: '^[0-9]+$' }
|
||||
fileName: { type: string }
|
||||
contentType: { type: string }
|
||||
sizeBytes: { type: integer, format: int64 }
|
||||
ScanCode:
|
||||
type: object
|
||||
required: [code]
|
||||
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.baiyue.lastleg.merchant;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
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.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.security.OverseasPermissions;
|
||||
import com.baiyue.lastleg.trucking.controller.MerchantUsTruckOrderController;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MerchantOverseasCustomerApiContractTest {
|
||||
@Mock LocalLooseboardService looseboardService;
|
||||
@Mock InboundPlanService inboundPlanService;
|
||||
private MerchantLocalLooseboardController looseboard;
|
||||
private MerchantInboundPlanController inbound;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
looseboard = new MerchantLocalLooseboardController();
|
||||
inbound = new MerchantInboundPlanController();
|
||||
ReflectionTestUtils.setField(looseboard, "service", looseboardService);
|
||||
ReflectionTestUtils.setField(inbound, "service", inboundPlanService);
|
||||
}
|
||||
|
||||
@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);
|
||||
assertController(MerchantInboundPlanController.class,
|
||||
"/api/merchant/v1/last-leg/inbound-plans", OverseasPermissions.MERCHANT_INBOUND_LIST);
|
||||
}
|
||||
|
||||
@Test
|
||||
void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() {
|
||||
AuthenticatedUser merchant = user(101L, "MERCHANT");
|
||||
LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20,
|
||||
null, null, null, null, null, null);
|
||||
InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null,
|
||||
null, null, null, null, null, null, null, null);
|
||||
|
||||
looseboard.page(merchant, looseboardQuery);
|
||||
inbound.page(merchant, inboundQuery);
|
||||
|
||||
verify(looseboardService).page("101", looseboardQuery);
|
||||
verify(inboundPlanService).page("101", inboundQuery);
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantQueryDtosDoNotAcceptCustomerScope() {
|
||||
for (Class<?> type : Set.of(LocalLooseboardRequests.MerchantQuery.class,
|
||||
com.baiyue.lastleg.procurement.dto.OverseasProcurementRequests.MerchantQuery.class,
|
||||
com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests.MerchantQuery.class,
|
||||
InboundPlanRequests.Query.class)) {
|
||||
assertThat(Arrays.stream(type.getRecordComponents()).map(component -> component.getName()))
|
||||
.doesNotContain("customerId", "warehouseId", "operatorId");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void merchantEndpointsRejectPlatformIdentityBeforeCallingServices() {
|
||||
AuthenticatedUser platform = user(0L, "PLATFORM");
|
||||
assertThatThrownBy(() -> looseboard.detail(platform, "LB-1"))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
|
||||
assertThatThrownBy(() -> inbound.detail(platform, "IP-1"))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("403");
|
||||
}
|
||||
|
||||
private static void assertController(Class<?> type, String path, String permission) {
|
||||
assertThat(type.getAnnotation(RequestMapping.class).value()).containsExactly(path);
|
||||
assertThat(type.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('" + permission + "')");
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(long customerId, String appCode) {
|
||||
return new AuthenticatedUser(7L, customerId, customerId, 8L, "operator", appCode, "WEB", Set.of());
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.lastleg.merchant;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class MerchantOverseasOpenApiContractTest {
|
||||
@Test
|
||||
void documentsFrozenMerchantPathsPermissionsResponsesAndConflictSemantics() throws Exception {
|
||||
String yaml;
|
||||
try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) {
|
||||
assertThat(input).isNotNull();
|
||||
yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
for (String path : new String[] {
|
||||
"/api/merchant/v1/last-leg/overseas-procurements:",
|
||||
"/api/merchant/v1/last-leg/overseas-procurements/{orderNo}:",
|
||||
"/api/merchant/v1/last-leg/us-truck-orders:",
|
||||
"/api/merchant/v1/last-leg/us-truck-orders/{orderNo}:",
|
||||
"/api/merchant/v1/last-leg/local-looseboard-orders:",
|
||||
"/api/merchant/v1/last-leg/local-looseboard-orders/{orderNo}:",
|
||||
"/api/merchant/v1/last-leg/inbound-plans:",
|
||||
"/api/merchant/v1/last-leg/inbound-plans/{planNo}:"}) {
|
||||
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")
|
||||
.contains("merchant:warehouse:inbound:list")
|
||||
.contains("#/components/schemas/ProcurementDetailResponse")
|
||||
.contains("#/components/schemas/TruckDetailResponse")
|
||||
.contains("#/components/schemas/LooseboardDetailResponse")
|
||||
.contains("#/components/schemas/InboundPlanDetailResponse")
|
||||
.contains("PENDING_REVIEW").contains("PENDING_ACCEPTANCE")
|
||||
.contains("version 必填").contains("幂等冲突");
|
||||
}
|
||||
|
||||
private static String pathBlock(String yaml, String path) {
|
||||
int start = yaml.indexOf(" " + path);
|
||||
int end = yaml.indexOf("\n /api/", start + path.length());
|
||||
return yaml.substring(start, end < 0 ? yaml.length() : end);
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -50,6 +50,10 @@ class PdaOperationsOpenApiContractTest {
|
||||
"/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:",
|
||||
"/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:",
|
||||
"/api/pda/v1/last-leg/inventory-moves:",
|
||||
"/api/pda/v1/last-leg/inventory/resolve:",
|
||||
"/api/pda/v1/last-leg/locations/resolve:",
|
||||
"/api/pda/v1/last-leg/pallets/resolve:",
|
||||
"/api/pda/v1/last-leg/operations:",
|
||||
"/api/pda/v1/last-leg/pallet-operations/pallets/resolve:",
|
||||
"/api/pda/v1/last-leg/pallet-operations/assemble:",
|
||||
"/api/pda/v1/last-leg/pallet-operations/disassemble:",
|
||||
@@ -74,12 +78,15 @@ class PdaOperationsOpenApiContractTest {
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pick:",
|
||||
"/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:");
|
||||
for (String path : paths) assertTrue(yaml.contains(path), path);
|
||||
assertEquals(28, paths.size());
|
||||
assertEquals(32, paths.size());
|
||||
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
|
||||
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
|
||||
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
|
||||
for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field);
|
||||
assertFalse(yaml.contains("/api/pda/v1/last-leg/delivery"));
|
||||
for (String response : List.of("StocktakeSummaryPageResponse", "StocktakeDetailResponse",
|
||||
"StocktakeItemResponse", "InventoryMoveResponse", "InventoryScanResponse", "LocationResponse",
|
||||
"PalletResponse", "OperationPageResponse", "RelabelCandidatesResponse", "RelabelDetailResponse",
|
||||
"RelabelPhotoResponse")) assertTrue(yaml.contains("#/components/schemas/" + response), response);
|
||||
}
|
||||
|
||||
private static void assertNoServerOwnedFields(Class<?> type) {
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ class PdaWarehouseContractTest {
|
||||
|
||||
@Test
|
||||
void openApiAndGatewayPublishOnlyStablePdaPaths() throws Exception {
|
||||
String openapi = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"));
|
||||
String openapi = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"));
|
||||
for (String path : Set.of("/api/pda/v1/last-leg/inventory/resolve:",
|
||||
"/api/pda/v1/last-leg/locations/resolve:", "/api/pda/v1/last-leg/pallets/resolve:",
|
||||
"/api/pda/v1/last-leg/operations:")) assertThat(openapi).contains(path);
|
||||
|
||||
+3
-3
@@ -33,9 +33,9 @@ class OverseasProcurementControllerTest {
|
||||
|
||||
@Test
|
||||
void merchantAlwaysUsesTenantFromTrustedIdentity() {
|
||||
var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null);
|
||||
var query = new OverseasProcurementRequests.MerchantQuery(1, 20, null, null, null, null, null, null, null);
|
||||
merchant.page(user(100L, "MERCHANT", Set.of()), query);
|
||||
verify(service).page("100", query);
|
||||
verify(service).page("100", query.scoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -48,7 +48,7 @@ class OverseasProcurementControllerTest {
|
||||
|
||||
@Test
|
||||
void merchantEndpointRejectsPlatformIdentity() {
|
||||
var query = new OverseasProcurementRequests.Query(1, 20, null, null, null, null, null, null, null, null);
|
||||
var query = new OverseasProcurementRequests.MerchantQuery(1, 20, null, null, null, null, null, null, null);
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> merchant.page(user(0L, "PLATFORM", Set.of()), query));
|
||||
assertEquals(403, error.getStatusCode().value());
|
||||
|
||||
+5
-3
@@ -31,8 +31,8 @@ class UsTruckOrderControllerTest {
|
||||
|
||||
@Test
|
||||
void merchantListAlwaysUsesTrustedTenant() {
|
||||
var query = query(); merchant.page(user(100L, "MERCHANT", Set.of()), query);
|
||||
verify(service).page("100", query);
|
||||
var query = merchantQuery(); merchant.page(user(100L, "MERCHANT", Set.of()), query);
|
||||
verify(service).page("100", query.scoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -92,7 +92,9 @@ class UsTruckOrderControllerTest {
|
||||
verify(service).update("100", "TK1", "7", request);
|
||||
}
|
||||
|
||||
private UsTruckOrderRequests.Query query() { return new UsTruckOrderRequests.Query(1, 20, null, null, null, null, null, null, null); }
|
||||
private UsTruckOrderRequests.MerchantQuery merchantQuery() {
|
||||
return new UsTruckOrderRequests.MerchantQuery(1, 20, null, null, null, null, null, null);
|
||||
}
|
||||
private AuthenticatedUser user(long tenant, String app, Set<String> permissions) {
|
||||
return new AuthenticatedUser(7L, tenant, tenant, 8L, "operator", app, "WEB", permissions);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user